Compare commits

..

4 commits

Author SHA1 Message Date
51a8e2bc50 fix(web): renomme login-smoke.steps.ts en login-smoke.ts
Le vrai (et seul) problème du run précédent : "Step implementation
missing for 'I am not signed in'" — pas un souci ESM/CJS cette fois,
juste une convention de nommage. Le pattern stepDefinitions par défaut
du preprocessor cherche, pour cypress/e2e/login-smoke.feature :

  - cypress/e2e/login-smoke/**/*.{js,mjs,ts,tsx}
  - cypress/e2e/login-smoke.{js,mjs,ts,tsx}   <- même basename, SANS ".steps"
  - cypress/support/step_definitions/**/*.{js,mjs,ts,tsx}

`login-smoke.steps.ts` ne correspond à aucun des trois. Confirmé par
le message d'erreur lui-même (Cypress liste les 3 patterns essayés).
2026-08-19 12:59:46 +02:00
99bb9fcfb2 test(web): feature Gherkin jetable pour valider le pipeline preprocessor
Étape 2 de l'expérimentation — un seul scénario minimal (remplir
email/password sur l'écran de connexion) pour vérifier que
addCucumberPreprocessorPlugin@22.2.0 + l'esbuild plugin fonctionnent
de bout en bout, pas juste au chargement. Steps volontairement
autonomes dans login-smoke.steps.ts (pas de fichier partagé) — tout
ce fichier est prévu pour être supprimé une fois validé.

cypress.config.ts : specPattern couvre maintenant *.cy.ts ET *.feature
en parallèle (le reste de la suite reste en .cy.ts classique pour
l'instant).

Testé en local jusqu'au mur GPU/Electron habituel de cet
environnement (chargement de la config + bundling esbuild passent,
pas d'ERR_REQUIRE_ESM) — la vraie exécution du scénario reste à
vérifier via la CI.
2026-08-19 12:55:44 +02:00
7a504748ac feat(web): installe cypress-cucumber-preprocessor@22.2.0, versions figées
Étape 1 de l'expérimentation. Versions exactes (pas de ^), comme
demandé, pour éviter que le prochain `pnpm install` fasse dériver la
résolution vers des patchs plus récents :

- @badeball/cypress-cucumber-preprocessor@22.2.0
- @bahmutov/cypress-esbuild-preprocessor@2.2.8
- cypress@13.17.0
- esbuild@0.21.5

Contrairement à v26.0.0 (utilisé dans la tentative précédente,
fermée), cette version charge sans ERR_REQUIRE_ESM — aucun
pnpm.overrides nécessaire cette fois. Vérifié :
- `import { addCucumberPreprocessorPlugin }` : OK
- `addCucumberPreprocessorPlugin(on, config)` avec un contexte Cypress
  minimal : résout sans erreur, enregistre tous ses event handlers
  (before:run, after:run, before:spec, after:spec, after:screenshot,
  task)

À vérifier ensuite : cypress.config.ts + un vrai fichier .feature.
2026-08-19 12:52:27 +02:00
7d812a7e9a chore: point de départ pour l'expérimentation Cucumber/Gherkin + Cypress
Repart de zéro (pas de reprise du travail précédent sur
feat/cypress-cucumber, fermée/supprimée) — voir la discussion sur la
PR pour le contexte : bug amont dans
@badeball/cypress-cucumber-preprocessor@26.0.0 (require() synchrone de
dépendances @cucumber/* désormais ESM pur, plusieurs incompatibilités
de schéma trouvées en épinglant d'anciennes versions).
2026-08-19 12:47:39 +02:00
297 changed files with 4723 additions and 39315 deletions

View file

@ -6,12 +6,6 @@
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "web", "dev"],
"port": 5173
},
{
"name": "api",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "api", "dev"],
"port": 3000
}
]
}

View file

@ -21,23 +21,3 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# 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

View file

@ -12,32 +12,22 @@ on:
push:
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.
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:
# 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
# "lint-and-test then e2e" pipeline.
lint:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
@ -55,80 +45,34 @@ jobs:
POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci
ports:
- 5433:5432
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
# `services:` (like the `postgres` container above) can only pull an
# already-published image — it can't build
# services/tech-step-intent-service/Dockerfile from this checkout.
# Running `uvicorn` as a plain background step instead: it keeps
# running for the rest of this job (GitHub Actions steps in one job
# share the same runner process tree), and `pnpm --filter api test`
# below needs a real instance to talk to per this repo's "never mock
# an internal service" test convention — same reasoning as the real
# `postgres` container just above, not a mock HTTP server.
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Start services/tech-step-intent-service in the background
working-directory: services/tech-step-intent-service
run: |
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~540s (fr) / ~390s (en),
# ~930s combined, against the current ~74-technique corpus (see
# docker-compose.yml's healthcheck for the same reasoning and why
# this grew slightly from the original ~670s).
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
- run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy
- run: pnpm --filter api test
intent-service-test:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Run pytest
working-directory: services/tech-step-intent-service
run: uv run pytest -q
build:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
@ -139,17 +83,17 @@ jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Cache Cypress binary
uses: https://github.com/actions/cache@v4
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@ -160,8 +104,3 @@ jobs:
# explicitly so `cypress run` finds it.
- run: pnpm --filter web exec cypress install
- run: pnpm --filter web e2e
# No dev server needed here — Cypress spins up its own Vite dev
# server internally for component testing (see cypress.config.ts's
# `component.devServer`), unlike `e2e` above which needs the real app
# running first.
- run: pnpm --filter web cy:run:component

13
.gitignore vendored
View file

@ -69,14 +69,6 @@ web_modules/
.env
.env.*
!.env.example
!.env.test.example
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
# is otherwise all-Node — see that service's own .gitignore for the rest;
# duplicated here too since some tooling only honors the repo-root file).
services/tech-step-intent-service/.venv/
services/tech-step-intent-service/__pycache__/
services/tech-step-intent-service/.pytest_cache/
# parcel-bundler cache (https://parceljs.org/)
.cache
@ -158,8 +150,3 @@ tmp-mockups/
# IA
.claude/
# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
apps/web/cypress/screenshots/
apps/web/cypress/videos/
apps/web/cypress/downloads/

View file

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

418
README.md
View file

@ -4,21 +4,13 @@
Monorepo pnpm workspaces :
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
recettes depuis des sources externes, préférences (thème, régime, allergies,
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
de dev — voir plus bas).
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
(`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
[specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
matching de recettes importées — voir plus bas) — même règles des deux
côtés, pas de risque de dérive entre front et back.
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
Page de connexion/inscription en place ; le reste est encore un squelette générique.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
même règles des deux côtés, pas de risque de dérive entre front et back.
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
@ -27,16 +19,11 @@ Monorepo pnpm workspaces :
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
"date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
— utilisés à la fois par `apps/api` (validation de date de planning) et
`apps/web` (grille/navigateur de semaine).
`packages/shared`, `packages/error-tools`, `packages/express-tools` et
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
`packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
volée), voir la note dans
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
## 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`)
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
- Docker (pour Postgres en local)
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
## Installation
@ -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
tien, voir le commentaire dans `apps/api/.env.example`).
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.
### Cypress : téléchargement du binaire
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
@ -98,21 +80,6 @@ docker compose up -d postgres
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
pnpm --filter api exec prisma migrate dev
# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
pnpm --filter api prisma:seed
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
# répondre quoi que ce soit sur /health.
cd services/tech-step-intent-service
uv sync
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
uv run uvicorn intent_service.main:app --reload --port 8000
cd ../..
# Backend (http://localhost:3000)
pnpm dev:api
@ -137,46 +104,15 @@ pnpm dev:web
## Qualité / Tests
Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
privés, règles Biome actives, logs côté serveur, etc.) :
[specs/dev-conventions.md](specs/dev-conventions.md).
```bash
pnpm lint # Biome (lint + format check)
pnpm lint:fix # Biome --write
pnpm test # tests unitaires/intégration (Mocha, apps/api)
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
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`).
### Base de test isolée de la base de dev (`apps/api`)
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
fichier **à créer toi-même**, pas fourni automatiquement :
```bash
cp apps/api/.env.test.example apps/api/.env.test
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
# différente (ex. batchcooking_test) — .env.test.example documente les
# commandes exactes pour la créer et lui appliquer le schéma.
```
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
seule base qu'il doit rejeter est ta vraie base de dev.
`services/tech-step-intent-service` doit aussi tourner en local avant
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
par le vrai service (jamais un mock, voir
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
section [Développement](#développement) ci-dessus.
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`).
## Déploiement
@ -190,22 +126,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
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
`node` : `prisma migrate deploy` (applique les migrations), puis
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
`server.ts` ne survit pas au changement de processus, voir
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
répéter à chaque redémarrage du conteneur.
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
coordonner entre deux origines, le frontend et l'API sont désormais servis
depuis la même origine. Les deux autres services du fichier
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle.
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
seul port, `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).
**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
@ -242,14 +166,10 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
l'email ou le mot de passe qui soit incorrect
- `POST /auth/logout` — efface le cookie (204)
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
ne l'incrémente encore — pas de route de changement d'email/mot de passe
aujourd'hui, seulement la suppression de compte.
Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée
toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas
encore implémenté).
> **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 —
@ -264,178 +184,171 @@ Les tests (Mocha) tournent avec un coût argon2 réduit
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
`prisma migrate deploy` avant les tests.
## Foyer — création, invitation, admin, sources externes (apps/api)
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).
- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
{ inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
si le profil a déjà un foyer.
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
l'adminship passe au membre restant le plus ancien ; si plus personne ne
reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
seulement, pas de self-retrait par cette route, utiliser `/leave`).
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
(voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
remplace (pas de fusion), opt-in (aucune source activée par défaut).
Détail complet (génération du code, transfert d'adminship) :
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).
> **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
> Lancer `pnpm test` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
## Planning (apps/api)
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
normaux confondus, jamais une erreur).
- `POST /planning/items` — ajoute une recette à un créneau :
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
indépendamment du rendement propre de la recette (`Recipe.portions`) — un
créneau peut mettre à l'échelle.
- `DELETE /planning/items/:id` — retire un item du planning.
- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
consommé tel quel par `apps/web`.
Le planning d'une semaine est créé à la demande (première recette ajoutée),
jamais en avance.
Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui
a mis au jour une contrainte générique trop stricte, corrigée à la source :
[specs/backend-architecture.md](specs/backend-architecture.md).
## 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=`
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
source externe non activée pour le foyer du viewer sont masquées de tous les
onglets.
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
`DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
planning), `POST`/`DELETE /recipes/:id/favorite`.
- **Import depuis une source externe** (`/sources`) : `GET
/sources/:sourceKey/browse` (parcourir), `GET
/sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
/sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
son ajout au planning (import transparent si tout est résolu) ou d'une revue
manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
"importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
(API officielle, catalogue anglais).
- `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées).
- `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }`
(le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de
nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique
`Allergy` sous cette catégorie).
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
synchronisation de la table `sources`) :
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
Les deux sont **publics** (pas de `requireAuth`) : ce sont des données de référence,
pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant
qu'un compte (donc une session) n'existe.
## 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`,
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
des données de référence, pas des données de foyer, et le wizard
d'inscription doit pouvoir les lire avant qu'un compte n'existe.
`Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc
spec d'origine) précisément pour permettre cet upsert idempotent par nom.
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
(Gluten/Sulfites). Détail complet du schéma :
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
standard, pas inventée.
## 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 à
l'utilisateur/au foyer, pas des données de référence.
## Foyer & profil — nom, régime, allergènes (apps/api)
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de
référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.
- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie
`null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée
toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil
n'a pas de foyer).
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
`null` efface le régime.
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
d'IDs, remplace (pas de fusion).
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
sécurité, juste un rappel discret sur la fiche recette), même contrat de
remplacement.
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
`SYSTEM`).
`null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
`dietId` ne correspond à aucun régime de référence.
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
ne correspond à aucun allergène de référence.
`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)
- `src/api/client.ts``ApiClient` (classe, instance unique exportée
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
pour que le cookie de session httpOnly parte/revienne — l'API et le front
sont sur des origines différentes). URL configurable via `VITE_API_URL`
(voir `.env.example`).
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
/auth/me` au chargement pour restaurer la session depuis le cookie ;
`deleteAccount()` pour la suppression de compte.
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
de route (react-router-dom) : l'espace connecté exige d'être connecté,
`/login` et `/signup` redirigent vers `/` si on l'est déjà.
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
- `src/api/client.ts``ApiClient` (classe, instance unique exportée `apiClient`) :
enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
de session httpOnly parte/revienne — l'API et le front sont sur des origines
différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
chargement pour restaurer la session depuis le cookie.
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
`/` si on l'est déjà.
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
schémas zod partagés (`packages/shared`), erreurs API traduites via
`ErrorMessageService` (voir ci-dessous).
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
[specs/frontend-architecture.md](specs/frontend-architecture.md).
## Sidebar, planning, recettes & sections (apps/web)
## Accueil, sidebar & sections (apps/web)
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx`
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
composant `ComingSoonPage``Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
parente, pourquoi un composant stub partagé) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
- **`/``PlanningPage`** : grille complète de la semaine (7 jours × 5
repas), navigation par semaine avec mini-calendrier, ajout via
`RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
prévisualiser avant de confirmer, import transparent en un clic si la
recette d'une source n'est pas encore résolue automatiquement, sinon revue
intégrée dans le même dialogue).
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
`RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
**plus un onglet par source externe activée pour le foyer**, tableau +
panneau de détail (surlignage des techniques détectées avec infobulle, icônes
d'ingrédients génériques, badges régime/allergènes/reproductible).
`/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
création/édition manuelle.
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
« Calcul batch-cooking » reste `TODO` (voir
[specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
- **`/parametres/*`** — Compte (identité + suppression), Préférences
(régime/allergies/ingrédients détestés), Foyer (création/invitation,
membres, sources activées), Préférences utilisateur (thème
clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).
## Parcours profil — foyer, régime, allergènes (apps/web)
Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
[specs/frontend-architecture.md](specs/frontend-architecture.md).
- `src/features/profile/``HouseNameField`, `DietSelect`, `AllergySelect` : champs
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile. Prend un
`legend` en prop (pas un libellé fixe interne) : le même composant est rendu
**deux fois** par chaque page consommatrice — une fois pour les allergies
(`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
(`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
`GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage`
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
chaque section sauvegarde automatiquement peu après la dernière modification —
nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
observerait la valeur — un tel effect se déclencherait aussi au chargement
initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
"vient d'être chargé" de "vient d'être modifié par l'utilisateur".
## 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 :
`/onboarding/regime``/onboarding/foyer``/onboarding/sources`
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton
"Continuer" qui envoie la valeur courante (y compris "aucune" pour
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
modification).
**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
page fetch son propre profil frais (`apiClient.me()`) au montage, et
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
régime pour que le reste de l'app reste cohérent aussi.
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts`) : mockent l'API via `cy.intercept`
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne pas de
Postgres/API, seulement le serveur de dev Vite. Le comportement réel de l'API est
couvert par la suite Mocha d'`apps/api` (contre une vraie base).
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU
@ -450,9 +363,8 @@ Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
## Gestion des erreurs (API ↔ web)
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
— et `ApiErrorResponse`) : l'API renvoie toujours
**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
quel), et le client traduit `code` en libellé français via **i18next**
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts`
@ -462,8 +374,8 @@ centralisent la transformation de toute erreur levée en réponse HTTP conforme
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
compris dans les mocks Cypress).
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
[specs/error-handling.md](specs/error-handling.md).
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
@ -479,21 +391,9 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
en ajouter une est une question de fichier de locale, pas de code. Les
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
`schema.prisma`), jamais stockés en base. Détail :
en ajouter une est une question de fichier de locale, pas de code. Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
## Thème clair / sombre / système (apps/web)
Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
`data-theme` n'est posé (choix "système", le défaut). Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).
## Données de test (faker.js)
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données

View file

@ -12,16 +12,3 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173
# INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see
# that service's own README) for any recipe save/preview to detect
# techniques at all. Must match that service's own INTENT_SERVICE_SECRET.
# Generate your own the same way as JWT_SECRET above.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed if you're running services/tech-step-llm-worker locally —
# every /internal/tech-steps/* request is rejected outright while unset.
# Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars

View file

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

View file

@ -2,6 +2,5 @@
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"node-option": ["import=tsx"],
"timeout": 10000,
"require": ["test-support/mocha-root-hooks.ts"]
"timeout": 10000
}

View file

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

View file

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

View file

@ -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";

View file

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

View file

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

View file

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

View file

@ -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");

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER,
ADD COLUMN "start" INTEGER;

View file

@ -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";

View file

@ -1,4 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
ADD COLUMN "context_start" INTEGER;

View file

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

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';

View file

@ -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");

View file

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

View file

@ -30,8 +30,6 @@ model House {
/// 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")
}
@ -39,11 +37,11 @@ model House {
/// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to
/// re-run, and so two reference rows can never silently duplicate the same
/// regime. A stable English camelCase uid (e.g. `"vegetarian"`), not the
/// display label — the label itself lives in `apps/web`'s
/// `locales/fr/translation.json` under `catalog.diets.<key>` (see
/// `reference-seed-data.ts`'s `DIETS`), so it can be edited/translated
/// without ever touching this column or the rows that reference it by id.
/// regime. A stable slug (e.g. `"vegetarien"`), not the display label —
/// the label itself lives in `apps/web`'s `locales/fr/translation.json`
/// under `catalog.diets.<key>` (see `reference-seed-data.ts`'s `DIETS` and
/// `utils/slugify.ts`), so it can be edited/translated without ever
/// touching this column or the rows that reference it by id.
model Diet {
id Int @id @default(autoincrement())
key String @unique
@ -121,10 +119,6 @@ model UserProfile {
/// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference?
/// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[]
@@map("user_profiles")
}
@ -205,7 +199,6 @@ model PlanningItem {
weekDay String @map("week_day")
meal String
recipeId Int @map("recipe_id")
portions Int
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id])
@ -217,61 +210,16 @@ model PlanningItem {
// Recipes
// -----------------------------------------------------------------------------
/// Catalog of implemented recipe sources (specific websites/APIs the
/// import pipeline knows how to talk to) — one row per adapter registered
/// in `apps/api/src/lib/recipe-source-registry.ts`, kept in sync by
/// `syncRecipeSources` (`apps/api/src/db/recipe-source-sync.ts`) rather
/// than hand-maintained like `DIETS`/`UNITS` (`reference-seed-data.ts`):
/// the adapter registry is the actual source of truth for "which sources
/// exist", this table just mirrors it so `Recipe.sourceId` has something
/// to point at. `key` matches `RecipeSourceAdapter.key` — same stable
/// English camelCase uid convention as `Diet.key`/`Unit.key`/`TechStep.key`.
/// Empty until a concrete adapter is registered (none exists yet, see
/// recipe-source-adapter.ts).
model Source {
id Int @id @default(autoincrement())
key String @unique
name String
url String?
/// Whether this is an official API (the site/publisher provides
/// structured recipe data itself) or unofficial web scraping (we parse
/// HTML the site never committed to a stable shape for) — mirrors
/// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the
/// same way as `key`/`name`. Surfaced to households picking which
/// sources to enable (see `HouseSource`) so scraped content is never
/// mistaken for an official feed.
official Boolean
/// The source's own logo/favicon URL, shown next to its name in
/// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`,
/// synced the same way as `name`/`official`. `null` if the source has
/// none worth showing.
iconUrl String? @map("icon_url")
recipes Recipe[]
enabledHouses HouseSource[]
@@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`).
@ -290,26 +238,8 @@ model Recipe {
id Int @id @default(autoincrement())
name String
sourceId Int? @map("source_id")
/// The item's identifier on `source` (`RecipeSourceListItem.externalId`,
/// recipe-source-adapter.ts) — `null` for a manually-authored recipe,
/// alongside `sourceId` being `null`. Together with `sourceId`, this is
/// what `findImportedExternalIds` (recipe-source-sync.ts) checks against
/// to tell an already-imported source item apart from a new one when
/// browsing (see `markAlreadyImported`, recipe-source-adapter.ts) — the
/// `@@unique([sourceId, externalId])` below is what actually prevents
/// importing the same source recipe twice (Postgres treats each `NULL`
/// as distinct, so manually-authored recipes never collide with each
/// other here).
externalId String? @map("external_id")
description String?
picture String?
/// How many portions this recipe yields as written (its ingredient
/// quantities/steps assume this count) — distinct from
/// `PlanningItem.portions`, which is how many to actually prepare for one
/// planning slot and now defaults to this value client-side but is still
/// entered/stored independently (a planning slot may scale the recipe
/// up/down).
portions Int
/// Creator — not in the original spec doc, required once recipes carry a
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
authorId Int @map("author_id")
@ -328,8 +258,9 @@ model Recipe {
planningItems PlanningItem[]
favoritedBy RecipeFavorite[]
diets RecipeDiet[]
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
@@unique([sourceId, externalId])
@@map("recipe")
}
@ -383,28 +314,30 @@ model RecipeDiet {
/// `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
/// columns to live in. `@default(EPICERIE_SECHE)` 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
/// 🥦 Légumes, fruits, herbes fraîches.
PRODUITS_FRAIS
/// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer.
BOUCHERIE_POISSONNERIE
/// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des
/// produits secs/en conserve qui ne rentre dans aucune autre case
/// (algues séchées, champignons séchés…).
EPICERIE_SECHE
/// 🍞 Pains et pâtes à cuire (crues, à enfourner).
BOULANGERIE
/// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux,
/// tofu…).
CREMERIE_FROMAGE
/// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de
/// cuisine…).
CONDIMENTS_EPICES
/// 🍳 Bases de préparation (farines, bouillons, eau), épaississants
/// (levures, fécules, gélatine), sucres.
AIDES_CULINAIRES
}
/// Finer-grained rack within one {@link IngredientCategory} aisle — see
@ -413,50 +346,50 @@ enum IngredientCategory {
/// `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
/// `@default(AUTRES)` — 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
// --- Produits frais ------------------------------------------------------
LEGUMES
FRUITS
HERBES_FRAICHES
// --- Boucherie & poissonnerie ---------------------------------------------
VIANDES
VOLAILLES
POISSONS
CRUSTACES_FRUITS_DE_MER
// --- Épicerie sèche --------------------------------------------------------
FECULENTS
LEGUMINEUSES
GRAINES_FRUITS_SECS
/// 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
AUTRES
// --- Boulangerie -------------------------------------------------------
PAINS
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
/// distinct from `breads` (already-baked bread).
rawDough
// --- dairyAndCheese ------------------------------------------------------
dairy
eggs
/// distinct from `PAINS` (already-baked bread).
PATES_A_CUIRE
// --- Crémerie & fromage --------------------------------------------------
PRODUITS_LAITIERS
OEUFS
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
plantBasedAlternatives
// --- condimentsAndSpices ---------------------------------------------------
spices
sauces
ALTERNATIVES
// --- Condiments & épices -------------------------------------------------
EPICES
SAUCES
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
/// season rather than form the base of a dish.
seasonings
// --- cookingEssentials -----------------------------------------------------
ASSAISONNEMENTS
// --- Aides culinaires ----------------------------------------------------
/// Flours, stocks/broths, canned tomato bases, water — the literal base
/// a recipe is built on.
bases
BASES
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
/// gelatin.
thickeners
sugars
EPAISSISSANTS
SUCRES
}
/// Generic pictogram *type* for an ingredient — not in the original spec
@ -501,29 +434,17 @@ model Ingredient {
id Int @id @default(autoincrement())
key String @unique
icon IngredientIcon @default(JAR)
category IngredientCategory @default(dryGoods)
subcategory IngredientSubcategory @default(other)
/// Whether this ingredient is reasonably makeable at home (a burger bun,
/// a béchamel) rather than something you'd only ever buy (a raw
/// vegetable, a specific cut of meat) — surfaced in the recipe form as a
/// badge/link nudging the author to go check the recipe catalog for a
/// "make it yourself" recipe (see `apps/web`'s `IngredientRow`/
/// `IngredientPicker`). Deliberately just a flag, not a link to a
/// specific recipe — replaces an earlier, never-wired-up
/// `alternateRecipeId` FK (product decision discussed in chat: no
/// ingredient↔recipe linking in the database, the UI only pre-fills the
/// catalog's own search with this ingredient's name).
reproducible Boolean @default(false)
category IngredientCategory @default(EPICERIE_SECHE)
subcategory IngredientSubcategory @default(AUTRES)
alternateRecipeId Int? @map("alternate_recipe")
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
recipes RecipeIngredient[]
allergies IngredientAllergy[]
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[]
/// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[]
@@map("ingredients")
}
@ -566,50 +487,6 @@ model IngredientAllergy {
@@map("ingredient_allergy")
}
/// Which physical quantity a {@link Unit} measures — only units of the same
/// type are ever mutually convertible via `toBaseFactor` (grams and
/// kilograms both measure MASS; a "pincée" and a "gousse" are both COUNT
/// but converting between *them* would need per-ingredient data no catalog
/// entry alone can provide, so COUNT units just don't convert to each
/// other, each stands alone with `toBaseFactor = 1`).
enum UnitType {
MASS
VOLUME
COUNT
}
/// `key` is `@unique` — same idempotent-seed/no-duplicate reasoning as
/// `Diet.key`. A stable English camelCase uid (e.g. `"tablespoon"`), not the
/// display label — the label lives in `apps/web`'s
/// `locales/fr/translation.json` under `catalog.units.<key>` (see
/// `reference-seed-data.ts`'s `UNITS`).
///
/// Not in the original spec doc — `RecipeIngredient.unit` used to be free
/// text ("g", "grammes", "G"…), which can never be reliably summed/converted
/// (a future shopping list can't tell "g" and "grammes" are the same unit).
/// This closes that off: `unit` is now a normalized, finite catalog.
/// `toBaseFactor` is how many of this type's base unit (gram for MASS,
/// milliliter for VOLUME, itself for COUNT) one of this unit equals —
/// laying the groundwork for a future conversion feature (e.g. summing
/// "500g" + "0.5kg" of the same ingredient into "1kg") without building
/// that feature itself yet.
model Unit {
id Int @id @default(autoincrement())
key String @unique
type UnitType
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
recipeIngredients RecipeIngredient[]
/// Ingredient mentions detected alongside a technique in a step's free
/// text (e.g. "50g" resolved against this `Unit`) — see
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
/// (the recipe's structured ingredient list): a step can mention a
/// quantity+unit that was never itself an ingredient list line.
stepTechStepIngredients StepTechStepIngredient[]
@@map("unit")
}
/// recipe <-> ingredients association. The spec documents this as a plain
/// many-to-many, but a shopping list / batch-cooking calculation needs a
/// quantity per recipe, so this join table carries quantity + unit
@ -618,66 +495,35 @@ model RecipeIngredient {
recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id")
quantity Decimal @db.Decimal(10, 2)
unitId Int @map("unit_id")
unit String
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId])
@@map("recipe_ingredient")
}
/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
/// label lives in `apps/web`'s `locales/fr/translation.json` under
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
///
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
/// `TechStepClassifierService`) used to go through a DB-backed
/// `TechStepMapping` table of per-locale regex expressions — replaced with
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
/// unable to generalize past their own literal vocabulary. Nothing
/// queries/edits that matching data at runtime anymore (it only ever feeds
/// that service's one-time training pass), so it no longer needs a table
/// of its own — this row now only exists to be a stable id/key other
/// tables (`StepTechStep`) reference.
model TechStep {
id Int @id @default(autoincrement())
key String @unique
steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`.
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
/// Corrections where this technique was the *corrected* (user-asserted)
/// match — see `StepTechStepCorrection.correctedTechStepId`.
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
/// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[]
steps Step[]
mappings TechStepMapping[]
@@map("tech_step")
}
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
/// categorization taxonomy like `Ingredient` needed yet, and no matching
/// data of its own here either: unlike `TechStep` (whose matching synonyms
/// used to live in TS and were moved into
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
/// pre-existing needed it — this row only exists to be a stable id/key
/// `StepTechStepUtensil` references, and to carry a French label
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
/// `UTENSILS`).
model Utensil {
/// Used by the recipe-import pipeline to auto-detect which technique a raw
/// instruction step corresponds to (expression = text pattern, weight = match score).
model TechStepMapping {
id Int @id @default(autoincrement())
key String @unique
techStepId Int @map("tech_step_id")
expression String
weight Int
steps StepTechStepUtensil[]
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
@@map("utensil")
@@map("tech_step_mapping")
}
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
@ -690,210 +536,10 @@ model Step {
description String
picture String?
order Int
techStepId Int? @map("tech_step_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
techSteps StepTechStep[]
/// User-submitted corrections to this step's detected techniques — see
/// `StepTechStepCorrection`.
corrections StepTechStepCorrection[]
techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
@@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")
}

View file

@ -1,7 +1,5 @@
import { PrismaClient } from "@prisma/client";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js";
import { registerAllRecipeSources } from "../src/sources/index.js";
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
// the rest of the app's config/env plumbing this doesn't need), run via
@ -10,10 +8,8 @@ import { registerAllRecipeSources } from "../src/sources/index.js";
// `prisma migrate reset`. The actual data/logic lives in
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
const prisma = new PrismaClient();
registerAllRecipeSources();
seedReferenceData(prisma)
.then(() => syncRecipeSources(prisma))
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);

View file

@ -0,0 +1,49 @@
/**
* One-off generator, run by hand whenever the catalog's reference data
* changes (a new ingredient/diet/allergen added to
* `db/reference-seed-data.ts`, or an English key corrected in
* `catalog-en-keys.ts`): regenerates
* `apps/web/src/locales/fr/translation.json`'s
* `catalog.{diets,allergens,ingredients}` sections (English key -> French
* label), merged in without touching the rest of the file.
*
* Doesn't touch the database a brand new diet/allergen/ingredient is
* created fresh by `seedReferenceData`'s normal create path (see
* `reference-seed-data.ts`), no backfill needed. Renaming an *existing*
* item's English key in `catalog-en-keys.ts` does need a one-off migration
* (`UPDATE ... SET key = ...`, keyed by the *old* key value) written by
* hand for that occasion see
* `prisma/migrations/20260818193000_catalog_keys_to_english/` for the shape
* one looks like.
*
* Never imported by the app itself a dev-time tool, run via
* `tsx scripts/generate-catalog-i18n.ts`.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js";
const here = fileURLToPath(new URL(".", import.meta.url));
function toKeyLabelMap(labels: string[]): Record<string, string> {
const map: Record<string, string> = {};
for (const label of labels) {
map[getEnglishKey(label)] = label;
}
return map;
}
const diets = toKeyLabelMap(DIETS);
const allergens = toKeyLabelMap(ALLERGENS.map((a) => a.name));
const ingredients = toKeyLabelMap(INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)));
console.log(
`diets: ${Object.keys(diets).length}, allergens: ${Object.keys(allergens).length}, ingredients: ${Object.keys(ingredients).length}`,
);
const localePath = `${here}../../web/src/locales/fr/translation.json`;
const locale = JSON.parse(readFileSync(localePath, "utf8"));
locale.catalog = { diets, allergens, ingredients };
writeFileSync(localePath, `${JSON.stringify(locale, null, 2)}\n`);
console.log(`wrote ${localePath}`);

View file

@ -0,0 +1,46 @@
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
/**
* One-off validation, run by hand: checks that `catalog-en-keys.ts` has an
* entry for every diet/allergen/ingredient currently in
* `reference-seed-data.ts`, and that the resulting English keys are unique
* within each table. Not part of the app or the seed itself just a
* pre-flight check while building/editing the dictionary by hand.
*/
import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js";
function check(label: string, names: string[]) {
const keys = new Map<string, string>();
const missing: string[] = [];
const duplicates: string[] = [];
for (const name of names) {
let key: string;
try {
key = getEnglishKey(name);
} catch {
missing.push(name);
continue;
}
const existing = keys.get(key);
if (existing !== undefined && existing !== name) {
duplicates.push(`"${existing}" and "${name}" both map to "${key}"`);
}
keys.set(key, name);
}
console.log(`${label}: ${names.length} names, ${keys.size} unique keys`);
if (missing.length > 0) {
console.log(` MISSING (${missing.length}):`, missing);
}
if (duplicates.length > 0) {
console.log(` DUPLICATES (${duplicates.length}):`, duplicates);
}
}
check("Diets", DIETS);
check(
"Allergens",
ALLERGENS.map((a) => a.name),
);
check(
"Ingredients",
INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)),
);

View file

@ -1,20 +1,15 @@
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 type { Express, Request, Response } from "express";
import { env } from "./config/env.js";
import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js";
import { shoppingListRouter } from "./modules/shopping-list/shopping-list.routes.js";
import { sourcesRouter } from "./modules/sources/sources.routes.js";
/**
* Builds the API's `ExpressServer`: standard middleware, routes, and the
@ -26,12 +21,6 @@ import { sourcesRouter } from "./modules/sources/sources.routes.js";
*/
export function createServer(): ExpressServer {
const server = new ExpressServer();
// First middleware registered, before even setupCore's own (CORS/JSON
// body parsing/cookies) — it only reads `req`/`res`, so it doesn't need
// to run after them, and mounting it first means it wraps the *whole*
// pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger);
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => {
@ -40,19 +29,11 @@ export function createServer(): ExpressServer {
server.mountRouter("/auth", authRouter);
server.mountRouter("/house", houseRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single
// recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
@ -69,12 +50,10 @@ export function createServer(): ExpressServer {
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
});
// Two error-handling middlewares in a row (Express runs them in
// registration order, same as regular middleware) — errorLogger logs the
// error, then hands it on (`next(err)`) to the real one: all the "what
// status/body does this error map to" logic lives in ErrorHandlerService,
// from @batch-cooking/error-tools — this stays a thin adapter.
server.setErrorHandler(errorLogger);
// Final error-handling middleware: every thrown/`next(err)`-ed error in
// the app ends up here. All the "what status/body does this error map
// to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
// — this stays a thin adapter.
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
return server;

View file

@ -1,18 +1,6 @@
import dotenv from "dotenv";
import "dotenv/config";
import { z } from "zod";
// Loads `.env.test` instead of `.env` when running the test suite
// (NODE_ENV=test, set by `cross-env` in package.json's `test` script —
// already present in `process.env` by the time this module runs, since
// `cross-env` sets it before invoking node/tsx at all). Keeps
// `resetDatabase()` (test-support/reset-db.ts, which TRUNCATEs almost
// every table before each test) pointed at a dedicated test database,
// never whatever `pnpm dev` actually uses — running the test suite once
// already wiped a real local dev database this way (`.env`/`.env.test`
// sharing one `DATABASE_URL`), see `.env.test.example` for how to set the
// separate test database this now requires.
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
/**
* Schema for every environment variable the API reads. Parsing (below)
* fails fast at startup if something required is missing/invalid, instead
@ -60,36 +48,6 @@ const envSchema = z.object({
.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. */

View file

@ -0,0 +1,557 @@
/**
* English key for every catalog reference label `Diet`/`Category`
* (allergens)/`Ingredient` rows must carry a stable, storage-safe `key`
* that is itself English, independent of whatever language the *seed's*
* authoring label (`DIETS`/`ALLERGENS`/`INGREDIENT_GROUPS` in
* `reference-seed-data.ts`, currently French) happens to be in a French
* `key` would tie the identifier to the one language it's meant to be
* decoupled from (see `utils/slugify.ts`'s doc comment and `apps/web`'s
* `locales/fr/translation.json` `catalog.*` namespace, which resolves the
* *display* label from this same key).
*
* Hand-assigned (not machine-translated) an English label is chosen once
* and never changes, exactly like the key it produces (via {@link
* slugify}). Keyed by the exact French authoring label so
* `reference-seed-data.ts` and `scripts/generate-catalog-i18n.ts` can look
* a row's key up by the same string they already have in hand.
*
* `getEnglishKey` throws on a missing entry rather than falling back to
* slugifying the French label a silently-French key defeats the point,
* so a newly-added diet/allergen/ingredient must get an entry here before
* it can seed.
*/
import { slugify } from "../utils/slugify.js";
const DIET_KEYS: Record<string, string> = {
Omnivore: "omnivore",
Végétarien: "vegetarian",
Végan: "vegan",
Pescétarien: "pescatarian",
"Sans gluten": "gluten_free",
};
const ALLERGEN_KEYS: Record<string, string> = {
Gluten: "gluten",
Crustacés: "crustaceans",
Œufs: "eggs",
Poissons: "fish",
Arachides: "peanuts",
Soja: "soy",
Lait: "milk",
"Fruits à coque": "tree_nuts",
Céleri: "celery",
Moutarde: "mustard",
"Graines de sésame": "sesame_seeds",
Sulfites: "sulfites",
Lupin: "lupin",
Mollusques: "molluscs",
};
// Grouped by (category, subcategory), mirroring `INGREDIENT_GROUPS` in
// reference-seed-data.ts, purely so a translator can find/check an entry
// against its source group — this is one flat lookup table at runtime.
const INGREDIENT_KEYS: Record<string, string> = {
// --- Produits frais / Légumes ---------------------------------------
Tomate: "tomato",
Oignon: "onion",
Échalote: "shallot",
Ail: "garlic",
Carotte: "carrot",
Courgette: "zucchini",
Concombre: "cucumber",
Cornichons: "gherkins",
Poivron: "bell_pepper",
Champignon: "mushroom",
Cèpes: "porcini",
Aubergine: "eggplant",
Brocoli: "broccoli",
"Chou-fleur": "cauliflower",
"Chou blanc": "white_cabbage",
"Chou rouge": "red_cabbage",
"Chou de Bruxelles": "brussels_sprouts",
Épinard: "spinach",
Blette: "swiss_chard",
Salade: "lettuce",
Roquette: "arugula",
Cresson: "watercress",
Poireau: "leek",
Radis: "radish",
Betterave: "beetroot",
Navet: "turnip",
Panais: "parsnip",
"Haricot vert": "green_bean",
"Petit pois": "pea",
Maïs: "corn",
Artichaut: "artichoke",
Fenouil: "fennel",
Endive: "endive",
Potiron: "pumpkin",
Butternut: "butternut_squash",
Asperge: "asparagus",
Avocat: "avocado",
"Pomme de terre": "potato",
"Patate douce": "sweet_potato",
"Tomates cerises": "cherry_tomato",
"Pak-choï": "bok_choy",
"Germes de soja": "soybean_sprouts",
Shiitake: "shiitake",
Daikon: "daikon",
"Piment vert frais": "fresh_green_chili",
// --- Produits frais / Fruits -----------------------------------------
Citron: "lemon",
"Citron vert": "lime",
Pomme: "apple",
Poire: "pear",
Banane: "banana",
Orange: "orange",
Clémentine: "clementine",
Pamplemousse: "grapefruit",
Fraise: "strawberry",
Framboise: "raspberry",
Myrtille: "blueberry",
Mûre: "blackberry",
Cerise: "cherry",
Abricot: "apricot",
Pêche: "peach",
Prune: "plum",
Raisin: "grape",
Melon: "melon",
Pastèque: "watermelon",
Ananas: "pineapple",
Mangue: "mango",
Kiwi: "kiwi",
Figue: "fig",
Datte: "date",
Litchi: "lychee",
Grenade: "pomegranate",
Rhubarbe: "rhubarb",
Coing: "quince",
// --- Produits frais / Herbes fraîches ---------------------------------
Basilic: "basil",
Persil: "parsley",
Thym: "thyme",
Romarin: "rosemary",
Laurier: "bay_leaf",
Ciboulette: "chives",
"Coriandre fraîche": "fresh_cilantro",
Menthe: "mint",
Origan: "oregano",
Aneth: "dill",
Estragon: "tarragon",
Sarriette: "savory",
Marjolaine: "marjoram",
Sauge: "sage",
Cerfeuil: "chervil",
Gingembre: "ginger",
Citronnelle: "lemongrass",
Combava: "kaffir_lime",
// --- Boucherie & poissonnerie / Viandes -------------------------------
Lapin: "rabbit",
"Bœuf haché": "ground_beef",
"Steak de bœuf": "beef_steak",
"Rôti de bœuf": "beef_roast",
"Escalope de veau": "veal_cutlet",
"Filet mignon de porc": "pork_tenderloin",
"Côte de porc": "pork_chop",
Agneau: "lamb",
"Gigot d'agneau": "leg_of_lamb",
Lardons: "bacon_lardons",
Bacon: "bacon",
"Jambon blanc": "ham",
"Jambon cru": "cured_ham",
Saucisse: "sausage",
Chorizo: "chorizo",
Merguez: "merguez",
Prosciutto: "prosciutto",
Pancetta: "pancetta",
Mortadelle: "mortadella",
Salami: "salami",
// --- Boucherie & poissonnerie / Volailles -----------------------------
Poulet: "chicken",
Dinde: "turkey",
Canard: "duck",
"Magret de canard": "duck_breast",
// --- Boucherie & poissonnerie / Poissons -------------------------------
Saumon: "salmon",
Thon: "tuna",
Cabillaud: "cod",
Truite: "trout",
Sardine: "sardine",
Anchois: "anchovy",
Merlan: "whiting",
Surimi: "surimi",
"Bar (loup de mer)": "sea_bass",
Dorade: "sea_bream",
Sole: "sole",
Turbot: "turbot",
Merlu: "hake",
Colin: "pollock",
"Lieu noir": "saithe",
Églefin: "haddock",
Maquereau: "mackerel",
Hareng: "herring",
Rouget: "red_mullet",
Raie: "skate",
Lotte: "monkfish",
Flétan: "halibut",
Espadon: "swordfish",
Carpe: "carp",
Brochet: "pike",
Perche: "perch",
Tilapia: "tilapia",
Panga: "pangasius",
"Saumon fumé": "smoked_salmon",
"Poisson séché": "dried_fish",
// --- Boucherie & poissonnerie / Crustacés & fruits de mer -------------
Crevettes: "shrimp",
Langoustines: "langoustine",
Homard: "lobster",
Crabe: "crab",
Langouste: "spiny_lobster",
Moules: "mussels",
Huîtres: "oysters",
"Saint-Jacques": "scallops",
Calamar: "squid",
Poulpe: "octopus",
Palourdes: "clams",
Bulots: "whelks",
// --- Épicerie sèche / Féculents ----------------------------------------
Semoule: "semolina",
Couscous: "couscous",
Boulgour: "bulgur",
Polenta: "polenta",
Quinoa: "quinoa",
Pâtes: "pasta",
"Pâtes complètes": "whole_wheat_pasta",
Riz: "rice",
"Riz basmati": "basmati_rice",
"Riz complet": "brown_rice",
"Flocons d'avoine": "oats",
Spaghetti: "spaghetti",
Penne: "penne",
Tagliatelles: "tagliatelle",
"Lasagnes (feuilles)": "lasagna_sheets",
Gnocchi: "gnocchi",
"Riz arborio": "arborio_rice",
"Nouilles de riz": "rice_noodles",
"Nouilles udon": "udon_noodles",
"Nouilles soba": "soba_noodles",
"Nouilles chinoises": "chinese_noodles",
"Vermicelles de riz": "rice_vermicelli",
"Vermicelles de soja": "soy_vermicelli",
"Riz gluant": "sticky_rice",
"Riz à sushi": "sushi_rice",
"Riz jasmin": "jasmine_rice",
// --- Épicerie sèche / Légumineuses --------------------------------------
"Lentilles vertes": "green_lentils",
"Lentilles corail": "red_lentils",
"Pois chiches": "chickpeas",
"Haricots blancs": "white_beans",
"Haricots rouges": "kidney_beans",
"Haricots noirs": "black_beans",
"Pois cassés": "split_peas",
Fèves: "fava_beans",
Edamame: "edamame",
"Haricots pinto": "pinto_beans",
// --- Épicerie sèche / Graines & fruits secs ----------------------------
Cacahuètes: "peanuts_shelled",
Amandes: "almonds",
Noix: "walnuts",
Noisettes: "hazelnuts",
"Noix de cajou": "cashews",
Pistaches: "pistachios",
"Noix de pécan": "pecans",
"Poudre d'amande": "almond_powder",
"Pignons de pin": "pine_nuts",
"Graines de tournesol": "sunflower_seeds",
"Graines de courge": "pumpkin_seeds",
"Noix de coco râpée": "shredded_coconut",
"Raisins secs": "raisins",
Pruneaux: "prunes",
"Abricots secs": "dried_apricots",
// --- Épicerie sèche / Autres --------------------------------------------
"Champignons noirs": "black_mushrooms",
"Algue nori": "nori_seaweed",
"Algue wakamé": "wakame_seaweed",
"Algue kombu": "kombu_seaweed",
"Pousses de bambou": "bamboo_shoots",
"Châtaignes d'eau": "water_chestnuts",
// --- Boulangerie / Pains -------------------------------------------------
Pain: "bread",
"Pain de mie": "sandwich_bread",
"Pain complet": "whole_wheat_bread",
Baguette: "baguette",
"Pain de seigle": "rye_bread",
Chapelure: "breadcrumbs",
"Pain à burger": "burger_bun",
"Pain brioché": "brioche_bun",
"Pain à hot-dog": "hot_dog_bun",
"Pain pita": "pita_bread",
"Pain bagel": "bagel",
Naan: "naan",
"Pain wrap": "wrap_bread",
"Pain viennois": "viennese_bread",
"Pain de campagne": "country_bread",
"Pain aux céréales": "multigrain_bread",
"Petit pain": "bread_roll",
"Pain suédois": "swedish_bread",
"Pain sans gluten": "gluten_free_bread",
Biscotte: "rusk",
Croûtons: "croutons",
Focaccia: "focaccia",
Ciabatta: "ciabatta",
"Tortilla de maïs": "corn_tortilla",
"Tortilla de blé": "wheat_tortilla",
// --- Boulangerie / Pâtes à cuire -----------------------------------------
"Pâte feuilletée": "puff_pastry",
"Pâte brisée": "shortcrust_pastry",
"Pâte à pizza": "pizza_dough",
"Pâte à tarte sablée": "sweet_shortcrust_pastry",
// --- Crémerie & fromage / Produits laitiers ------------------------------
Lait: "milk",
Beurre: "butter",
"Crème fraîche": "creme_fraiche",
"Crème liquide": "liquid_cream",
Fromage: "cheese",
Emmental: "emmental",
Gruyère: "gruyere",
Parmesan: "parmesan",
Mozzarella: "mozzarella",
"Chèvre (fromage)": "goat_cheese",
Feta: "feta",
Comté: "comte",
"Fromage blanc": "fromage_blanc",
Mascarpone: "mascarpone",
Yaourt: "yogurt",
Burrata: "burrata",
Ricotta: "ricotta",
Pecorino: "pecorino",
Gorgonzola: "gorgonzola",
Cheddar: "cheddar",
// --- Crémerie & fromage / Œufs -------------------------------------------
Œuf: "egg",
// --- Crémerie & fromage / Alternatives ------------------------------------
"Lait de coco": "coconut_milk",
"Crème de coco": "coconut_cream",
"Lait d'amande": "almond_milk",
"Lait d'avoine": "oat_milk",
Tofu: "tofu",
"Tofu soyeux": "silken_tofu",
// --- Condiments & épices / Épices -----------------------------------------
"Herbes de Provence": "herbes_de_provence",
"Poivre noir": "black_pepper",
Paprika: "paprika",
"Piment d'Espelette": "espelette_pepper",
"Piment de Cayenne": "cayenne_pepper",
Cumin: "cumin",
"Curry (poudre)": "curry_powder",
Curcuma: "turmeric",
Cannelle: "cinnamon",
Muscade: "nutmeg",
Safran: "saffron",
"Clou de girofle": "clove",
"Vanille (gousse)": "vanilla_bean",
"Poivre blanc": "white_pepper",
"Poivre rose": "pink_pepper",
"Poivre du Sichuan": "sichuan_pepper",
"Paprika fumé": "smoked_paprika",
"Piment oiseau": "bird_eye_chili",
"Baies de genièvre": "juniper_berries",
"Anis étoilé (badiane)": "star_anise",
"Anis vert": "green_anise",
"Graines de fenouil": "fennel_seeds",
Sumac: "sumac",
Nigelle: "nigella",
"Quatre épices": "allspice",
"Colombo (poudre)": "colombo_powder",
Baharat: "baharat",
Raifort: "horseradish",
"Sel aux herbes": "herb_salt",
"Sel de céleri": "celery_salt",
"Fleur de sel": "fleur_de_sel",
Sel: "salt",
"Cinq épices": "five_spice",
"Garam masala": "garam_masala",
"Graines de coriandre": "coriander_seeds",
Cardamome: "cardamom",
Fenugrec: "fenugreek",
"Piment jalapeño": "jalapeno",
"Piment chipotle": "chipotle",
"Piment poblano": "poblano_pepper",
"Piment habanero": "habanero",
"Ras el hanout": "ras_el_hanout",
"Za'atar": "zaatar",
// --- Condiments & épices / Sauces -------------------------------------------
"Sauce soja": "soy_sauce",
Moutarde: "mustard",
Mayonnaise: "mayonnaise",
Ketchup: "ketchup",
Tabasco: "tabasco",
"Sauce Worcestershire": "worcestershire_sauce",
"Sauce nuoc-mâm": "fish_sauce",
Wasabi: "wasabi",
Harissa: "harissa",
"Pâte de curry": "curry_paste",
"Beurre de cacahuète": "peanut_butter",
"Moutarde de Dijon": "dijon_mustard",
"Moutarde à l'ancienne": "wholegrain_mustard",
"Sauce barbecue": "barbecue_sauce",
"Sauce tartare": "tartar_sauce",
"Sauce cocktail": "cocktail_sauce",
"Sauce béarnaise": "bearnaise_sauce",
"Sauce hollandaise": "hollandaise_sauce",
"Sauce béchamel": "bechamel_sauce",
"Sauce teriyaki": "teriyaki_sauce",
"Sauce ponzu": "ponzu_sauce",
Chimichurri: "chimichurri",
"Pesto rouge (tomates séchées)": "red_pesto",
Pesto: "pesto",
"Sauce huître": "oyster_sauce",
"Sauce hoisin": "hoisin_sauce",
"Sauce sriracha": "sriracha",
"Sauce sweet chili": "sweet_chili_sauce",
Miso: "miso",
"Pâte de crevettes": "shrimp_paste",
"Pâte de curry rouge (thaï)": "red_curry_paste",
"Pâte de curry vert (thaï)": "green_curry_paste",
Tahini: "tahini",
// --- Condiments & épices / Assaisonnements -----------------------------------
"Huile d'olive": "olive_oil",
"Huile de tournesol": "sunflower_oil",
"Huile de colza": "rapeseed_oil",
"Huile de coco": "coconut_oil",
"Huile de sésame": "sesame_oil",
"Vinaigre de cidre": "cider_vinegar",
"Vinaigre blanc": "white_vinegar",
"Vinaigre balsamique": "balsamic_vinegar",
Câpres: "capers",
Olives: "olives",
"Vin blanc (cuisine)": "white_wine",
"Vin rouge (cuisine)": "red_wine",
"Vinaigre de vin rouge": "red_wine_vinegar",
"Vinaigre de vin blanc": "white_wine_vinegar",
"Vinaigre de xérès": "sherry_vinegar",
"Huile de noix": "walnut_oil",
"Huile de noisette": "hazelnut_oil",
"Huile d'arachide": "peanut_oil",
"Huile pimentée": "chili_oil",
"Vinaigre de riz": "rice_vinegar",
Mirin: "mirin",
"Saké (cuisine)": "sake",
"Jus de citron": "lemon_juice",
"Jus de citron vert": "lime_juice",
"Jus d'orange": "orange_juice",
"Jus de pomme": "apple_juice",
"Jus de raisin": "grape_juice",
"Jus de tomate": "tomato_juice",
"Jus de cranberry": "cranberry_juice",
Café: "coffee",
Thé: "tea",
"Bière (cuisine)": "beer",
"Cidre (cuisine)": "cider",
"Champagne / vin pétillant (cuisine)": "champagne",
"Porto (cuisine)": "port_wine",
"Vin jaune (cuisine)": "vin_jaune",
Cognac: "cognac",
Rhum: "rum",
Whisky: "whisky",
Vodka: "vodka",
// --- Aides culinaires / Bases -------------------------------------------
"Farine de blé": "wheat_flour",
"Farine complète": "whole_wheat_flour",
"Farine de maïs": "corn_flour",
"Farine de sarrasin": "buckwheat_flour",
"Farine de riz": "rice_flour",
"Bouillon cube légumes": "vegetable_stock_cube",
"Bouillon cube volaille": "chicken_stock_cube",
"Concentré de tomate": "tomato_paste",
"Coulis de tomate": "tomato_coulis",
"Tomates pelées (conserve)": "canned_peeled_tomatoes",
"Tomates séchées": "sun_dried_tomatoes",
"Fond de veau": "veal_stock",
"Fond de volaille": "chicken_stock",
"Bouillon cube bœuf": "beef_stock_cube",
"Bouillon cube poisson": "fish_stock_cube",
"Bouillon de légumes": "vegetable_broth",
"Bouillon de volaille": "chicken_broth",
"Bouillon de bœuf": "beef_broth",
"Court-bouillon": "court_bouillon",
"Dashi (bouillon japonais)": "dashi",
"Bisque de crustacés": "shellfish_bisque",
"Farine de tapioca": "tapioca_flour",
"Masa harina": "masa_harina",
Eau: "water",
"Eau gazeuse": "sparkling_water",
"Eau de fleur d'oranger": "orange_blossom_water",
"Eau de rose": "rose_water",
"Fumet de poisson": "fish_fumet",
// --- Aides culinaires / Épaississants -------------------------------------
"Levure boulangère": "bakers_yeast",
"Levure chimique": "baking_powder",
Maïzena: "cornstarch",
"Farine de lupin": "lupin_flour",
Gélatine: "gelatin",
"Bicarbonate de soude": "baking_soda",
"Fécule de pomme de terre": "potato_starch",
// --- Aides culinaires / Sucres ---------------------------------------------
Sucre: "sugar",
Miel: "honey",
"Sirop d'érable": "maple_syrup",
"Sucre roux": "brown_sugar",
"Sucre glace": "powdered_sugar",
Cassonade: "demerara_sugar",
"Chocolat noir": "dark_chocolate",
"Chocolat au lait": "milk_chocolate",
"Chocolat blanc": "white_chocolate",
"Pépites de chocolat": "chocolate_chips",
"Cacao en poudre": "cocoa_powder",
"Extrait de vanille": "vanilla_extract",
"Sucre de palme": "palm_sugar",
"Sirop de sucre de canne": "cane_syrup",
};
const ALL_KEYS: Record<string, string> = {
...DIET_KEYS,
...ALLERGEN_KEYS,
...INGREDIENT_KEYS,
};
/**
* Resolves a seed-time French authoring label to its English `key`
* throws if it's missing an entry above rather than silently falling back
* to a French slug, since a new diet/allergen/ingredient needs a
* deliberately-chosen English key before it can seed at all.
* `slugify` still runs over the result so a stray character/casing slip in
* the table above can't produce a key that doesn't match the
* `[a-z0-9_]`-only shape every other key has.
*/
export function getEnglishKey(frenchLabel: string): string {
const english = ALL_KEYS[frenchLabel];
if (english === undefined) {
throw new Error(
`No English key registered for "${frenchLabel}" — add one to catalog-en-keys.ts`,
);
}
return slugify(english);
}

View file

@ -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";
/**

View file

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

View file

@ -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();

View file

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

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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: [],
},
];

View file

@ -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);
}

View file

@ -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 };
}

View file

@ -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();

View file

@ -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;
}

View file

@ -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";
}
}

View file

@ -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();
}

View file

@ -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);
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -1,7 +1,7 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
import type { CookieOptions, Response } from "express";
import { Router } from "express";
import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { deleteAccount, login, signup } from "./auth.service.js";

View file

@ -35,10 +35,7 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
*/
export async function signup(input: SignupInput): Promise<AuthResult> {
try {
const existing = await prisma.userProfile.findUnique({
where: { email: input.email },
});
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
if (existing) {
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
}
@ -58,18 +55,8 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
},
});
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,
});
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;
}
}
/**
@ -87,10 +74,7 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
*/
export async function deleteAccount(profileId: number, password: string): Promise<void> {
try {
const profile = await prisma.userProfile.findUnique({
where: { id: profileId },
});
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
}
@ -99,9 +83,6 @@ export async function deleteAccount(profileId: number, password: string): Promis
await leaveCurrentHouse(profile.id, profile.houseId);
}
await prisma.userProfile.delete({ where: { id: profile.id } });
} catch (err) {
throw err; // see signup()'s catch comment above
}
}
/**
@ -112,21 +93,12 @@ export async function deleteAccount(profileId: number, password: string): Promis
* caller can never learn whether a given email has an account.
*/
export async function login(input: LoginInput): Promise<AuthResult> {
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))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,
});
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
return { profile: toSafeProfile(profile), token };
} catch (err) {
throw err; // see signup()'s catch comment above
}
}

View file

@ -1,11 +1,10 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createHouseSchema,
ErrorCode,
createHouseSchema,
joinHouseSchema,
renameHouseSchema,
updateHouseSourcesSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
@ -13,12 +12,10 @@ import {
createHouse,
deleteHouse,
getCurrentHouse,
getHouseSourceIds,
joinHouse,
leaveCurrentHouse,
removeMember,
renameHouse,
updateHouseSources,
} from "./house.service.js";
/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */
@ -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`. */
houseRouter.delete(
"/members/:memberId",

View file

@ -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`). */
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
try {
if (houseId === null) {
return null;
}
return toHouseView(await findHouseOrThrow(houseId));
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}
/**
@ -66,7 +58,6 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -77,9 +68,6 @@ export async function renameHouse(houseId: number | null, name: string): Promise
include: houseWithMembers,
});
return toHouseView(house);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -93,13 +81,8 @@ export async function createHouse(
houseId: number | null,
name: string,
): Promise<HouseView> {
try {
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
@ -110,28 +93,18 @@ export async function createHouse(
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 },
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
});
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
return created;
});
return await getCurrentHouseOrThrow(house.id);
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");
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -145,13 +118,8 @@ export async function joinHouse(
houseId: number | null,
inviteCode: string,
): Promise<HouseView> {
try {
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 } });
@ -163,14 +131,8 @@ export async function joinHouse(
);
}
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
}
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
return getCurrentHouseOrThrow(house.id);
}
/**
@ -187,7 +149,6 @@ export async function joinHouse(
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
*/
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -196,10 +157,7 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
const remainingMembers = house.members.filter((member) => member.id !== profileId);
await prisma.$transaction(async (tx) => {
await tx.userProfile.update({
where: { id: profileId },
data: { houseId: null },
});
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
if (house.adminId !== profileId) {
return;
@ -211,14 +169,8 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
const nextAdmin = remainingMembers.reduce((oldest, member) =>
member.id < oldest.id ? member : oldest,
);
await tx.house.update({
where: { id: house.id },
data: { adminId: nextAdmin.id },
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
});
});
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -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.
*/
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
throw new HttpError(
403,
ErrorCode.NOT_HOUSE_ADMIN,
"Only the household's admin can delete it",
);
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.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
}
}
/**
@ -274,7 +215,6 @@ export async function removeMember(
houseId: number | null,
targetMemberId: number,
): Promise<HouseView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -297,92 +237,13 @@ export async function removeMember(
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
}
}
/**
* Current enabled-source ids for a household an empty array is normal
* and is this household's starting state (opt-in: see `HouseSource` in
* schema.prisma), not just "no preference set yet".
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const rows = await prisma.houseSource.findMany({
where: { houseId },
select: { sourceId: true },
});
return rows.map((row) => row.sourceId);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
* Replaces a household's full set of enabled recipe sources (not a merge
* same "replace, not merge" contract as `profile.service.ts`'s
* `updateAllergies`). Every recipe-catalog tab (`recipe.service.ts`'s
* `listRecipes`) filters against this set a source left out here simply
* never shows its recipes to this household, in any tab.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if any `sourceId` doesn't match a reference `Source` row.
*/
export async function updateHouseSources(
houseId: number | null,
sourceIds: number[],
): Promise<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
if (sourceIds.length > 0) {
const found = await prisma.source.findMany({
where: { id: { in: sourceIds } },
select: { id: true },
});
const foundIds = new Set(found.map((source) => source.id));
const missing = sourceIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Unknown source id(s): ${missing.join(", ")}`,
);
}
}
await prisma.$transaction([
prisma.houseSource.deleteMany({ where: { houseId } }),
prisma.houseSource.createMany({
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
}),
]);
return sourceIds;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
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. */
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
try {
return toHouseView(await findHouseOrThrow(houseId));
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
@ -404,7 +265,6 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
* `HOUSE_NOT_FOUND` HttpError.
*/
async function findHouseOrThrow(houseId: number) {
try {
const house = await prisma.house.findUnique({
where: { id: houseId },
include: houseWithMembers,
@ -413,7 +273,4 @@ async function findHouseOrThrow(houseId: number) {
throw new Error(`House ${houseId} referenced by a profile but not found`);
}
return house;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}

View file

@ -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));
}),
);

View file

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

View file

@ -1,10 +1,10 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
import { Router } from "express";
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. */
export const planningRouter = Router();
@ -34,48 +34,3 @@ planningRouter.get(
res.status(200).json(planning);
}),
);
/** Parses and validates the `:id` route param shared by every `/items/:id` route below. */
function parsePlanningItemId(rawId: string | undefined): number {
const id = Number(rawId);
if (!Number.isInteger(id)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer");
}
return id;
}
/**
* Adds a recipe to one (day, meal) slot of the authenticated user's
* household's planning, creating that week's `Planning` row on the fly if
* needed (see {@link addPlanningItem}).
*/
planningRouter.post(
"/items",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = addPlanningItemSchema.parse(req.body);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await addPlanningItem(houseId, viewerId, houseId, date, input);
res.status(201).json(item);
}),
);
/** Removes one recipe from a planning slot. */
planningRouter.delete(
"/items/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parsePlanningItemId(req.params.id);
await removePlanningItem(id, res.locals.userProfile.houseId);
res.status(204).end();
}),
);

View file

@ -1,13 +1,6 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import {
type AddPlanningItemInput,
ErrorCode,
type PlanningItemView,
type PlanningView,
} from "@batch-cooking/shared";
import { type DateTime, toDateOnly } from "@batch-cooking/date-tools";
import type { PlanningView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { assertRecipeVisible } from "../recipe/recipe.service.js";
/**
* Finds the household's planning that covers `date` and shapes it into a
@ -19,15 +12,14 @@ import { assertRecipeVisible } from "../recipe/recipe.service.js";
* Returns `null` for two distinct, both entirely normal states a
* `houseId` of `null` (the profile has no household yet households are no
* longer created automatically at signup, see `auth.service.ts`) and "no
* planning row covers this date yet" (e.g. a week nobody has added a recipe
* to via {@link addPlanningItem}) neither is an error, so both collapse
* to the same "nothing to show yet" result rather than throwing.
* planning row covers this date" (the expected case until planning
* creation is built) neither is an error, so both collapse to the same
* "nothing to show yet" result rather than throwing.
*/
export async function getPlanningForDate(
houseId: number | null,
date: DateTime,
): Promise<PlanningView | null> {
try {
if (houseId === null) {
return null;
}
@ -67,127 +59,7 @@ export async function getPlanningForDate(
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;
}
}
/**
* Finds the household's `Planning` covering the week `weekStart` (a Monday)
* starts, creating it on the fly (spanning the full Monday-to-Sunday week)
* if none exists yet. Matched by exact `startDate`, not a covering range
* like {@link getPlanningForDate} that query is for "what covers today",
* this one is for "the row `addPlanningItem` should attach to", so it needs
* to land on the same row a second add to the same week would reuse rather
* than risk matching some other overlapping planning.
*
* Not wrapped in a transaction with the `findFirst` no unique constraint
* exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc
* comment on the same gap), so two concurrent first-adds to an empty week
* could each create their own `Planning` row. Accepted at this project's
* scale rather than adding a migration + retry-on-conflict loop for it.
*/
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
try {
const startDate = weekStart.toJSDate();
const existing = await prisma.planning.findFirst({
where: { houseId, startDate },
});
if (existing) {
return existing;
}
return await prisma.planning.create({
data: {
houseId,
startDate,
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
},
});
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
* Adds a recipe to one (day, meal) slot of `houseId`'s planning for the
* week containing `date`, creating that week's `Planning` row first if it
* doesn't exist yet (see {@link findOrCreatePlanningForWeek}) this is the
* only place a `Planning` row gets created at all today, there's no
* separate "create an empty planning" action. `date` is a caller-parsed
* `DateTime` (see `planning.routes.ts`, which validates `input.date` the
* same way `GET /planning` validates its own `?date=`, before calling
* here) rather than the raw `input.date` string only its week matters,
* not the exact day.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null` (the caller has no household).
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `input.recipeId` doesn't match a recipe visible to `viewerId` — see `recipe.service.ts`'s `assertRecipeVisible`.
*/
export async function addPlanningItem(
houseId: number | null,
viewerId: number,
viewerHouseId: number | null,
date: DateTime,
input: AddPlanningItemInput,
): Promise<PlanningItemView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
const item = await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: input.weekDay,
meal: input.meal,
recipeId: input.recipeId,
portions: input.portions,
},
include: { recipe: { select: { id: true, name: true } } },
});
return {
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
};
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
* Removes one planning item outright. The parent `Planning` row is left in
* place even if this was its last item an empty planning is a normal,
* already-handled state on the read side (`getPlanningForDate`'s grid just
* shows every slot's "+"), not worth a cleanup pass here.
*
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
*/
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
try {
const item = await prisma.planningItem.findUnique({
where: { id },
include: { planning: true },
});
if (!item || houseId === null || item.planning.houseId !== houseId) {
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
}
await prisma.planningItem.delete({ where: { id } });
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}

View file

@ -9,18 +9,8 @@ import { prisma } from "../../db/prisma.js";
* just to read it.
*/
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
try {
const preferences = await prisma.userPreference.findUnique({
where: { userProfileId },
});
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
return { theme: preferences?.theme ?? "SYSTEM" };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}
/**
@ -32,14 +22,10 @@ export async function updatePreferences(
userProfileId: number,
theme: ThemePreference,
): Promise<PreferencesView> {
try {
const preferences = await prisma.userPreference.upsert({
where: { userProfileId },
create: { userProfileId, theme },
update: { theme },
});
return { theme: preferences.theme };
} catch (err) {
throw err; // see getPreferences()'s catch comment above
}
}

View file

@ -14,7 +14,6 @@ export async function updateDiet(
userProfileId: number,
dietId: number | null,
): Promise<SafeUserProfile> {
try {
if (dietId !== null) {
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
if (!diet) {
@ -27,26 +26,15 @@ export async function updateDiet(
data: { dietId },
});
return toSafeProfile(profile);
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
try {
const rows = await prisma.userProfileAllergy.findMany({
where: { userProfileId },
select: { allergyId: true },
});
return rows.map((row) => row.allergyId);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
@ -61,7 +49,6 @@ export async function updateAllergies(
userProfileId: number,
allergyIds: number[],
): Promise<number[]> {
try {
if (allergyIds.length > 0) {
const found = await prisma.allergy.findMany({
where: { id: { in: allergyIds } },
@ -86,22 +73,15 @@ export async function updateAllergies(
]);
return allergyIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
try {
const rows = await prisma.userProfileDislikedIngredient.findMany({
where: { userProfileId },
select: { ingredientId: true },
});
return rows.map((row) => row.ingredientId);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
@ -115,7 +95,6 @@ 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 } },
@ -133,19 +112,11 @@ export async function updateDislikedIngredients(
}
await prisma.$transaction([
prisma.userProfileDislikedIngredient.deleteMany({
where: { userProfileId },
}),
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
prisma.userProfileDislikedIngredient.createMany({
data: dislikedIngredientIds.map((ingredientId) => ({
userProfileId,
ingredientId,
})),
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
}),
]);
return dislikedIngredientIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}

View file

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

View file

@ -1,10 +1,9 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createRecipeSchema,
ErrorCode,
createRecipeSchema,
listRecipesSchema,
submitTechStepCorrectionSchema,
updateRecipeSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
@ -18,10 +17,6 @@ import {
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();
@ -35,29 +30,13 @@ function parseRecipeId(rawId: string | undefined): number {
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,
}),
);
res.status(200).json(await listRecipes(viewerId, houseId, input.tab, input.search));
}),
);
@ -123,29 +102,3 @@ recipeRouter.delete(
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));
}),
);

View file

@ -8,52 +8,14 @@ import {
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: {
@ -62,46 +24,25 @@ function recipeInclude(viewerId: number) {
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
},
},
},
steps: { orderBy: { order: "asc" } },
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"];
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** 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 {
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
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,
@ -136,7 +77,6 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
name: recipe.name,
description: recipe.description,
picture: recipe.picture,
portions: recipe.portions,
authorId: recipe.authorId,
visibility: recipe.visibility,
allergens,
@ -145,70 +85,12 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
};
}
/**
* 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),
unit: recipeIngredient.unit,
}));
return {
...toRecipeSummaryView(recipe),
@ -218,7 +100,6 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
description: step.description,
picture: step.picture,
order: step.order,
techSteps: toStepTechStepViews(step.techSteps),
})),
};
}
@ -231,11 +112,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
* `RecipeVisibility` in schema.prisma.
*/
function canView(
recipe: {
authorId: number;
authorHouseId: number | null;
visibility: string;
},
recipe: { authorId: number; authorHouseId: number | null; visibility: string },
viewerId: number,
viewerHouseId: number | null,
): boolean {
@ -263,113 +140,9 @@ function visibleToViewerWhere(
};
}
/**
* `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
* optionally filtered further by a case-insensitive name substring. 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
@ -380,34 +153,12 @@ export async function listRecipes(
viewerId: number,
viewerHouseId: number | null,
tab: RecipeTab,
filters: ListRecipesFilters = {},
search?: string,
): Promise<RecipeSummaryView[]> {
try {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
const conditions: Prisma.RecipeWhereInput[] = [];
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":
@ -433,9 +184,6 @@ export async function listRecipes(
orderBy: { name: "asc" },
});
return recipes.map(toRecipeSummaryView);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -448,15 +196,11 @@ export async function getRecipe(
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
}
}
/**
@ -468,7 +212,6 @@ export async function getRecipe(
* 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(
@ -476,140 +219,29 @@ export async function createRecipe(
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,
unit: ingredient.unit,
})),
},
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
create: input.steps.map((step, 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 })) },
@ -617,9 +249,6 @@ async function createRecipeInternal(
include: recipeInclude(authorId),
});
return toRecipeView(created);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -632,7 +261,6 @@ async function createRecipeInternal(
* @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(
@ -641,12 +269,9 @@ export async function updateRecipe(
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 } }),
@ -658,30 +283,19 @@ export async function updateRecipe(
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,
unit: ingredient.unit,
})),
},
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
create: input.steps.map((step, 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 })) },
@ -690,9 +304,6 @@ export async function updateRecipe(
]);
return toRecipeView(await findRecipeOrThrow(id, viewerId));
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -708,12 +319,9 @@ export async function deleteRecipe(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
const usedInPlanning = await prisma.planningItem.findFirst({
where: { recipeId: id },
});
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
if (usedInPlanning) {
throw new HttpError(
409,
@ -723,9 +331,6 @@ export async function deleteRecipe(
}
await prisma.recipe.delete({ where: { id } });
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -739,60 +344,24 @@ export async function addFavorite(
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 },
},
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
}
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
}
/** 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),
@ -801,9 +370,6 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
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`). */
@ -812,7 +378,6 @@ async function assertIsAuthor(
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`);
@ -820,14 +385,10 @@ async function assertIsAuthor(
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 } },
@ -842,36 +403,10 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
`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({
@ -881,13 +416,6 @@ async function assertDietsExist(dietIds: number[]): Promise<void> {
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
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `Diet(s) not found: ${missing.join(", ")}`);
}
}

View file

@ -1,14 +1,6 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express";
import {
getAllergies,
getDiets,
getIngredients,
getSources,
getTechSteps,
getUnits,
getUtensils,
} from "./reference.service.js";
import { getAllergies, getDiets, getIngredients } from "./reference.service.js";
/**
* Router mounted at `/reference` in app.ts. Every route is deliberately
@ -41,31 +33,3 @@ referenceRouter.get(
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());
}),
);

View file

@ -1,12 +1,4 @@
import type {
AllergyView,
DietView,
IngredientView,
SourceView,
TechStepView,
UnitView,
UtensilView,
} from "@batch-cooking/shared";
import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
@ -18,15 +10,7 @@ import { prisma } from "../../db/prisma.js";
* client-side (`apps/web`'s `locales/fr/translation.json`).
*/
export async function getDiets(): Promise<DietView[]> {
try {
return await prisma.diet.findMany({ orderBy: { key: "asc" } });
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `async` body without a
// try/catch per the repo's convention.
throw err;
}
return prisma.diet.findMany({ orderBy: { key: "asc" } });
}
/**
@ -37,7 +21,6 @@ export async function getDiets(): Promise<DietView[]> {
* callers.
*/
export async function getAllergies(): Promise<AllergyView[]> {
try {
const allergies = await prisma.allergy.findMany({
include: { category: { select: { key: true, kind: true } } },
orderBy: { category: { key: "asc" } },
@ -47,84 +30,6 @@ export async function getAllergies(): Promise<AllergyView[]> {
key: allergy.category.key,
kind: allergy.category.kind,
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* All reference recipe-ingredient units, ordered by key (see {@link getDiets}
* for why) small, static list (see `reference-seed-data.ts`'s `UNITS`).
* `toBaseFactor` comes back as a Prisma `Decimal`, converted to a plain
* `number` here the same way `recipe.service.ts` does for
* `RecipeIngredient.quantity`.
*/
export async function getUnits(): Promise<UnitView[]> {
try {
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
return units.map((unit) => ({
id: unit.id,
key: unit.key,
type: unit.type,
toBaseFactor: Number(unit.toBaseFactor),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`). Not consumed by the recipe UI yet see {@link TechStepView}.
*/
export async function getTechSteps(): Promise<TechStepView[]> {
try {
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* All reference cooking utensils, ordered by key (see {@link getDiets} for
* why) small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
* same bare `id`/`key` shape as {@link getTechSteps}.
*/
export async function getUtensils(): Promise<UtensilView[]> {
try {
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* Every implemented recipe source, ordered by name (not `key` unlike
* every other reference catalog, `name` here *is* the display string a
* household picks from, see {@link SourceView}, so alphabetical-by-name is
* what a real picker should show). Empty until a concrete adapter is
* registered (see `recipe-source-registry.ts`) and synced (see
* `recipe-source-sync.ts`'s `syncRecipeSources`).
*/
export async function getSources(): Promise<SourceView[]> {
try {
// Explicit `select` — `url` exists on the `Source` row but isn't part of
// `SourceView` yet, so it must not leak into the response the way a bare
// `findMany()` would let it.
return await prisma.source.findMany({
select: {
id: true,
key: true,
name: true,
official: true,
iconUrl: true,
},
orderBy: { name: "asc" },
});
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -135,7 +40,6 @@ export async function getSources(): Promise<SourceView[]> {
* 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 } } } },
@ -149,18 +53,11 @@ export async function getIngredients(): Promise<IngredientView[]> {
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,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}

View file

@ -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);
}),
);

View file

@ -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;
}
}

View file

@ -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,
),
);
}),
);

View file

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

View file

@ -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);
});
}

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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);
});

View file

@ -1,7 +1,5 @@
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
@ -16,25 +14,11 @@ import { registerAllRecipeSources } from "../sources/index.js";
* 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
* each row's unique name, so re-running it 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);

View file

@ -1,55 +1,8 @@
import { createServer } from "./app.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();
server.listen(env.PORT, () => {
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
console.log(`API listening on http://localhost:${env.PORT}`);
});

View file

@ -1,425 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "750g";
// 750g.com's own site search is a client-side widget (results are fetched
// by the page's own JS after load, nothing server-rendered to scrape) — but
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
// returns an HTML fragment of recipe cards for a free-text query. Verified
// live: works with a bare `fetch`, no special headers/cookies/session
// needed, same as every other adapter in this family. Only used for a
// non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers
// a blank query with nothing at all.
const SEARCH_URL = "https://www.750g.com/genius/query/";
// What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query`
// ("browse everything", per `RecipeSourceListParams.query`'s own doc
// comment) — verified live, `SEARCH_URL` responds to a blank query with a
// zero-length body, so browsing this source with no filter typed would
// otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's
// own "latest recipes" archive: real, server-rendered pagination via
// `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see
// `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link`
// markup `extractRecipeCards` already reads elsewhere on the site. Checked
// live up to `page=500` — genuinely different recipes every time, no
// redirect/clamp once past whatever the real end is (unlike marmiton.ts's
// search, which 404s past its last page), so `list()` treats a page with no
// cards at all as the end-of-results signal instead.
const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm";
/**
* Matches every `<script type="application/ld+json">…</script>` block
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
* own private copy here rather than sharing that module's export: this one
* does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever
* sees it (see {@link sanitizeJsonLdBlocks} below), a different concern
* from extracting-and-parsing blocks into objects.
*/
const JSON_LD_SCRIPT_PATTERN =
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
/**
* Escapes any raw (unescaped) JSON control character U+0000U+001F
* found *inside* a string literal of `json`, leaving everything outside
* string literals (structural whitespace, brackets, ) untouched. Fixes a
* real bug in 750g.com's own JSON-LD generator: some `HowToStep.text`
* values contain a literal, un-escaped `\r\n` where valid JSON requires
* `\\r\\n` (verified live, e.g.
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm — and
* roughly a third of a random sample of recipe pages hit this)
* `JSON.parse` throws "Bad control character in string literal" on these
* pages as-is, which would make `jsonLdRecipeAdapter.parse` wrongly report
* "no JSON-LD Recipe found" on a page that has a perfectly good one.
*
* A blind find/replace across the whole block would be wrong: JSON also
* uses real newlines as *structural* whitespace between tokens
* (pretty-printing), where they're perfectly legal and must be left alone
* only walking the text with string-literal awareness (tracking `"…"`
* boundaries and `\`-escapes) can tell the two apart.
*/
function escapeRawControlCharactersInStrings(json: string): string {
const SHORT_ESCAPES: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\n": "\\n",
"\r": "\\r",
"\t": "\\t",
};
let result = "";
let inString = false;
let escapedNext = false;
for (const ch of json) {
if (!inString) {
if (ch === '"') inString = true;
result += ch;
continue;
}
if (escapedNext) {
result += ch;
escapedNext = false;
continue;
}
if (ch === "\\") {
result += ch;
escapedNext = true;
continue;
}
if (ch === '"') {
inString = false;
result += ch;
continue;
}
if (ch < " ") {
result += SHORT_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
continue;
}
result += ch;
}
return result;
}
/**
* Runs {@link escapeRawControlCharactersInStrings} over every JSON-LD
* `<script>` block's content in `html`, leaving the rest of the page
* untouched the repair step `fetchDetail`'s raw HTML needs before
* `jsonLdRecipeAdapter.parse` (which does its own extraction/`JSON.parse`
* internally) ever sees it.
*/
function sanitizeJsonLdBlocks(html: string): string {
return html.replace(
JSON_LD_SCRIPT_PATTERN,
(_match, openTag: string, json: string, closeTag: string) =>
`${openTag}${escapeRawControlCharactersInStrings(json)}${closeTag}`,
);
}
/**
* Numeric entities plus a hand-picked table of named ones not a general
* HTML5 entity decoder (400+ named entities exist in the spec), just what's
* actually been observed necessary to clean up 750g.com's French recipe
* text: the five basic XML entities, Latin-1 accented letters, and a
* handful of common punctuation entities.
*/
const NAMED_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
eacute: "é",
egrave: "è",
ecirc: "ê",
euml: "ë",
agrave: "à",
acirc: "â",
auml: "ä",
icirc: "î",
iuml: "ï",
ocirc: "ô",
ouml: "ö",
ucirc: "û",
ugrave: "ù",
uuml: "ü",
ccedil: "ç",
oelig: "œ",
aelig: "æ",
laquo: "«",
raquo: "»",
lsquo: "",
rsquo: "",
ldquo: "“",
rdquo: "”",
hellip: "…",
ndash: "",
mdash: "—",
deg: "°",
};
/** One pass of numeric (`&#39;`/`&#x27;`) and {@link NAMED_ENTITIES} decoding — see {@link decodeHtmlEntities}, which is what actually runs against parsed text; this is split out only so that function can run it twice. */
function decodeHtmlEntitiesOnce(text: string): string {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (entity, body: string) => {
if (body[0] === "#") {
const isHex = body[1] === "x" || body[1] === "X";
const codePoint = isHex
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10);
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
return NAMED_ENTITIES[body] ?? entity;
});
}
/**
* Decodes HTML entities in free-text pulled from 750g.com, run **twice**:
* its JSON-LD sometimes double-escapes text that already went through its
* own HTML-entity encoder once e.g. a real "é" ends up as `&amp;eacute;`
* (the `&` of an already-produced `&eacute;` got re-escaped to `&amp;`)
* rather than a plain `&eacute;` or a raw "é" (verified live, e.g.
* "Pr&amp;eacute;parez" on
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm). One
* pass turns that into `&eacute;` a *newly formed*, valid-looking entity
* so a second pass is needed to resolve it the rest of the way to "é". A
* string with no entities at all (the common case) is unaffected by either
* pass.
*/
function decodeHtmlEntities(text: string): string {
return decodeHtmlEntitiesOnce(decodeHtmlEntitiesOnce(text));
}
/**
* Runs {@link decodeHtmlEntities} over every free-text field of a
* `ParsedRecipe` produced by `jsonLdRecipeAdapter.parse`. `picture`/
* `sourceUrl` are deliberately left untouched they're URLs, not prose,
* and the entity-encoding bug this fixes has only ever been observed in
* name/description/instruction/ingredient text, never in a URL field.
*/
function decodeParsedRecipeText(recipe: ParsedRecipe): ParsedRecipe {
return {
...recipe,
name: decodeHtmlEntities(recipe.name),
description: recipe.description === null ? null : decodeHtmlEntities(recipe.description),
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
rawText: decodeHtmlEntities(ingredient.rawText),
name: decodeHtmlEntities(ingredient.name),
})),
steps: recipe.steps.map((step) => ({
...step,
description: decodeHtmlEntities(step.description),
})),
};
}
/** One recipe card as scraped off a 750g.com results fragment (search or listing page) — see {@link extractRecipeCards}. */
interface SevenFiftyGCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of a 750g.com results HTML fragment
* there's no JSON-LD `ItemList` on this endpoint to lean on (unlike
* marmiton.ts's search page), just the same server-rendered `card-recipe`
* markup 750g.com uses everywhere. Each card's title/url comes from its
* `<a class="card-link">`; its image is whichever `<img>` most recently
* preceded that link, rather than a naive same-index zip of "every image on
* the page" against "every link on the page" a plain fragment like this
* one carries a few extra decorative images with no card of their own
* (verified live: 28 `<img>` tags against 23 real cards for one sample
* query), which would silently shift every image after the first stray one
* onto the wrong title. Each card's own `<img>` always sits immediately
* before its title link in the markup, so "nearest preceding image" is
* unambiguous and doesn't depend on the two counts matching.
*/
function extractRecipeCards(html: string): SevenFiftyGCard[] {
const linkPattern =
/<a\s+href="(https:\/\/www\.750g\.com\/[^"]+)"\s+class="card-link[^"]*">([^<]+)<\/a>/g;
const imagePattern = /<img[^>]*\ssrc="(https:\/\/static\.750g\.com\/images\/[^"]+)"[^>]*>/g;
const images = [...html.matchAll(imagePattern)];
const cards: SevenFiftyGCard[] = [];
let searchFrom = 0;
for (const linkMatch of html.matchAll(linkPattern)) {
let image: string | null = null;
for (const imgMatch of images) {
if (imgMatch.index === undefined || imgMatch.index >= linkMatch.index) break;
if (imgMatch.index >= searchFrom) image = imgMatch[1] ?? null;
}
cards.push({
url: linkMatch[1] ?? "",
title: decodeHtmlEntities(linkMatch[2] ?? ""),
image,
});
searchFrom = linkMatch.index;
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"750g"`) same reasoning as
* marmiton.ts's identically-named helper: `fetchDetail`/`parse` below are
* thin wrappers around the generic adapter's own methods, but a caller
* catching `RecipeSourceError` and reading `.sourceKey` should see "750g",
* the source it actually asked about.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* 750g.com one of France's largest recipe sites. Unofficial (`official:
* false`): no published API, this adapter fetches ordinary pages and reads
* the schema.org structured data 750g.com embeds for search engines, built
* on {@link jsonLdRecipeAdapter} the same way marmiton.ts is. Two real
* 750g-specific problems separate this adapter from a pure thin wrapper
* like marmiton.ts, though:
*
* - `list()` has no `ItemList` JSON-LD to read off its search results (see
* {@link extractRecipeCards}) its site search is a client-side widget,
* so a non-empty query instead calls the plain GET endpoint that widget's
* own JS calls internally (`SEARCH_URL`), an "AI answer engine" that
* returns a curated batch of cards rather than an exhaustive, paginated
* catalog verified live, requesting `page=2` of the same query always
* comes back empty, so `nextCursor` is always `null` in that case, same
* as `theMealDbAdapter`'s "one response holds every match". An empty
* query reads `LATEST_RECIPES_URL` instead, a real paginated catalog
* `SEARCH_URL` itself answers a blank query with nothing at all, which
* would otherwise make browsing this source with no filter typed always
* come back empty.
* - `parse()` doesn't delegate to `jsonLdRecipeAdapter.parse` as directly as
* marmiton.ts's does — 750g.com's own JSON-LD generator has two real bugs
* this adapter works around: some pages embed literal, unescaped control
* characters inside a JSON string (see {@link sanitizeJsonLdBlocks}), and
* its free text is sometimes double HTML-entity-encoded (see
* {@link decodeParsedRecipeText}). Both are pre/post-processing around the
* same underlying delegation, not a reimplementation of it.
*/
export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "750g",
official: false,
// Un chemin stable, sans le paramètre `?v=…` de cache-busting que 750g.com
// ajoute à ses balises <link> (susceptible de changer à chaque
// déploiement) — cette adresse répond correctement sans lui.
iconUrl: "https://www.750g.com/img/750g/favicons/favicon.svg",
// Le contenu de 750g.com (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
const page = params.cursor ? Number(params.cursor) : 1;
const hasQuery = query.length > 0;
// Deux endpoints distincts selon qu'il y a un texte de recherche ou
// non — voir les commentaires de `SEARCH_URL`/`LATEST_RECIPES_URL` :
// le premier ne répond rien du tout à une requête vide, le second est
// le vrai catalogue paginé "dernières recettes" de 750g.com. `page`
// n'a de sens que pour le second (le premier ne pagine pas — voir
// plus bas) mais est toujours passé, y compris `page=1`, par
// cohérence avec le reste de cette famille d'adaptateurs.
const listUrl = hasQuery
? `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`
: `${LATEST_RECIPES_URL}?page=${page}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error listing 750g recipes (${listUrl})`,
{
cause,
},
);
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`750g responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
// No filter for a missing title/url here (unlike marmiton.ts/
// the-meal-db.ts, which drop entries with a null field from a
// structured API response) — `extractRecipeCards`' own regex requires
// at least one character for both, so there's no "absent field" shape
// to guard against.
const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
// La recherche par texte libre ne pagine pas du tout (voir le
// commentaire de `SEARCH_URL`) — `nextCursor` y vaut toujours `null`,
// même logique que `theMealDbAdapter`. "Dernières recettes" pagine
// réellement (voir le commentaire de `LATEST_RECIPES_URL`) — une page
// sans aucune carte en est le signal de fin.
const nextCursor = hasQuery ? null : items.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "750g" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`json-ld-recipe.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// 750g.com (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement : la réparation du JSON-LD (voir
// `sanitizeJsonLdBlocks`) n'a lieu qu'à l'étape `parse()`, pas ici.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
const sanitizedHtml = sanitizeJsonLdBlocks(raw.html);
const parsed = jsonLdRecipeAdapter.parse({ html: sanitizedHtml, url: raw.url });
return decodeParsedRecipeText(parsed);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,42 +0,0 @@
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js";
import { theMealDbAdapter } from "./the-meal-db.js";
/**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry
* points that need the registry populated:
*
* - `server.ts` the running API process, before it starts listening.
* - `prisma/seed.ts` so `syncRecipeSources` has something to mirror into
* the `sources` table (`Source`, household-toggleable see
* `HouseSource`).
*
* Deliberately **not** imported by `app.ts`: `createApp()` is what every
* test file gets via supertest, and registering a real adapter there would
* make its presence in the registry depend on test *order* (once
* registered at module load, nothing re-registers it after a test's
* `clearRecipeSources()` clears it out) instead of each test's own
* explicit setup. Tests that need a source in the registry register their
* own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`).
*
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) itself is deliberately **not**
* registered here it's a generic schema.org-JSON-LD parser meant to be
* specialized per scraped website, not a household-toggleable `Source` in
* its own right: nobody can meaningfully "trust" or "enable" a generic
* parsing mechanism the way they can a named website. `marmitonAdapter`
* (marmiton.ts), `sevenFiftyGAdapter` (750g.ts) and `mangerBougerAdapter`
* (manger-bouger.ts) are exactly that specialization, one per site the
* concrete adapters its own doc comment anticipated ("a concrete adapter
* for a specific site would use it internally").
*/
export function registerAllRecipeSources(): void {
registerRecipeSource(theMealDbAdapter);
registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter);
}

View file

@ -1,271 +0,0 @@
import type {
ParsedRecipe,
ParsedRecipeIngredient,
RecipeSourceAdapter,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
const SOURCE_KEY = "jsonLdRecipe";
/**
* Matches every `<script type="application/ld+json">…</script>` block in a
* page a plain regex rather than a full HTML parser (no such dependency
* exists in this codebase yet): a `<script>` tag's content is never nested
* HTML, so a non-greedy match reliably captures each block whole without
* needing real DOM parsing.
*/
const JSON_LD_SCRIPT_PATTERN =
/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
/** schema.org's `image` property — a bare URL, one `ImageObject`, or an array of either. */
type SchemaOrgImage = string | { url?: string } | Array<string | { url?: string }>;
/** One `HowToStep`, or a `HowToSection` grouping several of them under `itemListElement` — schema.org's two shapes for `recipeInstructions` array entries. */
interface SchemaOrgHowToStep {
"@type"?: string;
text?: string;
name?: string;
itemListElement?: SchemaOrgHowToStep[];
}
/** The subset of schema.org's `Recipe` type this adapter reads — every field is optional per the spec, sites vary in how much they fill in. */
interface SchemaOrgRecipe {
"@type"?: string | string[];
"@graph"?: unknown[];
name?: string;
description?: string;
image?: SchemaOrgImage;
recipeYield?: string | number | Array<string | number>;
recipeIngredient?: string[];
recipeInstructions?: string | Array<string | SchemaOrgHowToStep>;
url?: string;
}
/**
* Extracts and JSON-parses every JSON-LD block on the page a block that
* fails to parse is skipped rather than failing the whole page over one
* malformed script tag (some sites ship more than one JSON-LD block, e.g.
* `BreadcrumbList` alongside `Recipe`). Exported (not just consumed
* internally by {@link findRecipeNode} below) so a concrete per-site adapter
* built on top of this module e.g. `marmiton.ts`, which needs the same
* page's embedded `ItemList` rather than its `Recipe` reuses this same
* extraction step instead of re-implementing the `<script>`-block regex.
*/
export function extractJsonLdBlocks(html: string): unknown[] {
const blocks: unknown[] = [];
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
try {
blocks.push(JSON.parse((match[1] ?? "").trim()));
} catch {
// Malformed JSON-LD — not our problem to fix, just skip it.
}
}
return blocks;
}
function hasRecipeType(type: SchemaOrgRecipe["@type"]): boolean {
return type === "Recipe" || (Array.isArray(type) && type.includes("Recipe"));
}
/**
* Finds the first `Recipe`-typed node within one parsed JSON-LD block
* handles a bare `Recipe` object, an array of mixed-type nodes (a page
* commonly ships `Recipe` alongside `BreadcrumbList`/`WebPage`/), and the
* `@graph` wrapper some sites nest everything under.
*/
function findRecipeNode(node: unknown): SchemaOrgRecipe | null {
if (node === null || typeof node !== "object") return null;
if (Array.isArray(node)) {
for (const item of node) {
const found = findRecipeNode(item);
if (found) return found;
}
return null;
}
const obj = node as SchemaOrgRecipe;
if (hasRecipeType(obj["@type"])) return obj;
if (Array.isArray(obj["@graph"])) return findRecipeNode(obj["@graph"]);
return null;
}
/** `image` can be a bare URL, an `ImageObject`, or an array of either (usually several resolutions of the same picture) — this just wants one usable URL, the first it finds. */
function extractImageUrl(image: SchemaOrgImage | undefined): string | null {
if (!image) return null;
if (typeof image === "string") return image;
if (Array.isArray(image)) return image.length > 0 ? extractImageUrl(image[0]) : null;
return image.url ?? null;
}
/** `recipeYield` is meant to be a serving count but schema.org also allows a free-text string (`"4 servings"`) or an array of either — pulls the first integer out of whatever's there, `null` if none can be found. */
function extractPortions(recipeYield: SchemaOrgRecipe["recipeYield"]): number | null {
const raw = Array.isArray(recipeYield) ? recipeYield[0] : recipeYield;
if (raw === undefined || raw === null) return null;
if (typeof raw === "number") return Number.isFinite(raw) ? Math.trunc(raw) : null;
const match = raw.match(/\d+/);
return match ? Number(match[0]) : null;
}
/**
* `recipeIngredient` is already a flat array of free-text lines the
* closest schema.org gets to `ParsedRecipeIngredient`. Unlike TheMealDB's
* separate name/measure fields, there's nothing here to cleanly split a
* quantity/unit away from the ingredient name, so `name` just repeats the
* whole line (same fallback every other free-text-only field in this
* codebase uses when it can't do better).
*/
function extractIngredients(lines: string[] | undefined): ParsedRecipeIngredient[] {
return (lines ?? [])
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => ({ rawText: line, quantity: null, unit: null, name: line }));
}
/**
* `recipeInstructions` is the most inconsistent field across real sites:
* one unbroken string, an array of plain strings, an array of `HowToStep`
* objects (`text`, sometimes `name` instead), or `HowToStep`s grouped into
* named `HowToSection`s via a nested `itemListElement` this flattens
* every shape into a plain ordered list of step descriptions.
*/
function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]): string[] {
if (!instructions) return [];
if (typeof instructions === "string") {
// A single field crammed with every step — the closest split available
// is by line, same approach TheMealDB's own free-text instructions use.
return instructions
.split(/\r?\n+/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
const steps: string[] = [];
for (const item of instructions) {
if (typeof item === "string") {
const trimmed = item.trim();
if (trimmed.length > 0) steps.push(trimmed);
continue;
}
if (Array.isArray(item.itemListElement)) {
steps.push(...flattenInstructions(item.itemListElement));
continue;
}
const text = item.text ?? item.name;
if (text && text.trim().length > 0) steps.push(text.trim());
}
return steps;
}
/**
* Generic recipe source: given any recipe page's URL, fetches it and reads
* the schema.org `Recipe` structured data most recipe sites embed as
* JSON-LD for search engines (Google Rich Results, etc.) one adapter
* covering a large share of recipe sites, rather than one hand-built
* adapter per site.
*
* `official: false` even though JSON-LD is the site's own published
* structured data (not markup we're inferring meaning from), this adapter
* has no dedicated relationship with any one site: it fetches an arbitrary
* page and reads embedded metadata, not a maintained API endpoint a
* publisher operates and supports. That's closer to "unofficial" than
* "official" under this app's own distinction (see `official`'s doc
* comment on `RecipeSourceAdapter`).
*
* Has no catalog of its own to browse `list()` always returns nothing;
* `fetchDetail`'s `externalId` is simply the target URL itself, not an id
* from a prior `list()` call. A future "import from URL" flow would call
* `fetchDetail(pastedUrl)` directly.
*
* `marmiton.ts`'s `marmitonAdapter` is the first concrete adapter built on
* top of this one its `fetchDetail`/`parse` delegate straight here (a
* marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`, nothing
* site-specific to handle), and it only adds the `list()` this adapter
* itself can't offer, by reading the separate `ItemList` marmiton.org embeds
* on its search-results pages. `750g.ts`'s `sevenFiftyGAdapter` and
* `manger-bouger.ts`'s `mangerBougerAdapter` follow the same shape for their
* own sites, but each wraps this adapter's own `parse()` (rather than
* delegating untouched) to work around real bugs/gaps in that site's own
* JSON-LD see each module's doc comment.
*/
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
html: string;
url: string;
}> = {
key: SOURCE_KEY,
name: "Import générique (JSON-LD)",
official: false,
iconUrl: null,
// Genuinely varies per scraped site (this adapter has no fixed content
// language of its own) — "fr" as a placeholder since it's never actually
// consulted: this adapter isn't registered into the source registry (see
// sources/index.ts), so nothing calls translateRecipe against it today.
// A concrete per-site adapter built on top of this one would declare its
// own real locale.
locale: "fr",
async list() {
try {
return { items: [], nextCursor: null };
} catch (err) {
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention.
throw err;
}
},
async fetchDetail(url: string): Promise<{ html: string; url: string }> {
try {
let response: Response;
try {
response = await fetch(url);
} catch (cause) {
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
}
if (!response.ok) {
throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
}
const html = await response.text();
return { html, url };
} catch (err) {
throw err; // see list()'s catch comment above
}
},
parse({ html, url }): ParsedRecipe {
let recipe: SchemaOrgRecipe | null = null;
for (const block of extractJsonLdBlocks(html)) {
recipe = findRecipeNode(block);
if (recipe) break;
}
if (!recipe) {
throw new RecipeSourceParseError(SOURCE_KEY, `No JSON-LD Recipe found at ${url}`);
}
if (!recipe.name) {
throw new RecipeSourceParseError(SOURCE_KEY, `JSON-LD Recipe at ${url} has no name`);
}
const steps = flattenInstructions(recipe.recipeInstructions).map((description) => ({
description,
picture: null,
}));
if (steps.length === 0) {
throw new RecipeSourceParseError(
SOURCE_KEY,
`JSON-LD Recipe "${recipe.name}" at ${url} has no usable instructions`,
);
}
return {
name: recipe.name,
description: recipe.description ?? null,
picture: extractImageUrl(recipe.image),
portions: extractPortions(recipe.recipeYield),
sourceUrl: recipe.url ?? url,
ingredients: extractIngredients(recipe.recipeIngredient),
steps,
};
},
};

View file

@ -1,355 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "mangerBouger";
// "La Fabrique à Menus" — mangerbouger.fr's recipe tool (Santé publique
// France). Its listing page is a Next.js app with no JSON-LD `ItemList` at
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
// plain GET carries the exact same Redux state the client hydrates from as
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
// has everything `list()` needs. Verified live: `?query=<free text>` really
// filters server-side (not just a client-side URL update over an
// already-fetched page), and `page`/`hasMorePages` behave as real,
// consistent pagination — the best-behaved of this adapter family's three
// sources on that front.
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
/** Matches the `<script id="__NEXT_DATA__">…</script>` block every Next.js page ships — the site's own server-rendered hydration data, read instead of scraping HTML for both `list()` (the listing's recipe cards) and `parse()` (backfilling a gap in the detail page's JSON-LD, see {@link extractPortionsFromNextData}). */
const NEXT_DATA_PATTERN = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/;
/** The one field of one `list[]` entry `list()` actually reads off the listing page's `__NEXT_DATA__` — that state carries the site's full internal `Recipe` shape (60+ fields: nutriscore, seasons, macros, …), none of which this adapter's contract has anywhere to put. */
interface MangerBougerListEntry {
slug?: string;
name?: string;
image?: string | null;
}
/** The slice of `__NEXT_DATA__` this module reads off the *listing* page. */
interface MangerBougerListPageData {
props?: {
initialState?: {
recipes?: {
list?: MangerBougerListEntry[];
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
hasMorePages?: boolean;
};
};
};
}
/** The slice of `__NEXT_DATA__` this module reads off a recipe *detail* page — a different shape than the listing page's (`initialState.recipe.recipe`, not `initialState.recipes.list[]`) since it's a different Redux slice entirely. */
interface MangerBougerDetailPageData {
props?: {
initialState?: {
recipe?: {
recipe?: {
portions?: unknown;
};
};
};
};
}
/** Parses the page's `__NEXT_DATA__` block into `T`, or `null` if the block is missing or isn't valid JSON — callers degrade gracefully rather than throw, same as `marmiton.ts`'s "page has no ItemList at all" handling. */
function extractNextData<T>(html: string): T | null {
const match = html.match(NEXT_DATA_PATTERN);
if (!match) return null;
try {
return JSON.parse(match[1] ?? "") as T;
} catch {
return null;
}
}
function detailUrl(slug: string): string {
return `${DETAIL_BASE_URL}${slug}`;
}
/**
* Matches the single `<script type="application/ld+json">…</script>` block
* a mangerbouger.fr recipe *detail* page carries (verified live across a
* sample of 9 recipes always exactly one, always a bare `Recipe`, never
* an `@graph`) a much narrower pattern than `json-ld-recipe.ts`'s own
* `JSON_LD_SCRIPT_PATTERN` (no `g` flag: this module only ever needs the
* first/only block, to patch it see {@link patchRecipeJsonLd}) or
* `750g.ts`'s identically-named private copy (which does its own,
* different, character-level repair over every block on the page).
*/
const JSON_LD_SCRIPT_PATTERN =
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/i;
/** One node of a Slate.js rich-text document — see {@link flattenSlateDocument}. */
interface SlateNode {
type?: string;
text?: string;
children?: SlateNode[];
}
/** Concatenates a run of inline Slate nodes (leaf text, or further-nested inline runs) with no separator — bold/italic/underline marks (the only ones observed) carry no plain-text equivalent and are simply dropped. */
function flattenSlateInline(nodes: SlateNode[]): string {
return nodes
.map((node) =>
typeof node.text === "string"
? node.text
: node.children
? flattenSlateInline(node.children)
: "",
)
.join("");
}
/**
* Flattens a Slate.js document's top-level blocks into one line of plain
* text each verified live across every recipe step sampled (72 recipes):
* only `paragraph` and `bulleted-list` (of `list-item`s) ever appear as
* block types, so that's all this handles; any other/unrecognized block
* type still degrades reasonably (its own children read as one inline run)
* rather than being dropped outright.
*/
function flattenSlateBlocks(nodes: SlateNode[]): string[] {
const lines: string[] = [];
for (const node of nodes) {
if (node.type === "bulleted-list" && node.children) {
lines.push(...flattenSlateBlocks(node.children));
continue;
}
if (node.type === "list-item" && node.children) {
const text = flattenSlateInline(node.children);
if (text.trim().length > 0) lines.push(`- ${text}`);
continue;
}
if (node.children) {
const text = flattenSlateInline(node.children);
if (text.trim().length > 0) lines.push(text);
continue;
}
if (typeof node.text === "string" && node.text.trim().length > 0) lines.push(node.text);
}
return lines;
}
/**
* Flattens one `HowToStep.text` value into plain text. mangerbouger.fr's
* own JSON-LD embeds this field pre-formatted for its own web app instead
* of as prose: `text` is itself a JSON-serialized Slate.js rich-text
* document (verified live: every one of 72 sampled recipe steps parses as
* one) handing that straight to `jsonLdRecipeAdapter.parse` would surface
* the raw `[{"type":"paragraph","children":[{"text":"…` blob as a step's
* description, unusable as-is. `json` that doesn't parse as an array (a
* genuinely plain-text step, or some future/different shape) is returned
* unchanged rather than mangled.
*/
function flattenSlateDocument(json: string): string {
let doc: unknown;
try {
doc = JSON.parse(json);
} catch {
return json;
}
if (!Array.isArray(doc)) return json;
return flattenSlateBlocks(doc as SlateNode[]).join("\n");
}
/** The two schema.org `Recipe` fields {@link patchRecipeJsonLd} patches, plus an index signature so every other field survives re-serialization untouched. */
interface JsonLdRecipeLike {
recipeInstructions?: unknown;
recipeYield?: unknown;
[key: string]: unknown;
}
/** One `HowToStep`-shaped entry of `recipeInstructions`, as far as {@link patchRecipeJsonLd} needs to know. */
interface JsonLdHowToStepLike {
text?: unknown;
[key: string]: unknown;
}
/**
* `state.recipe.recipe.portions` from the same detail page's `__NEXT_DATA__`
* the number `recipeYield` should have been (see {@link patchRecipeJsonLd}),
* read from the site's own internal state rather than left unstated.
*/
function extractPortionsFromNextData(html: string): number | null {
const data = extractNextData<MangerBougerDetailPageData>(html);
const portions = data?.props?.initialState?.recipe?.recipe?.portions;
return typeof portions === "number" ? portions : null;
}
/**
* Repairs the two real gaps verified live in mangerbouger.fr's own
* recipe-detail JSON-LD, then hands the patched HTML to
* `jsonLdRecipeAdapter.parse` unmodified otherwise same "fix what's
* actually broken, delegate the rest" shape as `750g.ts`'s
* `sanitizeJsonLdBlocks`/`decodeParsedRecipeText`, just structural (parse
* mutate re-serialize the one JSON-LD object) rather than textual, since
* both gaps need real understanding of the document, not character-level
* fixups:
*
* - `recipeInstructions[].text` is Slate.js rich text, not prose flattened
* via {@link flattenSlateDocument}.
* - `recipeYield` is absent on every one of 9 sampled recipes (schema.org
* allows omitting it, and mangerbouger.fr's generator apparently always
* does) even though the site's own internal data has the serving count
* right there backfilled from `__NEXT_DATA__` via
* {@link extractPortionsFromNextData} rather than left as a needless
* `portions: null` on every single imported recipe.
*
* A missing or malformed JSON-LD block is left completely untouched
* `jsonLdRecipeAdapter`'s own "no JSON-LD Recipe found"/"malformed block,
* skip it" handling is exactly the right behavior for that, no need to
* duplicate it here.
*/
function patchRecipeJsonLd(html: string): string {
const match = html.match(JSON_LD_SCRIPT_PATTERN);
if (!match) return html;
let recipe: JsonLdRecipeLike;
try {
recipe = JSON.parse(match[2] ?? "{}") as JsonLdRecipeLike;
} catch {
return html;
}
if (Array.isArray(recipe.recipeInstructions)) {
for (const step of recipe.recipeInstructions as JsonLdHowToStepLike[]) {
if (step && typeof step === "object" && typeof step.text === "string") {
step.text = flattenSlateDocument(step.text);
}
}
}
if (recipe.recipeYield === undefined) {
const portions = extractPortionsFromNextData(html);
if (portions !== null) recipe.recipeYield = portions;
}
const patchedJson = JSON.stringify(recipe);
return html.replace(
JSON_LD_SCRIPT_PATTERN,
(_full, openTag: string, _json: string, closeTag: string) =>
`${openTag}${patchedJson}${closeTag}`,
);
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"mangerBouger"`) same
* reasoning as `marmiton.ts`/`750g.ts`'s identically-named helpers.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* mangerbouger.fr ("La Fabrique à Menus") Santé publique France's public
* nutrition site. Unofficial (`official: false`): no published API, same
* reasoning as every other adapter in this family fetching ordinary pages
* and reading data the site never committed to a stable contract, not a
* maintained endpoint. `fetchDetail` delegates straight to
* `jsonLdRecipeAdapter`; `parse` wraps it with {@link patchRecipeJsonLd}
* (see that function's doc comment for the two real gaps it fixes).
* `list()` doesn't use JSON-LD at all — see `LIST_URL`'s doc comment.
*/
export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Manger Bouger",
official: false,
// Chemin fixe (pas d'icône versionnée/hashée comme sur d'autres sources
// de cette famille) — répond correctement sans paramètre supplémentaire.
iconUrl: "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/favicon.ico",
// Le contenu de mangerbouger.fr (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error listing recipes (${listUrl})`, {
cause,
});
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`mangerbouger.fr responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
const data = extractNextData<MangerBougerListPageData>(html);
const state = data?.props?.initialState?.recipes;
const items: RecipeSourceListItem[] = (state?.list ?? [])
.filter((entry): entry is MangerBougerListEntry & { slug: string; name: string } =>
Boolean(entry.slug && entry.name),
)
.map((entry) => ({
externalId: detailUrl(entry.slug),
title: entry.name,
picture: entry.image ?? null,
url: detailUrl(entry.slug),
}));
return { items, nextCursor: state?.hasMorePages ? String(page + 1) : null };
} catch (err) {
// Rethrown as-is (already keyed "mangerBouger" by whichever branch
// above threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`750g.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// mangerbouger.fr (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement : la réparation du JSON-LD (voir
// `patchRecipeJsonLd`) n'a lieu qu'à l'étape `parse()`, pas ici.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
const patchedHtml = patchRecipeJsonLd(raw.html);
return jsonLdRecipeAdapter.parse({ html: patchedHtml, url: raw.url });
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,206 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { extractJsonLdBlocks, jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "marmiton";
const SEARCH_URL = "https://www.marmiton.org/recettes/recherche.aspx";
/**
* One `ListItem` inside the schema.org `ItemList` marmiton.org embeds as
* JSON-LD on its search-results pages the subset this adapter reads. Also
* what a search whose term happens to match a known ingredient (e.g.
* `aqt=poulet`) actually returns: marmiton.org silently serves its
* ingredient-index page instead of a "search results" page for those terms,
* but that page embeds the exact same `ItemList` shape, so `list()` doesn't
* need to tell the two apart.
*/
interface MarmitonListItem {
"@type"?: string;
url?: string;
name?: string;
image?: string;
}
/** The subset of a schema.org `ItemList` this adapter reads off marmiton.org's search-results page. */
interface MarmitonItemList {
"@type"?: string;
"@graph"?: unknown[];
itemListElement?: MarmitonListItem[];
}
/**
* Finds the first `ItemList` node within one parsed JSON-LD block mirrors
* `findRecipeNode`'s traversal in json-ld-recipe.ts (array of mixed-type
* nodes, `@graph` wrapper) but looks for the results listing marmiton.org's
* search page embeds instead of a `Recipe`.
*/
function findItemListNode(node: unknown): MarmitonItemList | null {
if (node === null || typeof node !== "object") return null;
if (Array.isArray(node)) {
for (const item of node) {
const found = findItemListNode(item);
if (found) return found;
}
return null;
}
const obj = node as MarmitonItemList;
if (obj["@type"] === "ItemList") return obj;
if (Array.isArray(obj["@graph"])) return findItemListNode(obj["@graph"]);
return null;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"marmiton"`). `fetchDetail`/
* `parse` below are thin wrappers around the generic adapter's own methods
* (see this module's doc comment) but a caller catching `RecipeSourceError`
* and reading `.sourceKey` to attribute a failure to a specific `Source`
* should see "marmiton", the source it actually asked about, not the
* internal implementation detail this adapter happens to be built on.
* Anything else (a bug, an unexpected throw) is passed through unchanged
* only the vocabulary this module documents gets relabeled.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* marmiton.org France's largest recipe site. Unofficial (`official:
* false`): there's no published API, this adapter fetches ordinary pages and
* reads the schema.org structured data marmiton.org embeds for search
* engines, same as {@link jsonLdRecipeAdapter} it's built on. It's the first
* concrete, per-site adapter that generic adapter's own doc comment
* anticipated ("a concrete adapter for a specific site would use it
* internally") `fetchDetail`/`parse` below just delegate straight to it,
* since a marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`
* with nothing site-specific to handle. The only real Marmiton-specific
* logic is `list()`: `jsonLdRecipeAdapter` has no catalog of its own to
* browse, but marmiton.org's search-results page embeds a browsable
* `ItemList` this adapter reads directly (see {@link findItemListNode}).
*/
export const marmitonAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Marmiton",
official: false,
// Un chemin stable (jamais un nom de fichier avec un hash de build, comme
// les icônes servies depuis statics.marmiton.fr) — marmiton.org sert son
// favicon à cette adresse indépendamment de tout déploiement.
iconUrl: "https://www.marmiton.org/favicon.ico",
// Le contenu de Marmiton (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
// Un curseur opaque qui encode simplement le numéro de page suivant —
// marmiton.org pagine sa recherche via `&page=N` (page 1 implicite
// quand le paramètre est absent), pas de token dédié à faire
// transiter.
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const searchUrl = `${SEARCH_URL}?aqt=${encodeURIComponent(query)}${
page > 1 ? `&page=${page}` : ""
}`;
let response: Response;
try {
response = await fetch(searchUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error searching Marmiton (${searchUrl})`,
{ cause },
);
}
// marmiton.org répond 404 dès que `page` dépasse la dernière page de
// résultats pour cette recherche — pas un vrai échec, juste "il n'y a
// plus rien" : son `ItemList` ne porte aucun total fiable (son
// `numberOfItems` vaut toujours la taille de la page courante, jamais
// le nombre total de résultats) pour le détecter à l'avance autrement
// qu'en demandant la page suivante et en constatant qu'elle est vide.
if (response.status === 404) {
return { items: [], nextCursor: null };
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Marmiton search responded ${response.status} (${searchUrl})`,
);
}
const html = await response.text();
let itemList: MarmitonItemList | null = null;
for (const block of extractJsonLdBlocks(html)) {
itemList = findItemListNode(block);
if (itemList) break;
}
const items: RecipeSourceListItem[] = (itemList?.itemListElement ?? [])
.filter((entry): entry is MarmitonListItem & { url: string; name: string } =>
Boolean(entry.url && entry.name),
)
.map((entry) => ({
externalId: entry.url,
title: entry.name,
picture: entry.image ?? null,
url: entry.url,
}));
return {
items,
// Voir le commentaire ci-dessus sur la réponse 404 : une page vide
// est elle-même le signal de fin, donc on ne propose une page
// suivante que si celle-ci en a retourné au moins un résultat.
nextCursor: items.length > 0 ? String(page + 1) : null,
};
} catch (err) {
// Rethrown as-is (already keyed "marmiton" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `json-ld-recipe.ts`/`the-meal-db.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// marmiton.org (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement (voir le commentaire du module).
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
return jsonLdRecipeAdapter.parse(raw);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,180 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
const SOURCE_KEY = "theMealDb";
// TheMealDB documents "1" as a shared, public test key, free to use for
// development (https://www.themealdb.com/api.php) — a deployment serving
// real traffic is expected to use a supporter-tier key instead (paid, via
// Patreon). Configurable here via an env var without touching anything
// else in this adapter.
const API_KEY = process.env.THE_MEAL_DB_API_KEY ?? "1";
const API_BASE = `https://www.themealdb.com/api/json/v1/${API_KEY}`;
/**
* TheMealDB's flat meal shape ingredients/measures are 20 numbered
* field pairs (`strIngredient1`/`strMeasure1` `strIngredient20`/
* `strMeasure20`), not an array, hence the string index signature rather
* than 20 explicit optional properties.
*/
export interface TheMealDbMeal {
idMeal: string;
strMeal: string | null;
strMealThumb: string | null;
strInstructions: string | null;
[key: string]: string | null | undefined;
}
interface TheMealDbMealsResponse {
meals: TheMealDbMeal[] | null;
}
async function fetchTheMealDb<T>(path: string): Promise<T> {
try {
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`);
} catch (cause) {
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, {
cause,
});
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`TheMealDB responded ${response.status} (${path})`,
);
}
return (await response.json()) as T;
} catch (err) {
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
function detailUrl(idMeal: string): string {
return `https://www.themealdb.com/meal/${idMeal}`;
}
/**
* TheMealDB (themealdb.com) a free, public recipe API (no scraping: the
* publisher's own structured JSON, hence `official: true`). The first real
* `RecipeSourceAdapter` implementation, proving the generic contract
* (recipe-source-adapter.ts) end to end against a live source.
*
* `list()` is search-only TheMealDB has no dedicated "browse everything"
* endpoint on its free tier. An omitted `query` searches for an empty
* string, which TheMealDB happens to answer with a small default sample
* (~25 meals) rather than nothing close enough to this contract's
* "omitted `query` means browse everything" convention
* (`RecipeSourceListParams.query`) to lean on as-is, though it's a fixed
* sample, not the whole catalog. Search isn't paginated either one
* response holds every match, so `nextCursor` is always `null`.
*/
export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
key: SOURCE_KEY,
name: "TheMealDB",
official: true,
iconUrl: "https://www.themealdb.com/images/logo.svg",
// TheMealDB's content (names, ingredients, instructions) is English —
// determines which locale translateRecipe (recipe-translation.ts)
// resolves this source's recipes against when previewing/importing one.
locale: "en",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
);
const meals = data.meals ?? [];
return {
items: meals
.filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal))
.map((meal) => ({
externalId: meal.idMeal,
title: meal.strMeal,
picture: meal.strMealThumb,
url: detailUrl(meal.idMeal),
})),
nextCursor: null,
};
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
try {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
const meal = data.meals?.[0];
if (!meal) {
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
}
return meal;
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
parse(meal: TheMealDbMeal): ParsedRecipe {
if (!meal.strMeal) {
throw new RecipeSourceParseError(SOURCE_KEY, "Meal is missing its name (strMeal)");
}
const ingredients = [];
for (let i = 1; i <= 20; i++) {
const name = meal[`strIngredient${i}`]?.trim();
if (!name) continue;
const measure = meal[`strMeasure${i}`]?.trim();
ingredients.push({
rawText: measure ? `${measure} ${name}` : name,
quantity: null,
unit: null,
name,
});
}
// Free-text instructions, usually one step per line — splitting on
// blank/newlines is the closest this source gets to discrete steps.
// TheMealDB frequently numbers each step on its own line ahead of the
// paragraph that follows (e.g. "…melted.\n\n2\n\nPreheat oven…") rather
// than inline ("1. Preheat oven…") — the blank-line split above turns
// that lone number into its own "line", which would otherwise become a
// bogus step containing nothing but a digit. Drop those rather than
// keep them as steps in their own right (see issue #52).
const steps = (meal.strInstructions ?? "")
.split(/\r?\n+/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => !/^\d+\.?$/.test(line))
.map((description) => ({ description, picture: null }));
if (steps.length === 0) {
throw new RecipeSourceParseError(
SOURCE_KEY,
`Meal "${meal.strMeal}" has no usable instructions`,
);
}
return {
name: meal.strMeal,
description: null,
picture: meal.strMealThumb,
// TheMealDB's free API doesn't state a serving size.
portions: null,
sourceUrl: detailUrl(meal.idMeal),
ingredients,
steps,
};
},
};

View file

@ -0,0 +1,26 @@
/**
* Deterministic slug for a French reference-data label used as the
* stable, storage-safe `key` for `Diet`/`Category`/`Ingredient` rows (see
* `db/reference-seed-data.ts`), decoupled from the display label so the
* label itself can live in `apps/web`'s `locales/fr/translation.json`
* (`catalog.*` namespace) instead of the database. A row's `key` is derived
* from its seed-time French name once and then never changes renaming the
* *label* later (a translation fix, a rewording) never touches the key, the
* FK-referencing rows, or any code that looks a row up by key.
*
* Handles the two French ligatures NFD decomposition doesn't touch (`œ`,
* `æ` aren't accented letters, they're distinct glyphs) explicitly, then
* strips every other accent via NFD decomposition + Unicode "Mark" removal
* (`\p{M}`, every combining diacritic NFD can produce), then collapses
* whatever isn't `[a-z0-9]` into single underscores.
*/
export function slugify(label: string): string {
return label
.toLowerCase()
.replace(/œ/g, "oe")
.replace(/æ/g, "ae")
.normalize("NFD")
.replace(/\p{M}/gu, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}

View file

@ -1,40 +0,0 @@
import { techStepClassifier } from "../src/lib/recipe-matching/tech-step-matcher.js";
import { resetDatabase } from "./reset-db.js";
/**
* Mocha root hook plugin (see `.mocharc.json`'s `require`) runs once
* before every test file's own suites, regardless of load order.
*
* Warms up `techStepClassifier` here resolving the `TechStep.key -> id`
* lookup from the DB (see `TechStepClassifierService._loadTechStepIds`)
* instead of leaving it to happen lazily on whichever test file Mocha
* happens to load first, same as `server.ts` does before the real server
* ever accepts traffic. Fast by itself (one DB query, one HTTP call to
* `services/tech-step-intent-service`): that service now trains itself
* entirely at its own process startup (see its own README), so unlike
* before this migration, nothing here waits on a slow training pass CI's
* own "wait for `/health`" step (`.github/workflows/ci.yml`) is what
* ensures that service is already fully trained before `pnpm --filter api
* test` even starts.
*
* `resetDatabase()` runs first, deliberately: id resolution needs
* `TechStep` rows, and a freshly-migrated (never-seeded) test database has
* none yet. Every per-test `beforeEach` in this suite already calls
* `resetDatabase()` again before its own test, which is a no-op
* duplication of effort but not a correctness problem: `TRUNCATE ...
* RESTART IDENTITY` plus deterministic re-seeding (`seedReferenceData`)
* assigns the exact same ids every time, so the `uid -> id` map memoized
* here from this first reset stays valid for every reset after it.
*/
export const mochaHooks = {
// biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts.
async beforeAll(this: any): Promise<void> {
// A little more generous than Mocha's normal 10s per-test default
// (`.mocharc.json`) purely for a slower/contended CI runner's first
// network round-trip to `services/tech-step-intent-service` — not
// because anything here waits on training anymore.
this.timeout(30000);
await resetDatabase();
await techStepClassifier.warmUp();
},
};

View file

@ -1,55 +1,21 @@
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js";
/**
* Refuses to run outside a database that's obviously a test one belt and
* braces alongside `config/env.ts` loading `.env.test` (not `.env`) under
* `NODE_ENV=test`: this already wiped a real local dev database once, when
* both env files shared one `DATABASE_URL`. `resetDatabase()` below
* TRUNCATEs almost the entire schema before every single test, so a
* misconfigured/missing `.env.test` must fail loudly here rather than
* silently truncate whatever `DATABASE_URL` happens to be set.
*/
function assertRunningAgainstTestDatabase() {
if (env.NODE_ENV !== "test") {
throw new Error(
`resetDatabase() TRUNCATEs almost the whole schema — refusing to run outside NODE_ENV=test (currently "${env.NODE_ENV}").`,
);
}
// "test" covers a local `.env.test` (`batchcooking_test`); "ci" covers
// CI's own service database (`batchcooking_ci`, set directly via the
// workflow's `env:`, not a `.env.test` file — see ci.yml). Neither
// matches the real dev database's name (`batchcooking`), which is the
// one case this must actually catch.
if (!env.DATABASE_URL?.includes("test") && !env.DATABASE_URL?.includes("ci")) {
throw new Error(
`resetDatabase() refuses to run against a DATABASE_URL that doesn't look like a test database (got "${env.DATABASE_URL}", expected it to contain "test" or "ci") — see .env.test.example.`,
);
}
}
// Single TRUNCATE ... CASCADE covers FK ordering and resets identity
// sequences — used between tests/scenarios to start from a clean slate.
// Re-seeds the Diet/Category/Allergy/Unit reference data right after
// truncating it, so every test starts from the same realistic reference
// data the real app seeds (`prisma/seed.ts`) rather than empty tables —
// tests exercising dietId/allergyIds/unitId need real rows to reference.
// `syncRecipeSources` runs last, for the same reason: `sources` should
// reflect whatever adapters this test run happens to have registered
// (usually none — see recipe-source-registry.ts).
// Re-seeds the Diet/Category/Allergy reference data right after truncating
// it, so every test starts from the same realistic reference data the real
// app seeds (`prisma/seed.ts`) rather than empty tables — tests exercising
// dietId/allergyIds need real rows to reference.
export async function resetDatabase() {
assertRunningAgainstTestDatabase();
await prisma.$executeRawUnsafe(`
TRUNCATE TABLE
"user_profile_allergy", "user_preference", "allergy", "category",
"planning_item", "planning",
"recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe", "ingredients", "sources", "unit",
"recipe_ingredient", "step", "tech_step_mapping", "tech_step",
"recipe", "ingredients", "sources",
"user_profiles", "diet", "house"
RESTART IDENTITY CASCADE;
`);
await seedReferenceData(prisma);
await syncRecipeSources(prisma);
}

View file

@ -5,12 +5,6 @@ import type { Express } from "express";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import {
clearRecipeSources,
registerRecipeSource,
} from "../src/lib/recipe-sources/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
function buildSignupPayload(): SignupInput {
@ -24,26 +18,6 @@ function buildSignupPayload(): SignupInput {
};
}
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return {
key,
name,
official: false,
iconUrl: null,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
/** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */
async function signupAgent(app: Express) {
const agent = request.agent(app);
@ -362,80 +336,4 @@ describe("Household", () => {
expect(memberProfile.houseId).to.equal(null);
});
});
describe("GET /house/current/sources + PATCH /house/current/sources", () => {
beforeEach(() => {
clearRecipeSources();
});
afterEach(() => {
clearRecipeSources();
});
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const getRes = await request(app).get("/house/current/sources");
const patchRes = await request(app).patch("/house/current/sources").send({ sourceIds: [] });
expect(getRes.status).to.equal(401);
expect(patchRes.status).to.equal(401);
});
it("rejects reading/writing with 404 HOUSE_NOT_FOUND when the profile has no household yet", async () => {
const { agent } = await signupAgent(app);
const getRes = await agent.get("/house/current/sources");
const patchRes = await agent.patch("/house/current/sources").send({ sourceIds: [] });
expect(getRes.status).to.equal(404);
expect(getRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
expect(patchRes.status).to.equal(404);
expect(patchRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("starts with nothing enabled, opt-in — not just an empty array by coincidence", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.get("/house/current/sources");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("replaces (not merges) the enabled-source set, and it's readable back", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
registerRecipeSource(buildFakeAdapter("otherSource", "Other Source"));
await syncRecipeSources(prisma);
const fakeSource = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } });
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const firstPatch = await agent
.patch("/house/current/sources")
.send({ sourceIds: [fakeSource.id, otherSource.id] });
expect(firstPatch.status).to.equal(200);
expect(firstPatch.body.sort()).to.deep.equal([fakeSource.id, otherSource.id].sort());
const secondPatch = await agent
.patch("/house/current/sources")
.send({ sourceIds: [fakeSource.id] });
expect(secondPatch.status).to.equal(200);
expect(secondPatch.body).to.deep.equal([fakeSource.id]);
const res = await agent.get("/house/current/sources");
expect(res.body).to.deep.equal([fakeSource.id]);
});
it("rejects an unknown sourceId with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.patch("/house/current/sources").send({ sourceIds: [999999] });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
});
});

View file

@ -1,306 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../../src/app.js";
import { env } from "../../src/config/env.js";
import { prisma } from "../../src/db/prisma.js";
import { resetDatabase } from "../../test-support/reset-db.js";
const SECRET_HEADER = "X-Internal-Worker-Secret";
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
async function techStepId(key: string): Promise<number> {
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
return techStep.id;
}
/** A minimal author + recipe + step fixture — these routes have no notion of a session/viewer, so nothing here needs to go through `/auth/signup` the way `recipe.test.ts`'s fixtures do. */
async function createRecipeWithStep(
description = "Faire mijoter la sauce.",
): Promise<{ stepId: number; recipeId: number }> {
const author = await prisma.userProfile.create({
data: {
firstName: "Test",
lastName: "Author",
email: `${crypto.randomUUID()}@example.test`,
passwordHash: "not-a-real-hash",
},
});
const recipe = await prisma.recipe.create({
data: {
name: "Recette",
authorId: author.id,
portions: 4,
steps: { create: [{ description, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
return { stepId: step.id, recipeId: recipe.id };
}
describe("Internal tech-step worker routes", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("requireInternalWorker", () => {
it("rejects a request with no secret header with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/internal/tech-steps/audit-batch");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a request with the wrong secret with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app)
.get("/internal/tech-steps/audit-batch")
.set(SECRET_HEADER, "definitely-not-the-right-secret");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects even the correct secret with 401 NOT_AUTHENTICATED on a plain user-facing route (no bypass of requireAuth)", async () => {
const res = await request(app)
.get("/recipes")
.query({ tab: "publique" })
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET ?? "irrelevant-unset-in-this-env");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
// Every test below needs a real configured secret to exercise the success
// path — skipped (not failed) in an environment that hasn't set one, same
// "optional, but the surface fails closed without it" posture
// `INTERNAL_WORKER_SECRET` itself has (see config/env.ts). Both this
// repo's `.env.test.example` and `.github/workflows/ci.yml` set one, so
// this only actually skips in an environment that deliberately diverges
// from both.
describe("with a configured secret", () => {
before(function skipWithoutConfiguredSecret() {
if (env.INTERNAL_WORKER_SECRET === undefined) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape a plain JS mocha callback would have.
(this as any).skip();
}
});
function withSecret(req: request.Test): request.Test {
return req.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string);
}
describe("GET /internal/tech-steps/audit-batch", () => {
// A true low-confidence positive case can't be asserted here without
// a live trained classifier to verify the exact sentence against
// first — same limitation `tech-step-eval-dataset.ts` documents for
// the same reason (no local Postgres was reachable in the session
// that introduced this file). This test instead covers the
// deterministic negative: a step the classifier confidently resolves
// (proven by `tech-step-matcher.test.ts`'s own identical-sentence
// case) must produce zero audit entries — nothing here should ever
// flag a confident match as worth a second opinion.
it("finds nothing to audit in a step the classifier confidently resolves", async () => {
await createRecipeWithStep("Faire mijoter à feu doux");
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("finds nothing to audit in a step naming no technique at all", async () => {
await createRecipeWithStep("Ranger les couverts dans le tiroir");
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("rejects a non-positive limit with 400 VALIDATION_ERROR", async () => {
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ limit: 0 }),
);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("GET /internal/tech-steps/pending-corrections", () => {
it("returns unconsumed corrections, oldest first, excluding already-consumed ones", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
const older = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const consumed = await prisma.stepTechStepCorrection.create({
data: {
stepId,
correctorId: author,
start: 0,
end: 5,
correctedTechStepId: simmerId,
consumedAt: new Date(),
},
});
const newer = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const res = await withSecret(request(app).get("/internal/tech-steps/pending-corrections"));
expect(res.status).to.equal(200);
const ids = (res.body as Array<{ id: number }>).map((entry) => entry.id);
expect(ids).to.deep.equal([older.id, newer.id]);
expect(ids).to.not.include(consumed.id);
});
it("respects ?limit=", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
await prisma.stepTechStepCorrection.createMany({
data: [
{ stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId },
{ stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
],
});
const res = await withSecret(
request(app).get("/internal/tech-steps/pending-corrections").query({ limit: 1 }),
);
expect(res.status).to.equal(200);
expect(res.body).to.have.length(1);
});
});
describe("POST /internal/tech-steps/training-suggestions", () => {
it("creates a suggestion and marks its source correction consumed", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
const correction = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "simmer",
locale: "fr",
suggestedSynonyms: ["frémissonner"],
suggestedUtterances: ["laisser frémissonner à feu très doux"],
sourceType: "correction",
sourceCorrectionId: correction.id,
},
],
}),
);
expect(res.status).to.equal(201);
expect(res.body).to.deep.equal({ created: 1 });
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
where: { techStepId: simmerId },
});
expect(suggestions).to.have.length(1);
expect(suggestions[0]?.sourceCorrectionId).to.equal(correction.id);
expect(suggestions[0]?.status).to.equal("pending");
const updatedCorrection = await prisma.stepTechStepCorrection.findUniqueOrThrow({
where: { id: correction.id },
});
expect(updatedCorrection.consumedAt).to.not.equal(null);
});
it("accepts an llm_audit suggestion with no sourceCorrectionId", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "boil",
locale: "fr",
suggestedSynonyms: ["bouillonner"],
suggestedUtterances: [],
sourceType: "llm_audit",
},
],
}),
);
expect(res.status).to.equal(201);
expect(res.body).to.deep.equal({ created: 1 });
});
it("rejects sourceType 'correction' with no sourceCorrectionId with 400 VALIDATION_ERROR", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "boil",
locale: "fr",
suggestedSynonyms: ["bouillonner"],
suggestedUtterances: [],
sourceType: "correction",
},
],
}),
);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown techStepKey with 404 TECH_STEP_NOT_FOUND", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "not-a-real-tech-step",
locale: "fr",
suggestedSynonyms: [],
suggestedUtterances: [],
sourceType: "llm_audit",
},
],
}),
);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
});
});
});
});

View file

@ -1,105 +0,0 @@
import { expect } from "chai";
import { LoggerService, logger, minLevelFor } from "../src/lib/logger.service.js";
/**
* Stubs one `console` method for one test, capturing every call instead of
* actually writing to stdout/stderr same "stub the one thing that
* touches the outside world" approach `the-meal-db.test.ts` uses for
* `fetch`. Restored by the caller (`afterEach` below) regardless of which
* test used it.
*/
function stubConsoleMethod(method: "debug" | "info" | "warn" | "error") {
const calls: unknown[][] = [];
// biome-ignore lint/suspicious/noConsole: this *is* the console-stubbing helper — it has to read the real method to be able to restore it. Never calls it for real.
const original = console[method];
console[method] = (...args: unknown[]) => {
calls.push(args);
};
return {
calls,
restore: () => (console[method] = original),
};
}
describe("logger.service", () => {
describe("minLevelFor", () => {
it("only lets warn/error through in test — Mocha's own output shouldn't get any noisier", () => {
expect(minLevelFor("test")).to.equal("warn");
});
it("only lets info/warn/error through in production — debug is too verbose to ship", () => {
expect(minLevelFor("production")).to.equal("info");
});
it("lets everything through, including debug, in development", () => {
expect(minLevelFor("development")).to.equal("debug");
});
});
describe("logger", () => {
let stub: ReturnType<typeof stubConsoleMethod>;
afterEach(() => {
stub?.restore();
});
it("emits a warn line as a single JSON object via console.warn, with a timestamp/level/message and any extra meta merged in", () => {
stub = stubConsoleMethod("warn");
logger.warn("Something routine failed", { status: 404, code: 4049 });
expect(stub.calls).to.have.length(1);
const [line] = stub.calls[0];
const parsed = JSON.parse(line as string);
expect(parsed.level).to.equal("warn");
expect(parsed.message).to.equal("Something routine failed");
expect(parsed.status).to.equal(404);
expect(parsed.code).to.equal(4049);
expect(new Date(parsed.timestamp).toString()).to.not.equal("Invalid Date");
});
it("emits an error line via console.error", () => {
stub = stubConsoleMethod("error");
logger.error("Something broke");
expect(stub.calls).to.have.length(1);
const parsed = JSON.parse(stub.calls[0][0] as string);
expect(parsed.level).to.equal("error");
});
it('drops debug/info under the test suite\'s own NODE_ENV=test (minLevelFor("test") === "warn")', () => {
const debugStub = stubConsoleMethod("debug");
const infoStub = stubConsoleMethod("info");
logger.debug("Should not appear");
logger.info("Should not appear either");
expect(debugStub.calls).to.have.length(0);
expect(infoStub.calls).to.have.length(0);
debugStub.restore();
infoStub.restore();
});
it("never lets meta override the line's own timestamp/level/message keys", () => {
stub = stubConsoleMethod("warn");
logger.warn("Real message", { message: "spoofed", level: "spoofed", timestamp: "spoofed" });
const parsed = JSON.parse(stub.calls[0][0] as string);
expect(parsed.message).to.equal("Real message");
expect(parsed.level).to.equal("warn");
expect(new Date(parsed.timestamp).toString()).to.not.equal("Invalid Date");
});
it('a separate instance constructed with nodeEnv="development" lets debug through, independently of the shared logger\'s own NODE_ENV=test threshold', () => {
const debugStub = stubConsoleMethod("debug");
const devLogger = new LoggerService("development");
devLogger.debug("Visible in dev");
expect(debugStub.calls).to.have.length(1);
debugStub.restore();
});
});
});

View file

@ -98,7 +98,7 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({
data: { name: "Ratatouille", authorId: houseRes.body.adminId, portions: 4 },
data: { name: "Ratatouille", authorId: houseRes.body.adminId },
});
const planning = await prisma.planning.create({
data: {
@ -108,13 +108,7 @@ describe("Planning", () => {
},
});
await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
});
const res = await agent.get("/planning").query({ date: today() });
@ -154,7 +148,7 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({
data: { name: "Curry de lentilles", authorId: houseRes.body.adminId, portions: 4 },
data: { name: "Curry de lentilles", authorId: houseRes.body.adminId },
});
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({
@ -165,13 +159,7 @@ describe("Planning", () => {
},
});
await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
},
data: { planningId: planning.id, weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id },
});
const res = await agent.get("/planning").query({ date: isoDate(nextWeek) });

View file

@ -3,6 +3,7 @@ import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
@ -40,7 +41,7 @@ describe("Profile", () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({
where: { key: "vegetarian" },
where: { key: getEnglishKey("Végétarien") },
});
const res = await agent.patch("/profile/diet").send({ dietId: diet.id });
@ -52,7 +53,7 @@ describe("Profile", () => {
it("clears the regime when dietId is null", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({ where: { key: "vegan" } });
const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey("Végan") } });
await agent.patch("/profile/diet").send({ dietId: diet.id });
const res = await agent.patch("/profile/diet").send({ dietId: null });
@ -85,8 +86,8 @@ describe("Profile", () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.key === "peanuts");
const gluten = allergies.find((a) => a.category.key === "gluten");
const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides"));
const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten"));
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
const initial = await agent.get("/profile/allergies");
@ -106,8 +107,8 @@ describe("Profile", () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.key === "peanuts");
const gluten = allergies.find((a) => a.category.key === "gluten");
const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides"));
const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten"));
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] });
@ -143,10 +144,10 @@ describe("Profile", () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({
where: { key: "tomato" },
where: { key: getEnglishKey("Tomate") },
});
const oignon = await prisma.ingredient.findFirstOrThrow({
where: { key: "onion" },
where: { key: getEnglishKey("Oignon") },
});
const initial = await agent.get("/profile/disliked-ingredients");
@ -166,10 +167,10 @@ describe("Profile", () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({
where: { key: "tomato" },
where: { key: getEnglishKey("Tomate") },
});
const oignon = await prisma.ingredient.findFirstOrThrow({
where: { key: "onion" },
where: { key: getEnglishKey("Oignon") },
});
await agent

View file

@ -1,459 +0,0 @@
import { INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_FR } from "@batch-cooking/shared";
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
extractQuantity,
findIngredientMentions,
type IngredientMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
type UnitMatchEntry,
} from "../../src/lib/recipe-matching/ingredient-matcher.js";
import { resetDatabase } from "../../test-support/reset-db.js";
describe("ingredient-matcher", () => {
describe("matchIngredientName", () => {
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
const allPurposeFlour: IngredientMatchEntry = { ingredientId: 5, label: "All-purpose flour" };
const catalog = [tomato, chicken, chickenBreast, onion, allPurposeFlour];
it("matches an exact single-word label", () => {
expect(matchIngredientName("tomato", catalog)).to.equal(tomato.ingredientId);
});
it("is case- and accent-insensitive", () => {
expect(matchIngredientName("TOMATO", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("Tömato", catalog)).to.equal(tomato.ingredientId);
});
it("tolerates a regular plural", () => {
expect(matchIngredientName("tomatoes", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("onions", catalog)).to.equal(onion.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 large diced yellow onions", catalog)).to.equal(
onion.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("boneless skinless chicken breasts", catalog)).to.equal(
chickenBreast.ingredientId,
);
});
it("still matches the shorter label when the more specific one isn't mentioned", () => {
expect(matchIngredientName("diced chicken thighs", catalog)).to.equal(chicken.ingredientId);
});
it("matches a hyphenated multi-word label", () => {
expect(matchIngredientName("2 cups all-purpose flour", catalog)).to.equal(
allPurposeFlour.ingredientId,
);
});
it("doesn't false-positive a short label inside an unrelated longer word", () => {
// "egg" must not match inside "eggplant" — whole-token comparison, not substring.
const eggplant: IngredientMatchEntry = { ingredientId: 6, label: "Eggplant" };
const egg: IngredientMatchEntry = { ingredientId: 7, label: "Egg" };
expect(matchIngredientName("eggplant", [egg, eggplant])).to.equal(eggplant.ingredientId);
});
it("returns null when nothing matches", () => {
expect(matchIngredientName("mango", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchIngredientName("tomato", [])).to.equal(null);
});
it("returns null for an empty name", () => {
expect(matchIngredientName("", catalog)).to.equal(null);
});
it("matches an alternate wording of the same ingredient via a second catalog entry sharing its ingredientId (issue #54)", () => {
const vanillaBean: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla bean" };
const vanillaBeanSynonym: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla pod" };
const synonymCatalog = [vanillaBean, vanillaBeanSynonym];
expect(matchIngredientName("1 vanilla pod", synonymCatalog)).to.equal(8);
expect(matchIngredientName("1 vanilla bean", synonymCatalog)).to.equal(8);
});
it("breaks a same-specificity tie by the lowest ingredientId", () => {
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
});
describe("locale: fr", () => {
const carotte: IngredientMatchEntry = { ingredientId: 30, label: "Carotte" };
const poulet: IngredientMatchEntry = { ingredientId: 31, label: "Poulet" };
const blancDePoulet: IngredientMatchEntry = { ingredientId: 32, label: "Blanc de poulet" };
const frCatalog = [carotte, poulet, blancDePoulet];
it("tolerates a regular French plural (a bare 's', unlike English's several suffix patterns)", () => {
// Regression case: French plurals like "carottes" end in "es", which
// the English stemmer's own "es" rule would wrongly strip down to
// "carott" (losing the "e" that's part of the singular "carotte")
// — see stemWordFr's own doc comment. Locale "fr" must use the
// French stemmer instead, or this never matches.
expect(matchIngredientName("carottes", frCatalog, "fr")).to.equal(carotte.ingredientId);
});
it("is accent-insensitive the same way the English path is", () => {
expect(matchIngredientName("CAROTTES", frCatalog, "fr")).to.equal(carotte.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 carottes râpées", frCatalog, "fr")).to.equal(
carotte.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("blancs de poulet fermier", frCatalog, "fr")).to.equal(
blancDePoulet.ingredientId,
);
});
it("defaults to the English stemmer when no locale is passed — 'fr' text needs to opt in explicitly", () => {
// Without locale: "fr", "carottes" stems via the English rules
// (endsWith("es") -> strip 2 chars) into "carott", which doesn't
// equal the catalog's own (also English-stemmed) "carotte" — no
// match. This is the exact bug locale-aware stemming fixes; this
// test pins down that the *default* stays exactly as it was for
// every pre-existing English-only caller.
expect(matchIngredientName("carottes", frCatalog)).to.equal(null);
});
});
});
describe("matchUnit", () => {
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const tablespoon: UnitMatchEntry = {
unitId: 2,
synonyms: ["tbsp", "tbs", "tablespoon", "tablespoons"],
};
const cup: UnitMatchEntry = { unitId: 3, synonyms: ["cup", "cups"] };
const catalog = [gram, tablespoon, cup];
it("matches a full word synonym", () => {
expect(matchUnit("tablespoon", catalog)).to.equal(tablespoon.unitId);
});
it("matches an abbreviation synonym", () => {
expect(matchUnit("tbsp", catalog)).to.equal(tablespoon.unitId);
});
it("matches a plural synonym via the same stemming as ingredients", () => {
expect(matchUnit("cups", catalog)).to.equal(cup.unitId);
});
it("is case-insensitive", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
});
it("ignores trailing text after the unit word", () => {
expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId);
});
it("also finds the unit word when it isn't first — unlike before French support existed, this is no longer only a first-word check (see the function's own doc comment)", () => {
expect(matchUnit("a heaped tablespoon of sugar", catalog)).to.equal(tablespoon.unitId);
});
it("doesn't match a short abbreviation inside an unrelated word", () => {
// "g" alone must not match "grated" — whole-token comparison.
expect(matchUnit("grated", catalog)).to.equal(null);
});
it("returns null when nothing matches", () => {
expect(matchUnit("pound", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchUnit("cup", [])).to.equal(null);
});
it("returns null for an empty string", () => {
expect(matchUnit("", catalog)).to.equal(null);
});
describe("locale: fr", () => {
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
const cuillereASoupe: UnitMatchEntry = {
unitId: 41,
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
};
const frCatalog = [gramme, cuillereASoupe];
it("matches a genuinely multi-word synonym — the bug this locale support fixes: the old single-first-token check could never equal a whole multi-word phrase", () => {
expect(matchUnit("cuillères à soupe de farine", frCatalog, "fr")).to.equal(
cuillereASoupe.unitId,
);
});
it("matches a single-word abbreviation the same way English units do", () => {
expect(matchUnit("càs de farine", frCatalog, "fr")).to.equal(cuillereASoupe.unitId);
});
it("is accent-insensitive", () => {
expect(matchUnit("2 CUILLÈRES À SOUPE de farine", frCatalog, "fr")).to.equal(
cuillereASoupe.unitId,
);
});
it("doesn't match a multi-word phrase against unrelated text mentioning the same first word alone", () => {
expect(matchUnit("cuillère de bois", frCatalog, "fr")).to.equal(null);
});
});
});
describe("extractQuantity", () => {
it("extracts a plain integer", () => {
expect(extractQuantity("2 onions")).to.deep.equal({ quantity: 2, remainder: "onions" });
});
it("extracts a decimal using a dot", () => {
expect(extractQuantity("1.5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a decimal using a comma", () => {
expect(extractQuantity("1,5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a simple fraction", () => {
expect(extractQuantity("1/2 cup sugar")).to.deep.equal({
quantity: 0.5,
remainder: "cup sugar",
});
});
it("extracts a mixed number", () => {
expect(extractQuantity("1 1/2 cups sugar")).to.deep.equal({
quantity: 1.5,
remainder: "cups sugar",
});
});
it("returns null quantity and the trimmed original text when there's no leading number", () => {
expect(extractQuantity("salt to taste")).to.deep.equal({
quantity: null,
remainder: "salt to taste",
});
});
it("trims surrounding whitespace", () => {
expect(extractQuantity(" 2 eggs ")).to.deep.equal({ quantity: 2, remainder: "eggs" });
});
it("only takes the first number of a hyphenated range", () => {
expect(extractQuantity("2-3 carrots")).to.deep.equal({
quantity: 2,
remainder: "-3 carrots",
});
});
});
describe("findIngredientMentions", () => {
const butter: IngredientMatchEntry = { ingredientId: 1, label: "Butter" };
const flour: IngredientMatchEntry = { ingredientId: 2, label: "Flour" };
const egg: IngredientMatchEntry = { ingredientId: 3, label: "Egg" };
const catalog = [butter, flour, egg];
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const unitCatalog = [gram];
it("finds a single mention with no quantity or unit", () => {
const text = "melt the butter";
const mentions = findIngredientMentions(text, catalog, unitCatalog);
expect(mentions).to.have.length(1);
const [mention] = mentions;
expect(mention?.ingredientId).to.equal(butter.ingredientId);
expect(text.slice(mention?.start, mention?.end)).to.equal("butter");
expect(mention?.quantity).to.equal(null);
expect(mention?.unitId).to.equal(null);
});
it('resolves a quantity and unit glued directly to the ingredient ("200g butter")', () => {
const text = "add 200g butter";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(butter.ingredientId);
expect(mention?.quantity).to.equal(200);
expect(mention?.unitId).to.equal(gram.unitId);
});
it("finds several mentions in reading order, non-overlapping", () => {
const text = "melt the butter then add the flour and an egg";
const mentions = findIngredientMentions(text, catalog, unitCatalog);
expect(mentions.map((mention) => mention.ingredientId)).to.deep.equal([
butter.ingredientId,
flour.ingredientId,
egg.ingredientId,
]);
});
it("is case- and accent-insensitive", () => {
const text = "MELT THE BUTTER";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(butter.ingredientId);
});
it("ignores an unrelated number earlier in the text (e.g. an oven temperature)", () => {
const text = "preheat to 180 degrees then add the egg";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(egg.ingredientId);
expect(mention?.quantity).to.equal(null);
});
it("returns an empty array when nothing in the catalog is mentioned", () => {
expect(findIngredientMentions("stir well", catalog, unitCatalog)).to.deep.equal([]);
});
it("returns an empty array for empty text", () => {
expect(findIngredientMentions("", catalog, unitCatalog)).to.deep.equal([]);
});
describe("locale: fr", () => {
const beurre: IngredientMatchEntry = { ingredientId: 10, label: "Beurre" };
const farine: IngredientMatchEntry = { ingredientId: 11, label: "Farine" };
const frCatalog = [beurre, farine];
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
const cuillereASoupe: UnitMatchEntry = {
unitId: 41,
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
};
const frUnitCatalog = [gramme, cuillereASoupe];
it("resolves a quantity and unit before the ingredient, connected by 'de'", () => {
const text = "faire fondre 50g de beurre";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
expect(mention?.quantity).to.equal(50);
expect(mention?.unitId).to.equal(gramme.unitId);
expect(text.slice(mention?.start, mention?.end)).to.equal("beurre");
});
it('resolves a multi-word unit connected by "d\'"', () => {
const text = "ajouter 2 cuillères à soupe de farine";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(farine.ingredientId);
expect(mention?.quantity).to.equal(2);
expect(mention?.unitId).to.equal(cuillereASoupe.unitId);
});
it("is accent-insensitive", () => {
const text = "FAIRE FONDRE LE BEURRE";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
});
});
});
describe("loadIngredientCatalog / loadUnitCatalog", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("loads one entry per Ingredient that has an English label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_EN), keyed by real ingredientId", async () => {
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
where: { key: "vanillaBean" },
});
const ingredientCount = await prisma.ingredient.count();
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_EN).reduce(
(sum, synonyms) => sum + synonyms.length,
0,
);
const catalog = await loadIngredientCatalog();
// Every seeded ingredient has an authored English label (verified at
// generation time — see packages/shared/src/data/catalog-labels-en.ts),
// so nothing should be silently skipped — plus one extra entry per
// synonym (issue #54), sharing the same ingredientId as the primary
// label's entry.
expect(catalog).to.have.length(ingredientCount + synonymCount);
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
expect(tomatoEntry?.label).to.equal("Tomato");
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
"Vanilla bean",
"Vanilla pod",
]);
});
it("loads one entry per Unit that has English synonyms, keyed by real unitId", async () => {
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const unitCount = await prisma.unit.count();
const catalog = await loadUnitCatalog();
expect(catalog).to.have.length(unitCount);
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
});
it("loads one entry per Ingredient that has a French label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_FR), keyed by real ingredientId", async () => {
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
where: { key: "vanillaBean" },
});
const ingredientCount = await prisma.ingredient.count();
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_FR).reduce(
(sum, synonyms) => sum + synonyms.length,
0,
);
const catalog = await loadIngredientCatalog("fr");
// Every seeded ingredient has an authored French label too (copied
// from apps/web's fr locale — see catalog-labels-fr.ts's own doc
// comment), so this mirrors the English test above 1:1.
expect(catalog).to.have.length(ingredientCount + synonymCount);
const carrotEntry = catalog.find((entry) => entry.ingredientId === carrot.id);
expect(carrotEntry?.label).to.equal("Carotte");
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
"Vanille (gousse)",
"Gousse de vanille",
]);
});
it("loads one entry per Unit that has French synonyms, keyed by real unitId", async () => {
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const unitCount = await prisma.unit.count();
const catalog = await loadUnitCatalog("fr");
expect(catalog).to.have.length(unitCount);
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["tasse", "tasses"]);
});
it("returns an empty catalog for a locale with no label table at all — the DB is still queried, there's just nothing in either table to match a row against", async () => {
const ingredientCatalog = await loadIngredientCatalog("de");
const unitCatalog = await loadUnitCatalog("de");
expect(ingredientCatalog).to.deep.equal([]);
expect(unitCatalog).to.deep.equal([]);
});
});
});

View file

@ -1,519 +0,0 @@
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import type {
IngredientMatchEntry,
UnitMatchEntry,
} from "../../src/lib/recipe-matching/ingredient-matcher.js";
import {
mergeDuplicateIngredients,
type TranslatedRecipeIngredient,
translateRecipe,
translateRecipeIngredients,
translateRecipeSteps,
type UnitConversionEntry,
} from "../../src/lib/recipe-matching/recipe-translation.js";
import type {
ParsedRecipe,
ParsedRecipeIngredient,
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import { resetDatabase } from "../../test-support/reset-db.js";
/** A minimal fixture `ParsedRecipe` — only `steps` (built from `descriptions`) matters for most tests here, the rest is filler to prove it survives translation untouched. */
function buildParsedRecipe(descriptions: string[]): ParsedRecipe {
return {
name: "Test Recipe",
description: "A recipe for testing",
picture: "https://example.test/recipe.jpg",
portions: 4,
sourceUrl: "https://example.test/recipes/1",
ingredients: [{ rawText: "1 egg", quantity: null, unit: null, name: "egg" }],
steps: descriptions.map((description, i) => ({
description,
picture: i === 0 ? "https://example.test/step1.jpg" : null,
})),
};
}
describe("recipe-translation", () => {
// `translateRecipeSteps` now goes through `techStepClassifier` (a
// trained model, not a pure regex test against a caller-supplied
// mapping list — see `tech-step-matcher.ts`), so these tests exercise
// the real training corpus (`services/tech-step-intent-service`'s
// `training_data.py`) against a real `TechStep` catalog rather than
// synthetic fixtures — same posture
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
// takes, for the same reason.
describe("translateRecipeSteps", () => {
let simmerId: number;
let preheatId: number;
let meltId: number;
beforeEach(async () => {
await resetDatabase();
simmerId = (await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } })).id;
preheatId = (await prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } })).id;
meltId = (await prisma.techStep.findFirstOrThrow({ where: { key: "melt" } })).id;
});
after(async () => {
await prisma.$disconnect();
});
it("declares each step's technique sequence, preserving order", async () => {
const recipe = buildParsedRecipe([
"Préchauffer la poêle, puis faire fondre le beurre",
"Servir immédiatement",
"Faire mijoter à feu doux",
]);
const translated = await translateRecipeSteps(recipe, "fr");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
[preheatId, meltId],
[],
[simmerId],
]);
});
it("leaves description/picture untouched on each step", async () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir immédiatement"]);
const translated = await translateRecipeSteps(recipe, "fr");
expect(translated.steps[0]).to.deep.equal({
description: "Faire mijoter à feu doux",
picture: "https://example.test/step1.jpg",
techStepIds: [simmerId],
});
expect(translated.steps[1]).to.deep.equal({
description: "Servir immédiatement",
picture: null,
techStepIds: [],
});
});
it("passes every other field through unchanged", async () => {
const recipe = buildParsedRecipe(["Servir immédiatement"]);
const translated = await translateRecipeSteps(recipe, "fr");
expect(translated.name).to.equal(recipe.name);
expect(translated.description).to.equal(recipe.description);
expect(translated.picture).to.equal(recipe.picture);
expect(translated.portions).to.equal(recipe.portions);
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
});
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", async () => {
const recipe = buildParsedRecipe(["Servir immédiatement"]);
const translated = await translateRecipeSteps(recipe, "fr");
expect(translated.ingredients).to.deep.equal([
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
]);
});
it("gives every step an empty sequence when nothing in it means a known technique", async () => {
const recipe = buildParsedRecipe([
"Servir immédiatement",
"Ranger les couverts dans le tiroir",
]);
const translated = await translateRecipeSteps(recipe, "fr");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
});
it("handles a recipe with no steps without error", async () => {
const recipe = buildParsedRecipe([]);
const translated = await translateRecipeSteps(recipe, "fr");
expect(translated.steps).to.deep.equal([]);
});
});
describe("translateRecipeIngredients", () => {
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
const gram: UnitMatchEntry = { unitId: 10, synonyms: ["g", "gram", "grams"] };
const cup: UnitMatchEntry = { unitId: 11, synonyms: ["cup", "cups"] };
function buildIngredient(
overrides: Partial<ParsedRecipeIngredient> & { rawText: string; name: string },
): ParsedRecipeIngredient {
return { quantity: null, unit: null, ...overrides };
}
it("resolves ingredientId from free-text name, tolerating extra descriptive words and plurals", () => {
const translated = translateRecipeIngredients(
[
buildIngredient({
rawText: "2 large diced yellow onions",
name: "large diced yellow onions",
}),
],
[tomato, chicken, chickenBreast, onion],
[],
);
expect(translated[0].ingredientId).to.equal(onion.ingredientId);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
const translated = translateRecipeIngredients(
[
buildIngredient({
rawText: "2 boneless chicken breasts",
name: "boneless chicken breasts",
}),
],
[tomato, chicken, chickenBreast, onion],
[],
);
expect(translated[0].ingredientId).to.equal(chickenBreast.ingredientId);
});
it("returns a null ingredientId when nothing in the catalog matches", () => {
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "1 mango", name: "mango" })],
[tomato, chicken, chickenBreast, onion],
[],
);
expect(translated[0].ingredientId).to.equal(null);
});
it("extracts a mixed-number quantity and unit from rawText when the source left them null", () => {
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "1 1/2 cups chicken breast", name: "chicken breast" })],
[chickenBreast],
[cup],
);
expect(translated[0].quantity).to.equal(1.5);
expect(translated[0].unitId).to.equal(cup.unitId);
});
it("trusts the source's own quantity/unit over re-deriving them from rawText", () => {
const translated = translateRecipeIngredients(
[
buildIngredient({
rawText: "some raw text that happens to mention cups",
name: "tomato",
quantity: 3,
unit: "g",
}),
],
[tomato],
[gram, cup],
);
expect(translated[0].quantity).to.equal(3);
expect(translated[0].unitId).to.equal(gram.unitId);
});
it("falls back to the generic 'piece' unit when a quantity was found but no unit word was (issue #53)", () => {
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "4 Egg Yolks", name: "Egg Yolks" })],
[],
[gram, cup, piece],
);
expect(translated[0].quantity).to.equal(4);
expect(translated[0].unitId).to.equal(piece.unitId);
});
it("does not fall back to 'piece' when there's no quantity at all to count", () => {
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
[],
[piece],
);
expect(translated[0].quantity).to.equal(null);
expect(translated[0].unitId).to.equal(null);
});
it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => {
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
[],
[],
);
expect(translated[0].quantity).to.equal(null);
expect(translated[0].unitId).to.equal(null);
});
it("leaves rawText/name/description untouched", () => {
const ingredient = buildIngredient({ rawText: "1 cup onions", name: "onions" });
const translated = translateRecipeIngredients([ingredient], [onion], [cup]);
expect(translated[0].rawText).to.equal(ingredient.rawText);
expect(translated[0].name).to.equal(ingredient.name);
});
});
describe("mergeDuplicateIngredients", () => {
const gram: UnitConversionEntry = { id: 1, type: "MASS", toBaseFactor: 1 };
const kilogram: UnitConversionEntry = { id: 2, type: "MASS", toBaseFactor: 1000 };
const milliliter: UnitConversionEntry = { id: 3, type: "VOLUME", toBaseFactor: 1 };
const piece: UnitConversionEntry = { id: 4, type: "COUNT", toBaseFactor: 1 };
const slice: UnitConversionEntry = { id: 5, type: "COUNT", toBaseFactor: 1 };
function buildLine(
overrides: Partial<TranslatedRecipeIngredient> & { rawText: string },
): TranslatedRecipeIngredient {
return {
name: overrides.rawText,
quantity: null,
unit: null,
ingredientId: null,
unitId: null,
...overrides,
};
}
it("sums the quantity of two lines resolving to the same ingredient, same unit (issue #53 follow-up)", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "100g Sugar", ingredientId: 1, quantity: 100, unitId: gram.id }),
buildLine({ rawText: "45g Sugar", ingredientId: 1, quantity: 45, unitId: gram.id }),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(1);
expect(merged[0].quantity).to.equal(145);
expect(merged[0].unitId).to.equal(gram.id);
expect(merged[0].rawText).to.equal("100g Sugar + 45g Sugar");
});
it("converts through toBaseFactor when the duplicate uses a different unit of the same type", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "500g Flour", ingredientId: 1, quantity: 500, unitId: gram.id }),
buildLine({
rawText: "0.5kg Flour",
ingredientId: 1,
quantity: 0.5,
unitId: kilogram.id,
}),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(1);
expect(merged[0].quantity).to.equal(1000);
expect(merged[0].unitId).to.equal(gram.id);
});
it("keeps duplicate COUNT-unit lines separate rather than guessing — a slice isn't a piece", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "2 piece Bread", ingredientId: 1, quantity: 2, unitId: piece.id }),
buildLine({ rawText: "3 slice Bread", ingredientId: 1, quantity: 3, unitId: slice.id }),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(2);
});
it("keeps duplicate lines with incompatible unit types separate (MASS vs VOLUME)", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "200g Milk", ingredientId: 1, quantity: 200, unitId: gram.id }),
buildLine({
rawText: "200ml Milk",
ingredientId: 1,
quantity: 200,
unitId: milliliter.id,
}),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(2);
});
it("never merges two unresolved lines (ingredientId: null) together", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(2);
});
it("leaves non-duplicate lines untouched and preserves order", () => {
const sugar = buildLine({ rawText: "Sugar", ingredientId: 1, quantity: 1, unitId: gram.id });
const salt = buildLine({ rawText: "Salt", ingredientId: 2, quantity: 1, unitId: gram.id });
const merged = mergeDuplicateIngredients([sugar, salt], [gram, kilogram, milliliter]);
expect(merged).to.deep.equal([sugar, salt]);
});
});
describe("translateRecipe", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("resolves real TechStep ids from the seeded French catalog", async () => {
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const recipe = buildParsedRecipe(["Hacher les oignons", "Faire mijoter à feu doux"]);
const translated = await translateRecipe(recipe, "fr");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
[chop.id],
[simmer.id],
]);
});
it("finds nothing for English text against the French catalog — locales are separate rule sets, never mixed", async () => {
// A step lifted verbatim from a real TheMealDB recipe.
const recipe = buildParsedRecipe([
"Bring a large saucepan of salted water to the boil",
"Chop the onions finely",
]);
const translated = await translateRecipe(recipe, "fr");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
});
it("resolves real TechStep ids from the seeded English catalog", async () => {
const boil = await prisma.techStep.findFirstOrThrow({ where: { key: "boil" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
// Same two steps as the French/English mismatch test above, this
// time matched against the matching-language catalog.
const recipe = buildParsedRecipe([
"Bring a large saucepan of salted water to the boil",
"Chop the onions finely",
]);
const translated = await translateRecipe(recipe, "en");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
[boil.id],
[chop.id],
]);
});
it("finds nothing for a locale with no mappings at all", async () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
const translated = await translateRecipe(recipe, "de");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[]]);
});
it("resolves real Ingredient/Unit ids from the seeded English catalog for an 'en' translation", async () => {
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Chop the onions finely"]),
ingredients: [
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
],
};
const translated = await translateRecipe(recipe, "en");
expect(translated.ingredients).to.deep.equal([
{
rawText: "1 cup onions, chopped",
quantity: 1,
unit: null,
name: "onions",
ingredientId: onion.id,
unitId: cup.id,
},
]);
});
it("resolves a real Ingredient id from the seeded French catalog, tolerating a regular French plural", async () => {
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" }],
};
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients[0]?.ingredientId).to.equal(carrot.id);
expect(translated.ingredients[0]?.quantity).to.equal(3);
});
it("resolves a real multi-word Unit id from the seeded French catalog (issue: matchUnit used to only ever compare a single word)", async () => {
const wheatFlour = await prisma.ingredient.findFirstOrThrow({ where: { key: "wheatFlour" } });
const tablespoon = await prisma.unit.findFirstOrThrow({ where: { key: "tablespoon" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [
{
rawText: "2 cuillères à soupe de farine de blé",
quantity: null,
unit: null,
name: "farine de blé",
},
],
};
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients[0]).to.deep.equal({
rawText: "2 cuillères à soupe de farine de blé",
quantity: 2,
unit: null,
name: "farine de blé",
ingredientId: wheatFlour.id,
unitId: tablespoon.id,
});
});
it("still extracts a locale-agnostic quantity even for a locale with no ingredient/unit matching data at all, leaving only the ids null", async () => {
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
],
};
const translated = await translateRecipe(recipe, "de");
expect(translated.ingredients).to.deep.equal([
{
rawText: "1 cup onions, chopped",
quantity: 1,
unit: null,
name: "onions",
ingredientId: null,
unitId: null,
},
]);
});
});
});

View file

@ -1,37 +0,0 @@
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../../src/lib/recipe-matching/tech-step-eval-runner.js";
import { resetDatabase } from "../../test-support/reset-db.js";
/**
* Regression gate for `TECH_STEP_TRAINING_DATA` every change to that
* corpus (including a maintainer applying suggestions from
* `TechStepTrainingSuggestion`, see `scripts/retrain-tech-steps.ts`) must
* keep this suite green. Runs {@link runTechStepEvalSuite} (the real
* trained classifier against `tech-step-eval-dataset.ts`) and asserts the
* aggregate F1 doesn't fall below {@link MIN_OVERALL_F1} see that
* constant's own doc comment (`tech-step-eval-runner.ts`) for the real run
* it was calibrated against.
*/
describe("tech-step-eval", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it(`scores at least ${MIN_OVERALL_F1} aggregate F1 against the labeled evaluation set`, async () => {
const { overall, byKey } = await runTechStepEvalSuite();
expect(
overall.f1,
`aggregate F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)}) fell below the ${MIN_OVERALL_F1} floor — per-technique breakdown: ${JSON.stringify(byKey)}`,
).to.be.at.least(MIN_OVERALL_F1);
});
});

View file

@ -1,427 +0,0 @@
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
normalizeText,
splitIntoClauses,
type TechniqueCandidate,
techStepClassifier,
} from "../../src/lib/recipe-matching/tech-step-matcher.js";
import { resetDatabase } from "../../test-support/reset-db.js";
describe("tech-step-matcher", () => {
describe("normalizeText", () => {
it("lowercases and strips accents", () => {
expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four");
});
it("strips a variety of diacritics, including cedilla", () => {
expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee");
});
it("leaves already-plain text unchanged, aside from casing", () => {
expect(normalizeText("Mix everything")).to.equal("mix everything");
});
it("returns an empty string for an empty input", () => {
expect(normalizeText("")).to.equal("");
});
});
describe("splitIntoClauses", () => {
// A candidate's own `uid` doesn't matter to the splitting logic itself
// (it's opaque, carried through as `anchor`) — kept short and
// arbitrary across these fixtures.
function candidate(uid: string, start: number, end: number): TechniqueCandidate {
return { uid, start, end };
}
it("returns the whole description as one anchor-less clause when there are no candidates", () => {
const text = "Servir immédiatement";
const result = splitIntoClauses(text, []);
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: null }]);
});
it("returns the whole description as one clause anchored on the single candidate", () => {
const melt = candidate("melt", 6, 13);
const text = "Faire fondre le beurre";
const result = splitIntoClauses(text, [melt]);
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]);
});
it("splits into two clauses at the whitespace nearest the gap's midpoint between two candidates", () => {
// "Préchauffer la poêle, puis faire fondre le beurre"
// 0 1 2 3 4
// 0123456789012345678901234567890123456789012345678901
const preheat = candidate("preheat", 0, 11); // "Préchauffer"
const melt = candidate("melt", 27, 39); // "faire fondre"
const text = "Préchauffer la poêle, puis faire fondre le beurre";
const result = splitIntoClauses(text, [preheat, melt]);
expect(result).to.have.length(2);
// The gap between the two candidates is [11, 27) — its raw midpoint
// (19) falls inside "poêle" (see findGapSplitPoint's doc comment for
// why that's specifically what this snaps away from); the nearest
// actual whitespace to that midpoint is the space at 21, right after
// the comma.
expect(result[0]).to.deep.equal({ start: 0, end: 21, anchor: preheat });
expect(result[1]).to.deep.equal({ start: 21, end: text.length, anchor: melt });
// The two clauses are contiguous and cover the whole text.
expect(
text.slice(result[0].start, result[0].end) + text.slice(result[1].start, result[1].end),
).to.equal(text);
});
it("sorts out-of-order candidates before splitting, and anchors each clause on the matching one", () => {
const preheat = candidate("preheat", 0, 11);
const melt = candidate("melt", 27, 39);
// Passed in reverse — the function must still produce clauses in
// reading order, each anchored on the right candidate.
const result = splitIntoClauses("Préchauffer la poêle, puis faire fondre le beurre", [
melt,
preheat,
]);
expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["preheat", "melt"]);
});
it("produces N contiguous clauses for N candidates, each anchored on its own", () => {
const a = candidate("a", 0, 3);
const b = candidate("b", 10, 13);
const c = candidate("c", 20, 23);
const text = "x".repeat(30);
const result = splitIntoClauses(text, [a, b, c]);
expect(result).to.have.length(3);
expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["a", "b", "c"]);
// Contiguous: each clause's end is the next one's start.
expect(result[0].start).to.equal(0);
expect(result[0].end).to.equal(result[1].start);
expect(result[1].end).to.equal(result[2].start);
expect(result[2].end).to.equal(text.length);
});
it("clamps the split point to the earlier candidate's own end when two candidates are adjacent/overlapping", () => {
// Gap midpoint would fall *before* `a`'s own end here — must not
// produce a clause that cuts into `a`'s own anchor span.
const a = candidate("a", 0, 10);
const b = candidate("b", 8, 15);
const result = splitIntoClauses("x".repeat(20), [a, b]);
expect(result[0].end).to.be.at.least(a.end);
expect(result[1].start).to.equal(result[0].end);
});
});
describe("techStepClassifier", () => {
// `techStepClassifier` is the one shared singleton (see
// tech-step-matcher.ts's own doc comment on why) — these tests
// exercise it against the real training corpus
// (`services/tech-step-intent-service`'s `training_data.py`) and the
// real seeded `TechStep` catalog, rather than synthetic injectable
// fixtures the old regex-based `matchTechStepSpans(description,
// mappings)` allowed. Every call round-trips over HTTP to a real,
// locally running `services/tech-step-intent-service` (see that
// service's own README and `apps/api/.env.test`) — that service trains
// itself once at its own startup (`test-support/mocha-root-hooks.ts`'s
// root hook doesn't wait on it, CI's own "wait for /health" step
// already does), so calls here are just a normal HTTP round-trip,
// comfortably inside this suite's default 10s timeout (.mocharc.json).
let simmerId: number;
let cookId: number;
let bakeId: number;
let preheatId: number;
let meltId: number;
let boilId: number;
let chopId: number;
// Real seeded catalog entries that also happen to be mentioned by
// several fixtures below now that `matchTechStepSpans` also resolves
// ingredient/utensil metadata — see `matchTechStepSpans`'s own describe
// block for where each of these gets used.
let panId: number;
let butterId: number;
let onionId: number;
let walnutsId: number;
beforeEach(async () => {
await resetDatabase();
const [simmer, cook, bake, preheat, melt, boil, chop, pan, butter, onion, walnuts] =
await Promise.all([
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
prisma.utensil.findFirstOrThrow({ where: { key: "pan" } }),
prisma.ingredient.findFirstOrThrow({ where: { key: "butter" } }),
prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } }),
// "Noix" (walnuts) — turns out to also be a real seeded ingredient
// label, and "noix" is literally the French word for "a pat of
// butter" ("une noix de beurre") used in one of the fixtures
// below, so it's a genuine (if slightly comical) second match
// alongside "beurre" in that clause, not a fixture bug.
prisma.ingredient.findFirstOrThrow({ where: { key: "walnuts" } }),
]);
simmerId = simmer.id;
cookId = cook.id;
bakeId = bake.id;
preheatId = preheat.id;
meltId = melt.id;
boilId = boil.id;
chopId = chop.id;
panId = pan.id;
butterId = butter.id;
onionId = onion.id;
walnutsId = walnuts.id;
});
after(async () => {
await prisma.$disconnect();
});
describe("matchTechSteps", () => {
it("matches an exact expression", async () => {
expect(
await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "fr"),
).to.deep.equal([simmerId]);
});
it("is case- and accent-insensitive", async () => {
expect(await techStepClassifier.matchTechSteps("FAIRE MIJOTER", "fr")).to.deep.equal([
simmerId,
]);
});
it("returns an empty sequence when nothing matches", async () => {
expect(
await techStepClassifier.matchTechSteps("Ranger les couverts dans le tiroir", "fr"),
).to.deep.equal([]);
});
it("returns an empty sequence for an empty description", async () => {
expect(await techStepClassifier.matchTechSteps("", "fr")).to.deep.equal([]);
});
it("returns an empty sequence for a locale nothing was trained on", async () => {
expect(
await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "de"),
).to.deep.equal([]);
});
it("detects several distinct techniques in one step, in reading order", async () => {
expect(
await techStepClassifier.matchTechSteps(
"Préchauffer la poêle, puis faire fondre le beurre",
"fr",
),
).to.deep.equal([preheatId, meltId]);
});
it("reverses the sequence when the techniques are mentioned in the opposite order", async () => {
expect(
await techStepClassifier.matchTechSteps(
"Faire fondre le beurre puis préchauffer le four",
"fr",
),
).to.deep.equal([meltId, preheatId]);
});
it("still matches the generic technique on its own when the more specific one isn't implied", async () => {
expect(
await techStepClassifier.matchTechSteps("Faire cuire à feu moyen", "fr"),
).to.deep.equal([cookId]);
});
it("resolves the more specific technique when a generic one's own vocabulary is embedded in it", async () => {
// "Cuire au four" literally contains "cuire" (the generic `cook`
// verb) but means the more specific `bake` — the classifier (not
// a weight table) is what has to get this right now.
expect(
await techStepClassifier.matchTechSteps("Cuire au four pendant 30 minutes", "fr"),
).to.deep.equal([bakeId]);
});
it("understands a technique described without ever naming it — the whole point of moving off pure keyword matching", async () => {
// No literal "fondre"/"fondu" anywhere in this sentence, yet it
// unambiguously means `melt` — this is the exact motivating case
// (see this module's own doc comment) a regex could never catch.
expect(
await techStepClassifier.matchTechSteps(
"jusqu'à ce que le beurre ait disparu dans la poêle",
"fr",
),
).to.deep.equal([meltId]);
});
it("understands preheating described without the verb 'préchauffer'", async () => {
expect(
await techStepClassifier.matchTechSteps("mettre la poêle sur feu vif", "fr"),
).to.deep.equal([preheatId]);
});
it("matches English text against the English-trained vocabulary", async () => {
expect(
await techStepClassifier.matchTechSteps(
"Bring a large saucepan of salted water to the boil",
"en",
),
).to.deep.equal([boilId]);
});
});
describe("matchTechStepSpans", () => {
it("returns a tight keyword span, and a wider context span that's the whole description when there's only one candidate", async () => {
const text = "Faire mijoter à feu doux";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.deep.equal([
{
techStepId: simmerId,
start: 6,
end: 13,
contextStart: 0,
contextEnd: text.length,
ingredients: [],
utensils: [],
},
]);
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
});
it("returns an empty list when nothing matches", async () => {
expect(
await techStepClassifier.matchTechStepSpans("Ranger les couverts dans le tiroir", "fr"),
).to.deep.equal([]);
});
it("returns each distinct technique's own tight keyword span and its own wider context span, in reading order", async () => {
const text = "Préchauffer la poêle, puis faire fondre le beurre";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.have.length(2);
expect(result[0].techStepId).to.equal(preheatId);
expect(result[1].techStepId).to.equal(meltId);
// Each keyword span, sliced back out of the original text, is
// exactly the word(s) that anchored that match — what the frontend
// needs to highlight the exact right characters.
expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer");
expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre");
// Each context span is the wider clause the keyword was found in —
// the two are contiguous and cover the whole description between
// them (see splitIntoClauses, which computed these).
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
"Préchauffer la poêle,",
);
expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal(
" puis faire fondre le beurre",
);
expect(result[0].contextEnd).to.equal(result[1].contextStart);
});
it("understands both techniques in the classic 'Dans une poêle chaude, faire chauffer une noix de beurre' example, each with its own keyword and context", async () => {
// The motivating example for context spans in the first place:
// `preheat`'s keyword is a noun phrase ("poêle chaude"), not a
// verb — its context ("Dans une poêle chaude") is what actually
// shows this is about preparing the pan, not (say) deglazing one.
const text = "Dans une poêle chaude, faire chauffer une noix de beurre";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.have.length(2);
expect(result[0]).to.deep.equal({
techStepId: preheatId,
start: 9,
end: 21,
contextStart: 0,
contextEnd: 22,
// "poêle" (the pan) sits inside this very clause — a separate
// utensil mention from `preheat`'s own "poêle chaude" keyword
// span above, found by the intent service's *other* PhraseMatcher
// (see `IntentServiceEntity.kind`).
ingredients: [],
utensils: [{ utensilId: panId, start: 9, end: 14 }],
});
expect(result[1]).to.deep.equal({
techStepId: meltId,
start: 23,
end: 37,
contextStart: 22,
contextEnd: text.length,
// Two mentions in this clause: "noix" (walnuts — also a real
// seeded ingredient, and literally the French word this phrase
// uses for "a pat of [butter]") *and* "beurre" itself, in
// reading order.
ingredients: [
{ ingredientId: walnutsId, start: 42, end: 46, quantity: null, unitId: null },
{ ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
],
utensils: [],
});
expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude");
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
"Dans une poêle chaude,",
);
expect(text.slice(result[1].start, result[1].end)).to.equal("faire chauffer");
expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal(
" faire chauffer une noix de beurre",
);
});
it("falls back to highlighting the whole clause for both spans when a technique was found with no literal anchor word", async () => {
const text = "jusqu'à ce que le beurre ait disparu dans la poêle";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.deep.equal([
{
techStepId: meltId,
start: 0,
end: text.length,
contextStart: 0,
contextEnd: text.length,
// "beurre" and "poêle" are both mentioned in this same
// anchor-less clause (there's no literal `melt` keyword here at
// all — the whole point of this test, see its own title) —
// still resolved, since ingredient/utensil scanning doesn't
// depend on the clause having a technique anchor of its own.
ingredients: [
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
],
utensils: [{ utensilId: panId, start: 45, end: 50 }],
},
]);
});
it("chop matches English text against the English-trained vocabulary, tight keyword span", async () => {
const text = "Chop the onions finely";
const result = await techStepClassifier.matchTechStepSpans(text, "en");
expect(result).to.deep.equal([
{
techStepId: chopId,
start: 0,
end: 4,
contextStart: 0,
contextEnd: text.length,
ingredients: [
{ ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
],
utensils: [],
},
]);
expect(text.slice(0, 4)).to.equal("Chop");
});
// Quantity+unit extraction itself (the leading-number-before-a-mention
// heuristic) is covered in full, deterministically, by
// `findIngredientMentions`'s own tests (`ingredient-matcher.test.ts`)
// — deliberately not re-exercised here through a brand-new invented
// sentence: a novel combination of words the real `textcat` (trained
// on a fixed, finite corpus, see `training_data.py`) has never seen
// together can land on a confidently-wrong technique for reasons
// that have nothing to do with this file's own logic, making such a
// test flaky against corpus/threshold changes rather than a
// trustworthy regression guard. The two tests above/below already
// demonstrate technique+ingredient+utensil co-occurring in one
// clause using sentences already proven reliable by this suite.
});
});
});

View file

@ -1,341 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { sevenFiftyGAdapter } from "../../src/sources/750g.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm";
/**
* A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately
* reproducing two real 750g.com bugs verified live on the recipe this test's
* URL/content is modeled after:
* - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid
* JSON as-is this is exactly what {@link sanitizeJsonLdBlocks} in the
* adapter under test has to repair before `JSON.parse` can succeed);
* - `Pr&amp;eacute;parez` a real "é" that went through 750g's own
* HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to
* fully resolve it back to "é").
* Plus a plain `&#039;` apostrophe entity in an ingredient line, the more
* common single-encoding case.
*/
const RAW_RECIPE_JSON_LD = `{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Poulet au vin jaune et aux morilles",
"description": "Une recette de f\\u00eate.",
"image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"},
"recipeYield": "6 personnes",
"recipeIngredient": ["1 poulet fermier", "Sel &#039;fin&#039;"],
"recipeInstructions": [
{"@type": "HowToStep", "text": "Pr&amp;eacute;parez les morilles :\r\nFendez-les en deux."}
],
"url": "${RECIPE_URL}"
}`;
function htmlWithRawJsonLd(rawJson: string): string {
return `<!doctype html><html><head><script type="application/ld+json">${rawJson}</script></head><body></body></html>`;
}
describe("sevenFiftyGAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(sevenFiftyGAdapter.key).to.equal("750g");
expect(sevenFiftyGAdapter.name).to.equal("750g");
expect(sevenFiftyGAdapter.official).to.equal(false);
expect(sevenFiftyGAdapter.iconUrl).to.be.a("string");
expect(sevenFiftyGAdapter.locale).to.equal("fr");
});
describe("list", () => {
/**
* Models the real shape found live: a card's own `<img>` sits
* immediately before its `<a class="card-link">`, but the fragment also
* carries decorative images that belong to no card at all (verified
* live: 28 `<img>` tags against 23 real cards for one sample query)
* an image search that isn't "nearest preceding, not naive same-index
* zip" would misattribute every card after the first stray image.
*/
const CARDS_HTML = `
<div class="grid">
<img src="https://static.750g.com/images/x/orphan-lead.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/tarte.jpg" alt="Tarte" />
<a href="https://www.750g.com/tarte-aux-pommes-r1.htm" class="card-link ">Tarte aux pommes</a>
</div>
<img src="https://static.750g.com/images/x/orphan-mid-1.jpg" class="decorative" />
<img src="https://static.750g.com/images/x/orphan-mid-2.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/gratin.jpg" alt="Gratin" />
<a href="https://www.750g.com/gratin-dauphinois-r2.htm" class="card-link ">Gratin dauphinois</a>
</div>
<div class="card">
<a href="https://www.750g.com/pain-perdu-r3.htm" class="card-link ">Pain perdu</a>
</div>
</div>
`;
it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items).to.deep.equal([
{
externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm",
title: "Tarte aux pommes",
picture: "https://static.750g.com/images/x/tarte.jpg",
url: "https://www.750g.com/tarte-aux-pommes-r1.htm",
},
{
externalId: "https://www.750g.com/gratin-dauphinois-r2.htm",
title: "Gratin dauphinois",
picture: "https://static.750g.com/images/x/gratin.jpg",
url: "https://www.750g.com/gratin-dauphinois-r2.htm",
},
{
externalId: "https://www.750g.com/pain-perdu-r3.htm",
title: "Pain perdu",
picture: null,
url: "https://www.750g.com/pain-perdu-r3.htm",
},
]);
});
it("decodes HTML entities in a card's title", async () => {
stubFetchHtml(
`<a href="https://www.750g.com/tarte-r1.htm" class="card-link ">Tarte aux pommes &#039;reinettes&#039;</a>`,
);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'");
});
it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.nextCursor).to.be.null;
});
it("ignores params.cursor for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" });
expect(requestedUrl).to.include("page=1");
expect(requestedUrl).not.to.include("page=7");
});
it("URL-encodes the query", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte aux pommes" });
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
});
describe("empty/omitted query (browsing with no filter)", () => {
it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
const result = await sevenFiftyGAdapter.list({});
expect(requestedUrl).to.include("dernieres-recettes.htm");
expect(requestedUrl).not.to.include("genius/query");
expect(result.items).to.have.length(3);
});
it("also browses for an explicitly empty query string, not just an omitted one", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "" });
expect(result.items).to.have.length(3);
});
it("requests the given cursor's page", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ cursor: "5" });
expect(requestedUrl).to.include("page=5");
});
it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => {
stubFetchHtml(CARDS_HTML);
const withItems = await sevenFiftyGAdapter.list({ cursor: "2" });
expect(withItems.nextCursor).to.equal("3");
stubFetchHtml("<html><body>Plus rien ici</body></html>");
const empty = await sevenFiftyGAdapter.list({ cursor: "50" });
expect(empty.nextCursor).to.be.null;
expect(empty.items).to.deep.equal([]);
});
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await sevenFiftyGAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await sevenFiftyGAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD));
const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Poulet au vin jaune");
});
it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
});
describe("parse", () => {
it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles");
expect(parsed.steps).to.deep.equal([
{ description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null },
]);
});
it("decodes a double HTML-entity-encoded accented character (é -> &eacute; -> &amp;eacute;)", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.steps[0]?.description).to.include("Préparez");
});
it("decodes a plain numeric apostrophe entity in ingredient text", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.ingredients).to.deep.equal([
{ rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" },
{ rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" },
]);
});
it("leaves picture/sourceUrl untouched by entity decoding", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg");
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
});
it("maps a recipe with no quirks end to end, same as the generic adapter would", () => {
const clean = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte classique.",
image: "https://static.750g.com/images/tarte.jpg",
recipeYield: 6,
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."],
};
const html = `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
clean,
)}</script></head><body></body></html>`;
const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.steps).to.deep.equal([
{ description: "Éplucher les pommes.", picture: null },
{ description: "Enfourner 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("750g");
}
});
});
});

View file

@ -1,301 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "../../src/sources/json-ld-recipe.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/** Wraps a JSON-LD payload (already an object/array, not yet stringified) in a minimal HTML page carrying it as one `<script type="application/ld+json">` block, optionally alongside `extraBlocks` (e.g. an unrelated `BreadcrumbList`, or deliberately malformed JSON). */
function htmlWithJsonLd(payload: unknown, ...extraBlocks: string[]): string {
const scripts = [JSON.stringify(payload), ...extraBlocks]
.map((json) => `<script type="application/ld+json">${json}</script>`)
.join("\n");
return `<!doctype html><html><head>${scripts}</head><body></body></html>`;
}
const RECIPE_URL = "https://example.test/recipes/apple-pie";
const baseRecipe = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Apple Pie",
description: "A classic apple pie.",
image: "https://example.test/apple-pie.jpg",
recipeYield: 8,
recipeIngredient: ["6 apples, peeled", "200g flour", " "],
recipeInstructions: ["Peel the apples.", "Bake at 180°C for 40 minutes."],
};
describe("jsonLdRecipeAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, iconless, browse-less source", () => {
expect(jsonLdRecipeAdapter.key).to.equal("jsonLdRecipe");
expect(jsonLdRecipeAdapter.official).to.equal(false);
expect(jsonLdRecipeAdapter.iconUrl).to.be.null;
});
describe("list", () => {
it("always returns an empty page — this source has no catalog of its own", async () => {
const result = await jsonLdRecipeAdapter.list({ query: "anything" });
expect(result).to.deep.equal({ items: [], nextCursor: null });
});
});
describe("fetchDetail", () => {
it("fetches the given URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithJsonLd(baseRecipe));
const result = await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Apple Pie");
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 404);
try {
await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("parse", () => {
function parse(html: string, url = RECIPE_URL) {
return jsonLdRecipeAdapter.parse({ html, url });
}
it("maps a straightforward JSON-LD Recipe end to end", () => {
const parsed = parse(htmlWithJsonLd(baseRecipe));
expect(parsed.name).to.equal("Apple Pie");
expect(parsed.description).to.equal("A classic apple pie.");
expect(parsed.picture).to.equal("https://example.test/apple-pie.jpg");
expect(parsed.portions).to.equal(8);
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
expect(parsed.steps).to.deep.equal([
{ description: "Peel the apples.", picture: null },
{ description: "Bake at 180°C for 40 minutes.", picture: null },
]);
});
it("filters out blank ingredient lines, keeping the full line as both rawText and name", () => {
const parsed = parse(htmlWithJsonLd(baseRecipe));
expect(parsed.ingredients).to.deep.equal([
{ rawText: "6 apples, peeled", quantity: null, unit: null, name: "6 apples, peeled" },
{ rawText: "200g flour", quantity: null, unit: null, name: "200g flour" },
]);
});
it("prefers the JSON-LD's own url over the fetched url, when present", () => {
const parsed = parse(
htmlWithJsonLd({ ...baseRecipe, url: "https://example.test/canonical" }),
);
expect(parsed.sourceUrl).to.equal("https://example.test/canonical");
});
it("accepts @type as an array containing Recipe", () => {
const parsed = parse(htmlWithJsonLd({ ...baseRecipe, "@type": ["Recipe", "NewsArticle"] }));
expect(parsed.name).to.equal("Apple Pie");
});
it("finds the Recipe nested under @graph", () => {
const graphPayload = {
"@context": "https://schema.org",
"@graph": [{ "@type": "BreadcrumbList", itemListElement: [] }, baseRecipe],
};
const parsed = parse(htmlWithJsonLd(graphPayload));
expect(parsed.name).to.equal("Apple Pie");
});
it("finds the Recipe among several top-level JSON-LD script blocks, skipping malformed ones", () => {
const html = htmlWithJsonLd(
{ "@type": "BreadcrumbList", itemListElement: [] },
"{ this is not valid json",
JSON.stringify(baseRecipe),
);
const parsed = parse(html);
expect(parsed.name).to.equal("Apple Pie");
});
describe("recipeInstructions shapes", () => {
it("splits a single free-text string on line breaks", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: "Step one.\nStep two.\n\nStep three.",
}),
);
expect(parsed.steps).to.deep.equal([
{ description: "Step one.", picture: null },
{ description: "Step two.", picture: null },
{ description: "Step three.", picture: null },
]);
});
it("reads HowToStep objects' text field", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: [
{ "@type": "HowToStep", text: "Peel the apples." },
{ "@type": "HowToStep", text: "Bake." },
],
}),
);
expect(parsed.steps).to.deep.equal([
{ description: "Peel the apples.", picture: null },
{ description: "Bake.", picture: null },
]);
});
it("falls back to a HowToStep's name when it has no text", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: [{ "@type": "HowToStep", name: "Peel the apples." }],
}),
);
expect(parsed.steps).to.deep.equal([{ description: "Peel the apples.", picture: null }]);
});
it("flattens HowToSections into their nested steps, dropping the section name", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: [
{
"@type": "HowToSection",
name: "Filling",
itemListElement: [
{ "@type": "HowToStep", text: "Peel the apples." },
{ "@type": "HowToStep", text: "Slice them." },
],
},
{
"@type": "HowToSection",
name: "Baking",
itemListElement: [{ "@type": "HowToStep", text: "Bake at 180°C." }],
},
],
}),
);
expect(parsed.steps).to.deep.equal([
{ description: "Peel the apples.", picture: null },
{ description: "Slice them.", picture: null },
{ description: "Bake at 180°C.", picture: null },
]);
});
it("throws RecipeSourceParseError when there are no usable instructions", () => {
expect(() => parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [] }))).to.throw(
RecipeSourceParseError,
);
expect(() =>
parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: undefined })),
).to.throw(RecipeSourceParseError);
});
});
describe("image shapes", () => {
it("reads a bare string image", () => {
const parsed = parse(
htmlWithJsonLd({ ...baseRecipe, image: "https://example.test/a.jpg" }),
);
expect(parsed.picture).to.equal("https://example.test/a.jpg");
});
it("reads the first entry of an array of ImageObjects", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
image: [
{ "@type": "ImageObject", url: "https://example.test/large.jpg" },
{ "@type": "ImageObject", url: "https://example.test/small.jpg" },
],
}),
);
expect(parsed.picture).to.equal("https://example.test/large.jpg");
});
it("is null when there's no image at all", () => {
const parsed = parse(htmlWithJsonLd({ ...baseRecipe, image: undefined }));
expect(parsed.picture).to.be.null;
});
});
describe("recipeYield shapes", () => {
it("accepts a plain number", () => {
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: 4 })).portions).to.equal(4);
});
it("extracts the leading integer from a free-text string", () => {
expect(
parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "4 servings" })).portions,
).to.equal(4);
});
it("reads the first entry of an array", () => {
expect(
parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: ["6", "6 servings"] })).portions,
).to.equal(6);
});
it("is null when absent or unparseable", () => {
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: undefined })).portions).to.be
.null;
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "plenty" })).portions).to.be.null;
});
});
describe("failure cases", () => {
it("throws RecipeSourceParseError when the page has no JSON-LD at all", () => {
expect(() => parse("<html><body>No structured data here</body></html>")).to.throw(
RecipeSourceParseError,
);
});
it("throws RecipeSourceParseError when JSON-LD exists but none of it is a Recipe", () => {
const html = htmlWithJsonLd({ "@type": "BreadcrumbList", itemListElement: [] });
expect(() => parse(html)).to.throw(RecipeSourceParseError);
});
it("throws RecipeSourceParseError when the Recipe has no name", () => {
const html = htmlWithJsonLd({ ...baseRecipe, name: undefined });
expect(() => parse(html)).to.throw(RecipeSourceParseError);
});
});
});
});

View file

@ -1,301 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { mangerBougerAdapter } from "../../src/sources/manger-bouger.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as every other adapter test in this family. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
const DETAIL_URL =
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/2854-salade-de-pates-aux-courgettes";
/** Wraps a `props.initialState.recipes` payload (the shape `list()` reads) in a minimal `__NEXT_DATA__` script tag, the same server-rendered hydration data every mangerbouger.fr Next.js page carries. */
function htmlWithListNextData(recipesState: unknown): string {
const payload = { props: { initialState: { recipes: recipesState } } };
return `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(
payload,
)}</script></body></html>`;
}
/**
* A real Slate.js rich-text document (paragraph + bulleted-list of
* list-items, the only block types ever observed live), JSON-stringified
* exactly the shape mangerbouger.fr's own JSON-LD embeds as a `HowToStep`'s
* `text` field.
*/
const SLATE_STEP_DOCUMENT = JSON.stringify([
{ type: "paragraph", children: [{ text: "Cuisson des courgettes", bold: true }] },
{
type: "bulleted-list",
children: [
{ type: "list-item", children: [{ text: "Épluchez les courgettes" }] },
{ type: "list-item", children: [{ text: "Coupez-les en rondelles" }] },
],
},
]);
/** A JSON-LD `Recipe` payload shaped exactly like a real mangerbouger.fr detail page's — no `recipeYield` (verified absent live on every sampled recipe), `recipeInstructions` holding {@link SLATE_STEP_DOCUMENT} instead of prose. */
const RECIPE_JSON_LD_NO_YIELD = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Salade de pâtes aux courgettes",
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
recipeIngredient: ["3 Courgette", "4 cuillères à soupe Huile d'olive"],
recipeInstructions: [{ "@type": "HowToStep", name: "Étape 1", text: SLATE_STEP_DOCUMENT }],
url: DETAIL_URL,
};
/** Wraps a JSON-LD `Recipe` payload (already an object, not yet stringified) and, optionally, a `__NEXT_DATA__` detail-page payload carrying `portions`, in one minimal HTML page — the two independent script tags `parse()` reads. */
function htmlWithDetail(recipeJsonLd: unknown, portions?: number): string {
const nextData = portions === undefined ? "" : htmlWithDetailNextDataScript(portions);
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
recipeJsonLd,
)}</script></head><body>${nextData}</body></html>`;
}
function htmlWithDetailNextDataScript(portions: number): string {
const payload = { props: { initialState: { recipe: { recipe: { portions } } } } };
return `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(payload)}</script>`;
}
describe("mangerBougerAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(mangerBougerAdapter.key).to.equal("mangerBouger");
expect(mangerBougerAdapter.name).to.equal("Manger Bouger");
expect(mangerBougerAdapter.official).to.equal(false);
expect(mangerBougerAdapter.iconUrl).to.be.a("string");
expect(mangerBougerAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("maps __NEXT_DATA__'s recipes.list into RecipeSourceListItems", async () => {
stubFetchHtml(
htmlWithListNextData({
list: [
{
id: "2854",
slug: "2854-salade-de-pates-aux-courgettes",
name: "Salade de pâtes aux courgettes",
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
},
],
hasMorePages: false,
}),
);
const result = await mangerBougerAdapter.list({ query: "salade" });
expect(result.items).to.deep.equal([
{
externalId: DETAIL_URL,
title: "Salade de pâtes aux courgettes",
picture: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
url: DETAIL_URL,
},
]);
});
it("offers a next page when hasMorePages is true, and none when false", async () => {
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.equal("2");
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: false }));
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.be.null;
});
it("requests the given cursor's page and URL-encodes the query", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
status: 200,
});
}) as typeof fetch;
await mangerBougerAdapter.list({ query: "crème brûlée", cursor: "3" });
expect(requestedUrl).to.include("page=3");
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
});
it("skips a list entry missing a slug or a name", async () => {
stubFetchHtml(
htmlWithListNextData({
list: [
{ id: "1", name: "No slug", image: null },
{ id: "2", slug: "no-name", image: null },
],
hasMorePages: false,
}),
);
const result = await mangerBougerAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("returns an empty page rather than throwing when the page has no __NEXT_DATA__ at all", async () => {
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
const result = await mangerBougerAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await mangerBougerAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await mangerBougerAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithDetail(RECIPE_JSON_LD_NO_YIELD));
const result = await mangerBougerAdapter.fetchDetail(DETAIL_URL);
expect(result.url).to.equal(DETAIL_URL);
expect(result.html).to.include("Salade de p");
});
it("throws a RecipeSourceFetchError keyed to mangerBouger, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await mangerBougerAdapter.fetchDetail(DETAIL_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
}
});
});
describe("parse", () => {
it("flattens a Slate.js rich-text step into readable plain text", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.steps).to.deep.equal([
{
description:
"Cuisson des courgettes\n- Épluchez les courgettes\n- Coupez-les en rondelles",
picture: null,
},
]);
});
it("backfills recipeYield/portions from __NEXT_DATA__ when the JSON-LD itself doesn't state one", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD, 4),
url: DETAIL_URL,
});
expect(parsed.portions).to.equal(4);
});
it("leaves portions null when __NEXT_DATA__ has no portions to backfill from either", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.portions).to.be.null;
});
it("doesn't override recipeYield when the JSON-LD already states one", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail({ ...RECIPE_JSON_LD_NO_YIELD, recipeYield: 8 }, 4),
url: DETAIL_URL,
});
expect(parsed.portions).to.equal(8);
});
it("leaves an already-plain-text step untouched rather than mangling it", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail({
...RECIPE_JSON_LD_NO_YIELD,
recipeInstructions: [{ "@type": "HowToStep", text: "Faites bouillir de l'eau." }],
}),
url: DETAIL_URL,
});
expect(parsed.steps).to.deep.equal([
{ description: "Faites bouillir de l'eau.", picture: null },
]);
});
it("maps name/image/ingredients end to end via the underlying generic adapter", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.name).to.equal("Salade de pâtes aux courgettes");
expect(parsed.picture).to.equal(
"https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
);
expect(parsed.sourceUrl).to.equal(DETAIL_URL);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "3 Courgette", quantity: null, unit: null, name: "3 Courgette" },
{
rawText: "4 cuillères à soupe Huile d'olive",
quantity: null,
unit: null,
name: "4 cuillères à soupe Huile d'olive",
},
]);
});
it("throws a RecipeSourceParseError keyed to mangerBouger, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
mangerBougerAdapter.parse({ html, url: DETAIL_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("mangerBouger");
}
});
});
});

View file

@ -1,226 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { marmitonAdapter } from "../../src/sources/marmiton.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/**
* Wraps a schema.org `ItemList` payload (already an object, not yet
* stringified) in a minimal HTML page carrying it as one
* `<script type="application/ld+json">` block the shape marmiton.org's
* search-results page embeds `list()` reads.
*/
function htmlWithItemListJsonLd(itemListElement: unknown[]): string {
const payload = {
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", name: "Marmiton" },
{
"@type": "ItemList",
"@id": "https://www.marmiton.org/recettes/recherche.aspx?aqt=poulet#itemlist",
numberOfItems: itemListElement.length,
itemListElement,
},
],
};
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
const RECIPE_URL = "https://www.marmiton.org/recettes/recette_tarte-aux-pommes_11457.aspx";
const baseListItem = {
"@type": "ListItem",
position: 1,
url: RECIPE_URL,
name: "Tarte aux pommes",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
};
const baseRecipeJsonLd = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte aux pommes classique.",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
recipeYield: "6 personnes",
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: [
{ "@type": "HowToStep", text: "Épluchez les pommes." },
{ "@type": "HowToStep", text: "Enfournez 30 minutes." },
],
};
function htmlWithRecipeJsonLd(): string {
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
baseRecipeJsonLd,
)}</script></head><body></body></html>`;
}
describe("marmitonAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(marmitonAdapter.key).to.equal("marmiton");
expect(marmitonAdapter.name).to.equal("Marmiton");
expect(marmitonAdapter.official).to.equal(false);
expect(marmitonAdapter.iconUrl).to.be.a("string");
expect(marmitonAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("maps the search page's ItemList into RecipeSourceListItems and offers a next page", async () => {
stubFetchHtml(htmlWithItemListJsonLd([baseListItem]));
const result = await marmitonAdapter.list({ query: "tarte aux pommes" });
expect(result.items).to.deep.equal([
{
externalId: RECIPE_URL,
title: "Tarte aux pommes",
picture: "https://assets.afcdn.com/recipe/tarte.jpg",
url: RECIPE_URL,
},
]);
expect(result.nextCursor).to.equal("2");
});
it("requests the given cursor's page and stops offering a next page once a page comes back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithItemListJsonLd([]), { status: 200 });
}) as typeof fetch;
const result = await marmitonAdapter.list({ query: "tarte", cursor: "3" });
expect(requestedUrl).to.include("page=3");
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("treats a 404 (page past the last one) as an empty final page, not a failure", async () => {
stubFetchHtml("", 404);
const result = await marmitonAdapter.list({ query: "tarte", cursor: "999" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("skips a ListItem missing a url or a name", async () => {
stubFetchHtml(
htmlWithItemListJsonLd([
{ "@type": "ListItem", position: 1, name: "No url" },
{ "@type": "ListItem", position: 2, url: RECIPE_URL },
]),
);
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("returns an empty page rather than throwing when the page has no ItemList at all", async () => {
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("throws RecipeSourceFetchError on a non-2xx, non-404 response", async () => {
stubFetchHtml("", 500);
try {
await marmitonAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await marmitonAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithRecipeJsonLd());
const result = await marmitonAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Tarte aux pommes");
});
it("throws a RecipeSourceFetchError keyed to marmiton, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await marmitonAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("marmiton");
}
});
});
describe("parse", () => {
it("delegates to the generic JSON-LD parser end to end", () => {
const parsed = marmitonAdapter.parse({ html: htmlWithRecipeJsonLd(), url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "3 pommes", quantity: null, unit: null, name: "3 pommes" },
{ rawText: "1 pâte brisée", quantity: null, unit: null, name: "1 pâte brisée" },
]);
expect(parsed.steps).to.deep.equal([
{ description: "Épluchez les pommes.", picture: null },
{ description: "Enfournez 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to marmiton, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
marmitonAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("marmiton");
}
});
});
});

View file

@ -1,210 +0,0 @@
import type { SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../../src/app.js";
import { prisma } from "../../src/db/prisma.js";
import { findImportedRecipeIds, syncRecipeSources } from "../../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import {
clearRecipeSources,
registerRecipeSource,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
import { resetDatabase } from "../../test-support/reset-db.js";
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official`/`iconUrl` matter for exercising `syncRecipeSources`. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return {
key,
name,
official: false,
iconUrl: null,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
describe("recipe-source-sync", () => {
const app = createApp();
async function signup(): Promise<{ profileId: number }> {
const res = await request.agent(app).post("/auth/signup").send(buildSignupPayload());
return { profileId: res.body.id };
}
beforeEach(async () => {
await resetDatabase();
clearRecipeSources();
});
afterEach(() => {
clearRecipeSources();
});
after(async () => {
await prisma.$disconnect();
});
describe("syncRecipeSources", () => {
it("does nothing when the registry is empty", async () => {
await syncRecipeSources(prisma);
expect(await prisma.source.count()).to.equal(0);
});
it("creates a Source row per registered adapter", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
expect(source.name).to.equal("Fake Source");
});
it("is idempotent — running it twice doesn't duplicate rows", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
await syncRecipeSources(prisma);
await syncRecipeSources(prisma);
expect(await prisma.source.count()).to.equal(1);
});
it("updates the name when the adapter's own name changes between syncs", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Old Name"));
await syncRecipeSources(prisma);
clearRecipeSources();
registerRecipeSource(buildFakeAdapter("fakeSource", "New Name"));
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
expect(source.name).to.equal("New Name");
});
it("never deletes a Source row whose key fell out of the registry", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
await syncRecipeSources(prisma);
clearRecipeSources();
await syncRecipeSources(prisma);
expect(await prisma.source.count()).to.equal(1);
});
});
describe("findImportedRecipeIds", () => {
it("returns an empty map for a sourceKey with no matching Source row", async () => {
expect(await findImportedRecipeIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Map());
});
it("returns an empty map for an empty externalIds list", async () => {
expect(await findImportedRecipeIds(prisma, "fakeSource", [])).to.deep.equal(new Map());
});
it("returns exactly the externalIds already imported from that source, mapped to their Recipe id", async () => {
const { profileId } = await signup();
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const imported = await prisma.recipe.create({
data: {
name: "Tarte",
authorId: profileId,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
// Manually-authored, not tied to any source — shouldn't ever show up as "imported".
await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } });
const result = await findImportedRecipeIds(prisma, "fakeSource", ["1", "2", "3"]);
expect(result).to.deep.equal(new Map([["1", imported.id]]));
});
it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => {
const { profileId } = await signup();
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
registerRecipeSource(buildFakeAdapter("otherSource", "Other Source"));
await syncRecipeSources(prisma);
const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } });
await prisma.recipe.create({
data: {
name: "Tarte",
authorId: profileId,
portions: 4,
sourceId: otherSource.id,
externalId: "1",
},
});
expect(await findImportedRecipeIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Map());
});
});
describe("Recipe(sourceId, externalId) uniqueness", () => {
it("rejects importing the same source recipe twice", async () => {
const { profileId } = await signup();
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await prisma.recipe.create({
data: {
name: "Tarte",
authorId: profileId,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
let rejected = false;
try {
await prisma.recipe.create({
data: {
name: "Tarte (again)",
authorId: profileId,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
} catch {
rejected = true;
}
expect(rejected).to.be.true;
});
it("allows any number of manually-authored recipes (both columns null)", async () => {
const { profileId } = await signup();
await prisma.recipe.create({ data: { name: "Une", authorId: profileId, portions: 4 } });
await prisma.recipe.create({ data: { name: "Deux", authorId: profileId, portions: 4 } });
expect(await prisma.recipe.count()).to.equal(2);
});
});
});

View file

@ -1,273 +0,0 @@
import { expect } from "chai";
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import { markAlreadyImported } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceError,
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import {
clearRecipeSources,
getRecipeSource,
listRecipeSources,
registerRecipeSource,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
interface FakeRawRecipe {
externalId: string;
title: string;
servings: number;
ingredientLines: string[];
instructionLines: string[];
}
const FAKE_CATALOG: FakeRawRecipe[] = [
{
externalId: "1",
title: "Tarte aux pommes",
servings: 6,
ingredientLines: ["3 pommes", "200 g de farine"],
instructionLines: ["Éplucher les pommes", "Cuire 30 minutes"],
},
{
externalId: "2",
title: "Soupe de légumes",
servings: 4,
ingredientLines: ["2 carottes"],
instructionLines: ["Mijoter 20 minutes"],
},
{
externalId: "3",
title: "Salade César",
servings: 2,
ingredientLines: ["1 salade"],
instructionLines: ["Mélanger"],
},
];
const PAGE_SIZE = 2;
/** A minimal in-memory `RecipeSourceAdapter`, standing in for a real website/API — proves the interface (recipe-source-adapter.ts) is actually implementable end to end. */
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe> {
return {
key,
name: "Fake Source",
official: false,
iconUrl: null,
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const start = params.cursor ? Number(params.cursor) : 0;
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
const nextStart = start + PAGE_SIZE;
return {
items: page.map((recipe) => ({
externalId: recipe.externalId,
title: recipe.title,
picture: null,
url: `https://fake.test/recipes/${recipe.externalId}`,
})),
nextCursor: nextStart < FAKE_CATALOG.length ? String(nextStart) : null,
};
},
async fetchDetail(externalId: string): Promise<FakeRawRecipe> {
const found = FAKE_CATALOG.find((recipe) => recipe.externalId === externalId);
if (!found) throw new RecipeSourceFetchError(key, `Unknown recipe ${externalId}`);
return found;
},
parse(raw: FakeRawRecipe): ParsedRecipe {
return {
name: raw.title,
description: null,
picture: null,
portions: raw.servings,
sourceUrl: `https://fake.test/recipes/${raw.externalId}`,
ingredients: raw.ingredientLines.map((line) => ({
rawText: line,
quantity: null,
unit: null,
name: line,
})),
steps: raw.instructionLines.map((line) => ({ description: line, picture: null })),
};
},
};
}
describe("recipe-source", () => {
afterEach(() => {
clearRecipeSources();
});
describe("registry", () => {
it("registers and retrieves an adapter by key", () => {
const adapter = buildFakeAdapter();
registerRecipeSource(adapter);
expect(getRecipeSource("fakeSource")).to.equal(adapter);
});
it("returns undefined for an unregistered key", () => {
expect(getRecipeSource("unknown")).to.be.undefined;
});
it("lists every registered adapter", () => {
registerRecipeSource(buildFakeAdapter("fakeSource"));
registerRecipeSource(buildFakeAdapter("otherSource"));
expect(
listRecipeSources()
.map((adapter) => adapter.key)
.sort(),
).to.deep.equal(["fakeSource", "otherSource"]);
});
it("rejects registering the same key twice", () => {
registerRecipeSource(buildFakeAdapter());
expect(() => registerRecipeSource(buildFakeAdapter())).to.throw(/already registered/);
});
it("clearRecipeSources empties the registry", () => {
registerRecipeSource(buildFakeAdapter());
clearRecipeSources();
expect(listRecipeSources()).to.deep.equal([]);
});
});
describe("adapter contract (via a fake adapter)", () => {
it("browses in pages until nextCursor is null", async () => {
const adapter = buildFakeAdapter();
const firstPage = await adapter.list({});
expect(firstPage.items.map((item) => item.externalId)).to.deep.equal(["1", "2"]);
expect(firstPage.nextCursor).to.equal("2");
const secondPage = await adapter.list({ cursor: firstPage.nextCursor });
expect(secondPage.items.map((item) => item.externalId)).to.deep.equal(["3"]);
expect(secondPage.nextCursor).to.be.null;
});
it("filters by query the same way, when the source supports it (fake adapter ignores it — only pagination is exercised here)", async () => {
const adapter = buildFakeAdapter();
const res = await adapter.list({ query: "tarte" });
// Documents that `query` is a valid, optional param even though this
// particular fake doesn't act on it — a real adapter would filter.
expect(res.items).to.have.length(2);
});
it("fetches the detail for a selected item, then parses it into a ParsedRecipe", async () => {
const adapter = buildFakeAdapter();
const raw = await adapter.fetchDetail("1");
const parsed = adapter.parse(raw);
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.description).to.be.null;
expect(parsed.portions).to.equal(6);
expect(parsed.sourceUrl).to.equal("https://fake.test/recipes/1");
expect(parsed.ingredients).to.have.length(2);
expect(parsed.ingredients[0]).to.deep.equal({
rawText: "3 pommes",
quantity: null,
unit: null,
name: "3 pommes",
});
expect(parsed.steps).to.deep.equal([
{ description: "Éplucher les pommes", picture: null },
{ description: "Cuire 30 minutes", picture: null },
]);
});
it("throws RecipeSourceFetchError for an unknown externalId", async () => {
const adapter = buildFakeAdapter();
try {
await adapter.fetchDetail("does-not-exist");
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("fakeSource");
}
});
});
describe("markAlreadyImported", () => {
const items: RecipeSourceListItem[] = [
{
externalId: "1",
title: "Tarte aux pommes",
picture: null,
url: "https://fake.test/recipes/1",
},
{
externalId: "2",
title: "Soupe de légumes",
picture: null,
url: "https://fake.test/recipes/2",
},
{ externalId: "3", title: "Salade César", picture: null, url: "https://fake.test/recipes/3" },
];
it("flags items whose externalId is in the imported set, leaves the rest false", () => {
const result = markAlreadyImported(items, new Set(["1", "3"]));
expect(
result.map((item) => ({
externalId: item.externalId,
alreadyImported: item.alreadyImported,
})),
).to.deep.equal([
{ externalId: "1", alreadyImported: true },
{ externalId: "2", alreadyImported: false },
{ externalId: "3", alreadyImported: true },
]);
});
it("flags nothing when the imported set is empty", () => {
const result = markAlreadyImported(items, new Set());
expect(result.every((item) => item.alreadyImported === false)).to.be.true;
});
it("returns an empty list unchanged", () => {
expect(markAlreadyImported([], new Set(["1"]))).to.deep.equal([]);
});
it("preserves every field from the original item alongside the new flag", () => {
const [first] = markAlreadyImported([items[0]], new Set(["1"]));
expect(first).to.deep.equal({ ...items[0], alreadyImported: true });
});
it("doesn't mutate the input items", () => {
const snapshot = structuredClone(items);
markAlreadyImported(items, new Set(["1"]));
expect(items).to.deep.equal(snapshot);
});
});
describe("RecipeSourceError hierarchy", () => {
it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => {
const cause = new Error("network down");
const err = new RecipeSourceFetchError("fakeSource", "could not reach source", { cause });
expect(err).to.be.instanceOf(Error);
expect(err).to.be.instanceOf(RecipeSourceError);
expect(err.name).to.equal("RecipeSourceFetchError");
expect(err.sourceKey).to.equal("fakeSource");
expect(err.message).to.equal("could not reach source");
expect(err.cause).to.equal(cause);
});
it("RecipeSourceParseError carries the source key and works without a cause", () => {
const err = new RecipeSourceParseError("fakeSource", "unexpected shape");
expect(err).to.be.instanceOf(RecipeSourceError);
expect(err.name).to.equal("RecipeSourceParseError");
expect(err.sourceKey).to.equal("fakeSource");
expect(err.cause).to.be.undefined;
});
});
});

View file

@ -1,192 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { type TheMealDbMeal, theMealDbAdapter } from "../../src/sources/the-meal-db.js";
/**
* Stubs `globalThis.fetch` for one test no HTTP-mocking library exists
* in this codebase yet (this is the first module that talks to a real
* external network), and a single reassignable global covers the handful
* of call shapes this adapter needs without adding a new dependency.
* Restored by the `afterEach` below regardless of which test used it.
*/
function stubFetch(body: unknown, status = 200) {
globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch;
}
const baseMeal: TheMealDbMeal = {
idMeal: "52795",
strMeal: "Chicken Handi",
strMealThumb: "https://www.themealdb.com/images/media/meals/wyxwsp1486979827.jpg",
strInstructions: "Step one.\r\nStep two.\r\n\r\nStep three.",
strIngredient1: "Chicken",
strMeasure1: "1 kg",
strIngredient2: " ",
strMeasure2: "2 tbsp",
strIngredient3: "Onion",
strMeasure3: "",
};
describe("theMealDbAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an official source, with a key/name/icon", () => {
expect(theMealDbAdapter.key).to.equal("theMealDb");
expect(theMealDbAdapter.name).to.equal("TheMealDB");
expect(theMealDbAdapter.official).to.equal(true);
expect(theMealDbAdapter.iconUrl).to.be.a("string");
});
describe("list", () => {
it("maps search results into RecipeSourceListItems", async () => {
stubFetch({
meals: [
{ idMeal: "1", strMeal: "Test Meal", strMealThumb: "https://example.test/thumb.jpg" },
],
});
const result = await theMealDbAdapter.list({ query: "test" });
expect(result.items).to.deep.equal([
{
externalId: "1",
title: "Test Meal",
picture: "https://example.test/thumb.jpg",
url: "https://www.themealdb.com/meal/1",
},
]);
expect(result.nextCursor).to.be.null;
});
it("returns an empty list when the API responds with meals: null", async () => {
stubFetch({ meals: null });
const result = await theMealDbAdapter.list({ query: "doesnotexist" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("skips a meal with no name rather than surfacing a titleless item", async () => {
stubFetch({ meals: [{ idMeal: "1", strMeal: null, strMealThumb: null }] });
const result = await theMealDbAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetch({}, 500);
try {
await theMealDbAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await theMealDbAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("returns the first meal from the lookup response", async () => {
stubFetch({ meals: [baseMeal] });
const result = await theMealDbAdapter.fetchDetail("52795");
expect(result).to.deep.equal(baseMeal);
});
it("throws RecipeSourceFetchError when no meal matches the id", async () => {
stubFetch({ meals: null });
try {
await theMealDbAdapter.fetchDetail("999999");
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
}
});
});
describe("parse", () => {
it("maps name/picture/sourceUrl and splits instructions into steps", () => {
const parsed = theMealDbAdapter.parse(baseMeal);
expect(parsed.name).to.equal("Chicken Handi");
expect(parsed.description).to.be.null;
expect(parsed.picture).to.equal(baseMeal.strMealThumb);
expect(parsed.portions).to.be.null;
expect(parsed.sourceUrl).to.equal("https://www.themealdb.com/meal/52795");
expect(parsed.steps).to.deep.equal([
{ description: "Step one.", picture: null },
{ description: "Step two.", picture: null },
{ description: "Step three.", picture: null },
]);
});
it("skips blank ingredient slots and keeps the measure alongside the name in rawText", () => {
const parsed = theMealDbAdapter.parse(baseMeal);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "1 kg Chicken", quantity: null, unit: null, name: "Chicken" },
{ rawText: "Onion", quantity: null, unit: null, name: "Onion" },
]);
});
it("drops lone step-number lines instead of turning them into bogus steps (issue #52)", () => {
const parsed = theMealDbAdapter.parse({
...baseMeal,
strInstructions:
"For the caramel, melt the sugar.\r\n\r\n2\r\n\r\nPreheat the oven.\r\n\r\n3\r\n\r\nBake it.",
});
expect(parsed.steps).to.deep.equal([
{ description: "For the caramel, melt the sugar.", picture: null },
{ description: "Preheat the oven.", picture: null },
{ description: "Bake it.", picture: null },
]);
});
it("throws RecipeSourceParseError when the meal has no name", () => {
expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw(
RecipeSourceParseError,
);
});
it("throws RecipeSourceParseError when there are no usable instructions", () => {
expect(() =>
theMealDbAdapter.parse({ ...baseMeal, strInstructions: " \r\n\r\n " }),
).to.throw(RecipeSourceParseError);
});
it("throws RecipeSourceParseError when instructions are null", () => {
expect(() => theMealDbAdapter.parse({ ...baseMeal, strInstructions: null })).to.throw(
RecipeSourceParseError,
);
});
});
});

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