Compare commits
No commits in common. "main" and "feat/cooking-optimizer-metadata" have entirely different histories.
main
...
feat/cooki
355 changed files with 2508 additions and 22524 deletions
19
.env.example
19
.env.example
|
|
@ -15,14 +15,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|||
# (docker-compose.yml), serving both the API and the built frontend.
|
||||
# APP_PORT=3000
|
||||
|
||||
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
|
||||
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
|
||||
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
|
||||
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
|
||||
# building locally instead (`docker compose build`, or `up` with no image
|
||||
# present) — see docker-compose.yml's `image:`/`build:` comment.
|
||||
# IMAGE_TAG=latest
|
||||
|
||||
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
||||
# All optional: an instance that doesn't run the admin app needs none of
|
||||
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
||||
|
|
@ -40,17 +32,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||
# ADMIN_INITIAL_NAME=Ops
|
||||
#
|
||||
# Optional — lets an admin open a Forgejo ticket from a contact message
|
||||
# (apps/web's /admin/messages, issue #81). All three needed together: the
|
||||
# button stays hidden and the endpoint answers 503 unless all are set to a
|
||||
# non-empty value. docker-compose.yml forwards these into the "app"
|
||||
# container, so setting them here is enough (a blank value counts as
|
||||
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
|
||||
# scope — generate it in Forgejo, never commit the real value.
|
||||
# FORGEJO_BASE_URL=https://git.example.com
|
||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
||||
# FORGEJO_REPO=owner/repo
|
||||
|
||||
# Optional — only set this to false if THIS deployment is served over
|
||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||
|
|
|
|||
171
.github/workflows/ci.yml
vendored
171
.github/workflows/ci.yml
vendored
|
|
@ -39,13 +39,6 @@ env:
|
|||
# services/tech-step-intent-service/Dockerfile from this checkout.
|
||||
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
|
||||
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
|
||||
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
|
||||
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
|
||||
# in those tests, so no real request ever leaves CI; these values only need
|
||||
# to parse (a valid URL and an `owner/repo`).
|
||||
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
|
||||
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
|
||||
FORGEJO_REPO: "ci/batchcooking"
|
||||
|
||||
jobs:
|
||||
# Five independent jobs, no needs: between them — each starts in parallel
|
||||
|
|
@ -68,70 +61,17 @@ jobs:
|
|||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
|
||||
# spent queued in "Wait for any earlier test job" below. Also a runaway
|
||||
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
|
||||
# leaked handle would otherwise hang the job until the runner's global
|
||||
# timeout (a `test` job zombied ~1h once). If a hung job hits this
|
||||
# ceiling the runner removes its container, which unblocks the queue.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Install curl and the docker CLI
|
||||
run: |
|
||||
apt-get update && apt-get install -y curl docker.io
|
||||
|
||||
# Serialize `test` across every concurrent run. Two `test` jobs must
|
||||
# never overlap: they share one `--name postgres` container on the
|
||||
# runner's shared docker network (next step), so their `prisma migrate
|
||||
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
|
||||
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
|
||||
# pending migrations" when there should be some, `tech_step`/`diet` id
|
||||
# shifts breaking unrelated assertions; and even past that they starve
|
||||
# each other's RAM/CPU (each also trains a spaCy model) until
|
||||
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
|
||||
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
|
||||
# re-trigger once the runner was idle.
|
||||
#
|
||||
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
|
||||
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
|
||||
# 1.26 and is silently ignored here. A lock object (network, container,
|
||||
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
|
||||
# trailing `if: always()` step after an earlier step fails, so there is
|
||||
# no reliable in-job release. Instead, key off the one thing the runner
|
||||
# *does* always clean up — the job container itself (removed on pass,
|
||||
# fail, and cancel alike): wait while any lower-numbered `test` job's
|
||||
# container is still running. Lower task id goes first, so two jobs
|
||||
# that start together can't deadlock; nothing to release.
|
||||
- name: Wait for any earlier test job
|
||||
run: |
|
||||
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
|
||||
echo "this test job is task ${my_id:-<unknown, not serializing>}"
|
||||
# ~25 min cap (75 * 20s): comfortably covers one earlier run
|
||||
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
|
||||
# wait exceeds this just fails and gets an empty-commit re-run.
|
||||
for attempt in $(seq 1 75); do
|
||||
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
|
||||
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
|
||||
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
|
||||
if [ -z "$earlier" ]; then
|
||||
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
|
||||
exit 0
|
||||
fi
|
||||
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
|
||||
sleep 20
|
||||
done
|
||||
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
|
||||
exit 1
|
||||
|
||||
# Postgres as a plain background container instead of a `services:`
|
||||
# block — see this workflow's top-level `env.DATABASE_URL` comment for
|
||||
# why. Reachable as `postgres` from this job (the runner mounts the
|
||||
# docker socket and runs jobs on a shared user-defined network, so a
|
||||
# `--name postgres` sibling resolves by name); the network is
|
||||
# discovered from the job container itself rather than hardcoded. The
|
||||
# `docker rm -f postgres` below also clears a container left behind by
|
||||
# the previous run (this runner has no reliable post-job cleanup), and
|
||||
# the serialization step above means no other run is using it now.
|
||||
# discovered from the job container itself rather than hardcoded.
|
||||
- name: Start Postgres in the background
|
||||
run: |
|
||||
net="$(docker inspect "$(hostname)" \
|
||||
|
|
@ -139,15 +79,6 @@ jobs:
|
|||
| awk '{print $1}')"
|
||||
echo "job network: ${net:?could not determine the job container network}"
|
||||
docker rm -f postgres >/dev/null 2>&1 || true
|
||||
# Also drop any `postgres-<suffix>` container. A since-reverted
|
||||
# attempt at per-run Postgres naming on this branch left some
|
||||
# running on this persistent runner (their `if: always()` teardown
|
||||
# never fired — this runner skips trailing steps after a failure),
|
||||
# and nothing else removes them. A second idle Postgres here starves
|
||||
# the runner's RAM and slows every query ~5x → `resetDatabase()`
|
||||
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
|
||||
# more, so this only ever clears that debris.
|
||||
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker run -d --name postgres --network "$net" \
|
||||
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
|
||||
postgres:16-alpine
|
||||
|
|
@ -190,14 +121,11 @@ jobs:
|
|||
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
|
||||
# `/health` only returns 200 once this service has finished
|
||||
# training itself from scratch (no model ever persisted to disk —
|
||||
# see its own README) — measured at ~3min combined (fr+en) against
|
||||
# the current ~74-technique corpus, down from ~930s once
|
||||
# `spacy.TextCatBOW.v3`'s `length` stopped being left at its
|
||||
# 262144 default (see `_TEXTCAT_MODEL_LENGTH` in
|
||||
# `locale_pipeline.py`, and docker-compose.yml's healthcheck for
|
||||
# the same reasoning). The timeout stays well above that: it's a
|
||||
# ceiling for a slow/contended runner, not a target.
|
||||
timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
|
||||
# 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
|
||||
|
|
@ -272,90 +200,3 @@ jobs:
|
|||
# `component.devServer`), unlike `e2e` above which needs the real app
|
||||
# running first.
|
||||
- run: pnpm --filter web cy:run:component
|
||||
|
||||
# Builds and packages a signed, downloadable Android APK on every push to
|
||||
# `main` — issue #64. `needs:` on every other job is what actually makes
|
||||
# this "build, TEST, and package" rather than just "build and package in
|
||||
# parallel with tests that might still be failing": Forgejo/GitHub Actions
|
||||
# has no native way for a job in a *different* workflow file to block on
|
||||
# one here, so this has to live in this same file to get a real dependency
|
||||
# rather than a same-push coincidence. `if:` (not a separate trigger) keeps
|
||||
# every other job running on every branch as today — only this one is
|
||||
# main-only.
|
||||
#
|
||||
# No dedicated Android test suite exists (zero native code beyond the
|
||||
# Capacitor-generated shell) — "testée" is satisfied by gating on the 5
|
||||
# existing jobs, not by inventing an instrumented test suite for this.
|
||||
#
|
||||
# Unverified on this runner as of writing: no Android build has ever run
|
||||
# on this self-hosted Forgejo `act` runner (unlike lint/test/build/e2e,
|
||||
# exercised for months). If SDK installation turns out too slow/heavy here,
|
||||
# cache `~/.android/sdk`/`~/.gradle` (same idea as the Cypress binary cache
|
||||
# in `e2e` above) rather than accepting a slow job on every push.
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test, intent-service-test, build, e2e]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
# Matches what Android Gradle Plugin 8.x (bundled with the
|
||||
# Capacitor-generated project, see apps/web/android/build.gradle)
|
||||
# expects — bump alongside AGP if that version ever moves.
|
||||
java-version: "21"
|
||||
|
||||
- uses: https://github.com/android-actions/setup-android@v3
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Unlike the normal web build (served same-origin as the API), a
|
||||
# packaged Capacitor build runs from the WebView's own local origin and
|
||||
# needs an absolute, public HTTPS URL baked in at build time — see
|
||||
# apps/web/.env.capacitor.example's own comment on this.
|
||||
- name: Build le bundle web pour Capacitor
|
||||
env:
|
||||
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
|
||||
run: pnpm --filter web build:capacitor
|
||||
- run: pnpm --filter web exec cap sync android
|
||||
|
||||
# Le keystore de release est un secret généré une fois à la main (voir
|
||||
# le README, section "Application mobile") — jamais régénéré ici, un
|
||||
# nouveau keystore casserait la mise à jour de l'appli déjà installée
|
||||
# (Android refuse une signature différente de celle déjà installée).
|
||||
- name: Décode le keystore de release
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
|
||||
|
||||
- name: Build l'APK signé
|
||||
working-directory: apps/web/android
|
||||
env:
|
||||
ANDROID_KEYSTORE_PATH: release.keystore
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
# chmod défensif : le dev de ce repo se fait sous Windows, où le bit
|
||||
# exécutable git de gradlew ne veut rien dire et peut être reperdu
|
||||
# silencieusement par un futur commit — ne pas dépendre uniquement
|
||||
# du mode suivi par git (voir aussi le fix de ce mode lui-même).
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease
|
||||
|
||||
# v4+ exige l'API "Results" de GitHub, que ce runner Forgejo self-hosted
|
||||
# ne supporte pas ("GHESNotSupportedError") — v3 utilise encore
|
||||
# l'ancienne API Artifacts (compatible), seule version utilisable ici.
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
with:
|
||||
name: batchcooking-release.apk
|
||||
path: apps/web/android/app/build/outputs/apk/release/app-release.apk
|
||||
|
|
|
|||
239
.github/workflows/release.yml
vendored
239
.github/workflows/release.yml
vendored
|
|
@ -1,243 +1,44 @@
|
|||
name: Release
|
||||
|
||||
# On a `vX.Y.Z` tag: build the three service images and push them to this
|
||||
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
|
||||
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
|
||||
# and `latest`), build+attach a signed Android APK, publish a Forgejo
|
||||
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
|
||||
# pull those prebuilt images (docker-compose.yml references them via
|
||||
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
|
||||
# keeps a `build:` fallback next to every `image:`.
|
||||
# No image registry involved: Portainer is wired to this repo's Git remote
|
||||
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
|
||||
# that the image actually builds at the tagged commit, publishes a GitHub
|
||||
# Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
|
||||
# have to wait for its own polling interval.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
|
||||
# (the `build-and-push-images` job below `docker login`s with it). If a
|
||||
# future instance policy scopes the run token tighter, swap that login's
|
||||
# password for a `write:package` PAT added as a repo secret.
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
# Build the three service images at the tagged commit and push them to the
|
||||
# Forgejo container registry. A successful push also *is* the "does it
|
||||
# still build" check the old `sanity-build` job did (which only covered
|
||||
# apps/api). One sequential job, not a per-image matrix: three concurrent
|
||||
# `docker build`s hammer this single persistent runner's Docker daemon
|
||||
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
|
||||
# for no real gain on a release, which is rare and usually runs alone.
|
||||
build-and-push-images:
|
||||
sanity-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This runner's job containers don't come with docker preinstalled —
|
||||
# same reason ci.yml's `test` job has to do this before its own
|
||||
# `docker run` calls.
|
||||
- name: Install docker
|
||||
run: apt-get update && apt-get install -y docker.io
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# Build only, no push, no registry — just confirms Portainer will be
|
||||
# able to build this same Dockerfile successfully at this tag before
|
||||
# anyone points it at this ref.
|
||||
- run: docker build -f apps/api/Dockerfile .
|
||||
|
||||
- name: Build et push des 3 images vers le registre Forgejo
|
||||
env:
|
||||
# The per-run token Forgejo Actions mints — has `packages: write`
|
||||
# via this workflow's `permissions:` block. `docker login` accepts
|
||||
# it as the password for the actor who pushed the tag.
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
|
||||
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
|
||||
# registry namespace is the owner, lower-cased (Forgejo package
|
||||
# names are case-insensitive but the CLI wants a stable form).
|
||||
registry="${GITHUB_SERVER_URL#http*://}"
|
||||
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
|
||||
tag="$GITHUB_REF_NAME"
|
||||
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
|
||||
|
||||
# Every Dockerfile's build context is the repo root (see
|
||||
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
|
||||
build_push() {
|
||||
image="$registry/$owner/$1"
|
||||
echo "::group::$1"
|
||||
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
|
||||
docker push "$image:$tag"
|
||||
docker push "$image:latest"
|
||||
echo "::endgroup::"
|
||||
}
|
||||
build_push batchcooking-app apps/api/Dockerfile
|
||||
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
|
||||
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
|
||||
|
||||
# Builds the same signed APK as ci.yml's build-android job (issue #64),
|
||||
# with one difference: the version baked into the APK is derived from the
|
||||
# tag itself rather than left at build.gradle's default. Duplicated here
|
||||
# rather than factored into a shared `workflow_call` reusable workflow —
|
||||
# deliberate: reusable-workflow support on this self-hosted Forgejo/act
|
||||
# runner is unverified, and this ~15-line recipe is proven working as-is
|
||||
# in ci.yml; not worth risking a new class of runner-compatibility surprise
|
||||
# (this repo has already hit several — see ci-forgejo-runner-constraints
|
||||
# in project memory) to save a little duplication. Keep both copies in
|
||||
# sync if the Android build steps ever change.
|
||||
build-android-apk:
|
||||
github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: sanity-build
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- uses: https://github.com/android-actions/setup-android@v3
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build le bundle web pour Capacitor
|
||||
env:
|
||||
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
|
||||
run: pnpm --filter web build:capacitor
|
||||
- run: pnpm --filter web exec cap sync android
|
||||
|
||||
- name: Décode le keystore de release
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
|
||||
|
||||
- name: Dérive versionName/versionCode depuis le tag
|
||||
# GITHUB_REF_NAME = "v1.4.2" pour un tag `v1.4.2` — versionName =
|
||||
# "1.4.2" (sans le `v`), versionCode = majeur*10000 + mineur*100 +
|
||||
# correctif (ex. 1.4.2 -> 10402), un entier strictement croissant à
|
||||
# chaque release comme Android l'exige (jamais réutilisé, jamais
|
||||
# décroissant) tant que chaque partie reste sous 100 (largement
|
||||
# suffisant ici). Exposé aux steps suivants via $GITHUB_ENV plutôt
|
||||
# que recalculé.
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME#v}"
|
||||
major="$(echo "$tag" | cut -d. -f1)"
|
||||
minor="$(echo "$tag" | cut -d. -f2)"
|
||||
patch="$(echo "$tag" | cut -d. -f3)"
|
||||
# Préfixe 10# : force une lecture en base 10, sinon bash lirait
|
||||
# un composant à zéro non significatif (ex. "08") comme de
|
||||
# l'octal invalide et échouerait.
|
||||
code=$((10#$major * 10000 + 10#$minor * 100 + 10#$patch))
|
||||
echo "Version dérivée du tag $GITHUB_REF_NAME : versionName=$tag versionCode=$code"
|
||||
echo "ANDROID_VERSION_NAME=$tag" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_VERSION_CODE=$code" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build l'APK signé
|
||||
working-directory: apps/web/android
|
||||
env:
|
||||
ANDROID_KEYSTORE_PATH: release.keystore
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease -PandroidVersionName="$ANDROID_VERSION_NAME" -PandroidVersionCode="$ANDROID_VERSION_CODE"
|
||||
|
||||
# Nom de fichier stable, jamais versionné : la page d'accueil (#65)
|
||||
# pointe vers /releases/latest (Forgejo n'a pas l'équivalent du
|
||||
# raccourci GitHub /releases/latest/download/<fichier>, seul
|
||||
# /releases/latest lui-même résout) — un nom stable évite au moins que
|
||||
# l'utilisateur voie un nom de fichier différent à chaque release une
|
||||
# fois sur la page.
|
||||
- name: Renomme l'APK sous un nom stable
|
||||
run: cp apps/web/android/app/build/outputs/apk/release/app-release.apk batchcooking.apk
|
||||
|
||||
# v4+ exige l'API "Results" de GitHub, non supportée par ce Forgejo
|
||||
# self-hosted ("GHESNotSupportedError") — v3 (ancienne API Artifacts)
|
||||
# est la seule utilisable ici, voir ci.yml's build-android pour la
|
||||
# même contrainte déjà rencontrée.
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
with:
|
||||
name: batchcooking.apk
|
||||
path: batchcooking.apk
|
||||
|
||||
forgejo-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-and-push-images, build-android-apk]
|
||||
steps:
|
||||
# Ce runner ne fournit ni docker ni curl par défaut dans ses
|
||||
# conteneurs de job — même constat déjà fait pour docker (voir
|
||||
# build-and-push-images) et pour curl dans ci.yml's test job.
|
||||
- name: Install curl
|
||||
run: apt-get update && apt-get install -y curl
|
||||
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: https://github.com/actions/download-artifact@v3
|
||||
with:
|
||||
name: batchcooking.apk
|
||||
|
||||
# softprops/action-gh-release (l'action GitHub habituelle pour ça)
|
||||
# échoue systématiquement contre ce Forgejo : sa requête de création
|
||||
# de release répond 405 (vérifié en conditions réelles, tag v0.2.0 —
|
||||
# pas un souci de ref bare comme les autres actions de ce fichier,
|
||||
# une vraie incompatibilité d'API entre les deux plateformes qui n'a
|
||||
# pas de contournement connu côté configuration de l'action). Appelle
|
||||
# directement l'API native de Forgejo plutôt qu'une action tierce
|
||||
# dont la compatibilité GitHub-Forgejo n'est pas garantie.
|
||||
#
|
||||
# Pas d'équivalent à `generate_release_notes` ici (fonctionnalité
|
||||
# propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la
|
||||
# release reste minimal, cohérent avec l'absence d'outillage de
|
||||
# changelog ailleurs dans le repo.
|
||||
- name: Crée (ou récupère) la Release Forgejo
|
||||
id: create-release
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"
|
||||
body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")"
|
||||
|
||||
# POST pour créer la release, code HTTP capturé à part. Si le tag est
|
||||
# déjà connu de Forgejo (tag poussé à la main avant ce workflow, ou
|
||||
# ré-exécution du job), l'API répond 409 : on réutilise alors la
|
||||
# release existante au lieu d'échouer — idempotence, cf. l'incident
|
||||
# du tag v0.2.2 poussé en avance qui a fait tomber ce job sur un 409.
|
||||
code="$(curl -sS -o /tmp/release.json -w '%{http_code}' -X POST "$api" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body")"
|
||||
|
||||
if [ "$code" = "409" ]; then
|
||||
echo "Release $GITHUB_REF_NAME déjà existante (HTTP 409) — récupération de son id."
|
||||
curl -fsS "$api/tags/$GITHUB_REF_NAME" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" -o /tmp/release.json
|
||||
elif [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
||||
echo "Échec de création de la Release (HTTP $code) : $(cat /tmp/release.json)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
id="$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)"
|
||||
if [ -z "$id" ]; then
|
||||
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Release $GITHUB_REF_NAME prête (id=$id)"
|
||||
echo "release-id=$id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attache l'APK à la Release
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/${{ steps.create-release.outputs.release-id }}/assets"
|
||||
curl -fsS -X POST "$api?name=batchcooking.apk" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
-F "attachment=@batchcooking.apk"
|
||||
# Auto-generated from merged PRs since the previous tag — no
|
||||
# changelog tooling to maintain, consistent with there being no
|
||||
# versioning tooling elsewhere in the repo yet.
|
||||
generate_release_notes: true
|
||||
|
||||
notify-portainer:
|
||||
runs-on: ubuntu-latest
|
||||
needs: forgejo-release
|
||||
needs: github-release
|
||||
steps:
|
||||
- name: Trigger Portainer redeploy
|
||||
# Best-effort: does nothing (and doesn't fail the workflow) until
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -70,7 +70,6 @@ web_modules/
|
|||
.env.*
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
!.env.capacitor.example
|
||||
|
||||
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
|
||||
# is otherwise all-Node — see that service's own .gitignore for the rest;
|
||||
|
|
@ -134,11 +133,6 @@ dist
|
|||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# JetBrains IDEs (Android Studio opens this repo as a project once the
|
||||
# Capacitor Android app exists under apps/web/android — see that folder's own
|
||||
# .gitignore for the generated native project's build artefacts).
|
||||
/.idea/
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
|
|
|
|||
98
README.md
98
README.md
|
|
@ -207,18 +207,10 @@ depuis la même origine. Les deux autres services du fichier
|
|||
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
||||
au host — voir leurs propres README pour leur rôle.
|
||||
|
||||
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les
|
||||
trois images de service — `batchcooking-app`, `batchcooking-intent-service`,
|
||||
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo**
|
||||
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`)
|
||||
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
|
||||
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
|
||||
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
|
||||
images pré-construites, `docker compose up`/`build` retombe sur un build local si
|
||||
elles sont absentes. L'instance **Portainer** de production peut donc désormais
|
||||
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
|
||||
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
|
||||
deux modes fonctionnent avec le même `docker-compose.yml`.
|
||||
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||||
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
||||
donc aucune image nulle part.
|
||||
|
||||
### Release (`.github/workflows/release.yml`)
|
||||
|
||||
|
|
@ -229,80 +221,14 @@ git tag vX.Y.Z
|
|||
git push --tags
|
||||
```
|
||||
|
||||
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à
|
||||
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push
|
||||
réussi vaut aussi vérification que tout build encore à ce commit),
|
||||
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release`
|
||||
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis
|
||||
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si
|
||||
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se
|
||||
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du
|
||||
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
|
||||
dépôt `PORTAINER_WEBHOOK_URL`.
|
||||
|
||||
## Application mobile (Android)
|
||||
|
||||
`apps/web` est empaqueté dans une coquille [Capacitor](https://capacitorjs.com/)
|
||||
pour Android — le projet natif est versionné dans `apps/web/android/` (issue
|
||||
\#39). L'authentification passe par un jeton Bearer stocké dans le stockage
|
||||
sécurisé de Capacitor (le cookie de session `SameSite=Lax` ne part pas depuis
|
||||
l'origine interne d'une WebView), et `main.tsx` bascule sur `HashRouter` quand
|
||||
`Capacitor.isNativePlatform()` est vrai.
|
||||
|
||||
**Prérequis** : Android Studio (SDK Android + un JDK ; le JBR fourni avec
|
||||
Android Studio suffit). Aucun serveur Postgres/Docker local n'est nécessaire —
|
||||
l'appli parle à l'API HTTPS publique.
|
||||
|
||||
**Build** — depuis `apps/web/` :
|
||||
|
||||
```bash
|
||||
# 1. URL publique de l'API, gravée dans le bundle empaqueté (le build web
|
||||
# normal utilise une URL relative « même origine », impossible ici).
|
||||
cp .env.capacitor.example .env.capacitor # puis renseigner VITE_API_URL
|
||||
|
||||
# 2. Build web en mode capacitor + synchronisation dans le projet natif.
|
||||
pnpm build:capacitor
|
||||
npx cap sync android
|
||||
|
||||
# 3. Ouvrir dans Android Studio (build/run/émulateur depuis l'IDE)…
|
||||
npx cap open android
|
||||
# …ou build/run en ligne de commande si un appareil/émulateur est prêt :
|
||||
npx cap run android
|
||||
```
|
||||
|
||||
Le bundle web copié (`apps/web/android/app/src/main/assets/public/`) est
|
||||
git-ignoré : il est régénéré par `pnpm build:capacitor && npx cap sync android`.
|
||||
Les icônes et le splash sont générés depuis `apps/web/assets/logo.svg` (voir
|
||||
`apps/web/assets/README.md`). iOS est hors périmètre pour l'instant (nécessite
|
||||
Xcode/macOS).
|
||||
|
||||
**CI — build automatique d'un APK signé** (`.github/workflows/ci.yml`, job
|
||||
`build-android`, issue #64) : à chaque push sur `main`, une fois les 5 autres
|
||||
jobs verts, un APK release signé est construit et déposé comme artefact de
|
||||
run téléchargeable. Secrets du dépôt Forgejo requis (Réglages du dépôt →
|
||||
Secrets) :
|
||||
|
||||
| Secret | Contenu |
|
||||
|---|---|
|
||||
| `MOBILE_API_URL` | URL HTTPS publique de l'API — même valeur que `VITE_API_URL` dans `.env.capacitor` |
|
||||
| `ANDROID_KEYSTORE_BASE64` | Le keystore de release (`.jks`) encodé en base64 |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | Mot de passe du keystore |
|
||||
| `ANDROID_KEY_ALIAS` | Alias de la clé dans le keystore |
|
||||
| `ANDROID_KEY_PASSWORD` | Mot de passe de la clé |
|
||||
|
||||
Le keystore doit être généré **une seule fois**, à la main, et rester stable
|
||||
pour toujours : Android refuse d'installer la mise à jour d'une appli signée
|
||||
par une clé différente de celle déjà installée.
|
||||
|
||||
```bash
|
||||
keytool -genkeypair -v -keystore batchcooking-release.jks \
|
||||
-alias batchcooking -keyalg RSA -keysize 2048 -validity 10000
|
||||
base64 -w0 batchcooking-release.jks # → coller comme secret ANDROID_KEYSTORE_BASE64
|
||||
```
|
||||
|
||||
Conserver le fichier `.jks` et ses mots de passe en lieu sûr en dehors du
|
||||
dépôt (jamais commité — voir `apps/web/android/.gitignore`) : le perdre
|
||||
empêche de publier toute future mise à jour de l'appli.
|
||||
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
|
||||
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
||||
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
||||
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
|
||||
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
|
||||
est configuré (sinon Portainer se resynchronise simplement à son prochain
|
||||
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
||||
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
||||
|
||||
## Auth (apps/api)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|||
# Optional — defaults shown (see src/config/env.ts)
|
||||
# JWT_EXPIRES_IN=7d
|
||||
# AUTH_COOKIE_NAME=session
|
||||
# CORS_ORIGIN=http://localhost:5173,https://localhost
|
||||
# CORS_ORIGIN=http://localhost:5173
|
||||
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
||||
|
||||
# Required — services/tech-step-intent-service must be running locally (see
|
||||
|
|
@ -35,12 +35,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||
# ADMIN_INITIAL_NAME=Ops
|
||||
|
||||
# Only needed to create a Forgejo issue from a contact message
|
||||
# (apps/web's /admin/messages, issue #81). The button is hidden and the
|
||||
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
|
||||
# personal access token with the write:issue scope — generate it in Forgejo,
|
||||
# never commit the real value.
|
||||
# FORGEJO_BASE_URL=https://git.example.com
|
||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
||||
# FORGEJO_REPO=owner/repo
|
||||
|
|
|
|||
|
|
@ -60,11 +60,8 @@ COPY --from=build /repo/apps/web/dist ./apps/web/dist
|
|||
|
||||
WORKDIR /repo/apps/api
|
||||
EXPOSE 3000
|
||||
# Applies pending migrations, seeds the reference data (Diet/Category/
|
||||
# Allergy — see src/scripts/seed-runtime.ts), then runs any pending one-time
|
||||
# data backfill (src/scripts/backfill-runtime.ts) before starting. migrate
|
||||
# deploy only applies pending migrations and the seed upserts by unique
|
||||
# name, so both are safe to repeat every start; the backfill step gates
|
||||
# itself on a `backfill_state` row (no-op once done) and is written never to
|
||||
# fail the boot — see its doc comment.
|
||||
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/scripts/backfill-runtime.js && node dist/server.js"]
|
||||
# Applies pending migrations, then seeds the reference data (Diet/Category/
|
||||
# Allergy — see src/scripts/seed-runtime.ts) before starting. Both steps are
|
||||
# safe to repeat on every container start: migrate deploy only applies
|
||||
# pending migrations, and the seed upserts by unique name.
|
||||
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js"]
|
||||
|
|
|
|||
|
|
@ -23,12 +23,10 @@
|
|||
"@batch-cooking/shared": "workspace:*",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"argon2": "0.31.2",
|
||||
"cookie": "^2.0.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"prisma": "^5.22.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -40,7 +38,6 @@
|
|||
"chai": "^5.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"mocha": "^10.8.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
-- CreateTable: idempotency ledger for one-time data backfills run from the
|
||||
-- production image's start command (apps/api/Dockerfile CMD ->
|
||||
-- src/scripts/backfill-runtime.ts). One row per completed backfill, keyed
|
||||
-- by an opaque backfill name; presence of the row is the "already done,
|
||||
-- don't repeat" signal. See the `BackfillState` model doc comment.
|
||||
CREATE TABLE "backfill_state" (
|
||||
"key" TEXT NOT NULL,
|
||||
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "backfill_state_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
-- Splits "portions to prepare" from "portions actually needed" on a
|
||||
-- planning slot, and adds the two columns a leftovers-serving item needs
|
||||
-- (its source item + how much of its own pool was thrown away). Purely
|
||||
-- additive — no existing column renamed. See the `PlanningItem` model doc
|
||||
-- comments and issue #34.
|
||||
|
||||
-- `discarded_portions` and `source_planning_item_id` are trivial adds.
|
||||
ALTER TABLE "planning_item"
|
||||
ADD COLUMN "discarded_portions" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "source_planning_item_id" INTEGER;
|
||||
|
||||
-- `portions_requested` is NOT NULL but the table may have rows already:
|
||||
-- add it nullable, backfill, then lock it down — same discipline as the
|
||||
-- `ingredient_unit_catalog` / `planning_item_portions` migrations. Every
|
||||
-- pre-existing row predates this feature, so it had no leftovers:
|
||||
-- requested == prepared at the time it was added.
|
||||
ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER;
|
||||
UPDATE "planning_item" SET "portions_requested" = "portions";
|
||||
ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL;
|
||||
|
||||
-- Self-FK for a "leftovers" item pointing back at the item it draws from.
|
||||
-- ON DELETE CASCADE: removing the source item removes everything served
|
||||
-- from its pool too (same as the Planning -> PlanningItem cascade).
|
||||
ALTER TABLE "planning_item"
|
||||
ADD CONSTRAINT "planning_item_source_planning_item_id_fkey"
|
||||
FOREIGN KEY ("source_planning_item_id") REFERENCES "planning_item"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
-- Guided-tour scenarios, their steps, the catalogue of valid highlight
|
||||
-- targets, and per-user "seen" progress. All four tables are new — plain
|
||||
-- CREATEs, nothing to backfill. See issues #42 (the tour engine) and #44
|
||||
-- (admin CRUD + the DB-backed model this migration implements, which
|
||||
-- revises #42's originally-planned in-code registry).
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tour_target" (
|
||||
"key" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "tour_target_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tour_scenario" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "tour_scenario_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tour_scenario_step" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"scenario_id" INTEGER NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"route" TEXT,
|
||||
"target_key" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "tour_scenario_step_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tour_progress" (
|
||||
"user_profile_id" INTEGER NOT NULL,
|
||||
"scenario_id" INTEGER NOT NULL,
|
||||
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tour_progress_pkey" PRIMARY KEY ("user_profile_id","scenario_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tour_scenario_key_key" ON "tour_scenario"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tour_scenario_step_scenario_id_order_key" ON "tour_scenario_step"("scenario_id", "order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_target_key_fkey" FOREIGN KEY ("target_key") REFERENCES "tour_target"("key") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
-- Contact-form messages (issue #80). New table only — nothing to backfill.
|
||||
-- `author_id` is nullable with ON DELETE SET NULL so a report survives its
|
||||
-- sender's account deletion (same as ingredients.created_by_id).
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "contact_message" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"author_id" INTEGER,
|
||||
"type" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"page_url" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"app_version" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "contact_message_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contact_message_created_at_idx" ON "contact_message"("created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contact_message" ADD CONSTRAINT "contact_message_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- Link a contact message to the Forgejo issue an admin created from it
|
||||
-- (issue #81). Two nullable columns, no backfill — existing rows have no
|
||||
-- linked issue.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "contact_message"
|
||||
ADD COLUMN "forgejo_issue_number" INTEGER,
|
||||
ADD COLUMN "forgejo_issue_url" TEXT;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- Ingrédients d'assaisonnement « selon le goût » (issue #128, puce 1 de #3) :
|
||||
-- une ligne de recette peut désormais n'avoir ni quantité ni unité. Purement
|
||||
-- additif — toutes les lignes existantes gardent leurs valeurs, aucune n'est
|
||||
-- réécrite.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL;
|
||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "unit_id" DROP NOT NULL;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
-- Régimes d'une recette calculés depuis ses ingrédients (issue #131, puce 4
|
||||
-- de #3) — `RecipeDiet` (tag manuel) est supprimé : `RecipeSummaryView.diets`
|
||||
-- est désormais dérivé de `IngredientDiet` + de l'allergène `gluten` côté
|
||||
-- lecture (voir `computeRecipeDietViews`). Aucune donnée à conserver.
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "recipe_diet";
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
-- AlterTable: off-catalog technique "placeholder" rows (issue #130, puce 3
|
||||
-- de #3) — a user names a technique the catalog is missing while correcting a
|
||||
-- step. Every existing row is a real seeded catalog entry, so the flag
|
||||
-- defaults to false and the four new nullable columns stay NULL for them —
|
||||
-- no backfill needed. Mirrors the `ingredients` placeholder migration.
|
||||
ALTER TABLE "tech_step" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "tech_step" ADD COLUMN "display_name" TEXT;
|
||||
ALTER TABLE "tech_step" ADD COLUMN "created_by_id" INTEGER;
|
||||
ALTER TABLE "tech_step" ADD COLUMN "created_at" TIMESTAMP(3);
|
||||
ALTER TABLE "tech_step" ADD COLUMN "reviewed_at" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tech_step_is_placeholder_idx" ON "tech_step"("is_placeholder");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tech_step" ADD CONSTRAINT "tech_step_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -49,6 +49,7 @@ model Diet {
|
|||
key String @unique
|
||||
|
||||
users UserProfile[]
|
||||
recipes RecipeDiet[]
|
||||
ingredients IngredientDiet[]
|
||||
|
||||
@@map("diet")
|
||||
|
|
@ -124,9 +125,6 @@ model UserProfile {
|
|||
/// list regardless of that real-world cardinality.
|
||||
administeredHouses House[] @relation("HouseAdmin")
|
||||
preferences UserPreference?
|
||||
/// Guided-tour scenarios this profile has seen (completed or skipped) —
|
||||
/// see {@link TourProgress} and issues #42 / #44.
|
||||
tourProgress TourProgress[]
|
||||
/// Tech-step corrections this profile has submitted (any profile that can
|
||||
/// view a recipe may correct its tech-step matches, not just its author —
|
||||
/// see `StepTechStepCorrection.correctorId`).
|
||||
|
|
@ -134,11 +132,6 @@ model UserProfile {
|
|||
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
||||
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
||||
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
||||
/// Placeholder `TechStep` rows this profile proposed while correcting a step
|
||||
/// — see `TechStep.isPlaceholder`.
|
||||
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
|
||||
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
||||
contactMessages ContactMessage[]
|
||||
|
||||
@@map("user_profiles")
|
||||
}
|
||||
|
|
@ -220,41 +213,17 @@ model Planning {
|
|||
}
|
||||
|
||||
model PlanningItem {
|
||||
id Int @id @default(autoincrement())
|
||||
planningId Int @map("planning_id")
|
||||
weekDay String @map("week_day")
|
||||
id Int @id @default(autoincrement())
|
||||
planningId Int @map("planning_id")
|
||||
weekDay String @map("week_day")
|
||||
meal String
|
||||
recipeId Int @map("recipe_id")
|
||||
/// Portions actually *prepared* for this slot — always a whole multiple
|
||||
/// of `recipe.portions` (a dish is cooked in whole batches). Derived from
|
||||
/// `portionsRequested` + the recipe's yield via `computeBatchedPortions`
|
||||
/// (`@batch-cooking/shared`) in `addPlanningItem`, never sent raw by the
|
||||
/// client. On a "leftovers" item (`sourcePlanningItemId` set) it always
|
||||
/// equals `portionsRequested`.
|
||||
recipeId Int @map("recipe_id")
|
||||
portions Int
|
||||
/// How many portions this slot must actually feed. For a fresh item
|
||||
/// (`sourcePlanningItemId` null), `<= portions` — the gap is this item's
|
||||
/// leftover pool. For a "leftovers" item, always `== portions` (serving
|
||||
/// leftovers prepares nothing new, so it can't generate more — no
|
||||
/// leftovers-on-leftovers).
|
||||
portionsRequested Int @map("portions_requested")
|
||||
/// Non-null only on a "leftovers" item: the item whose leftover pool it
|
||||
/// draws from. `onDelete: Cascade` — deleting the source item also
|
||||
/// deletes everything served from its leftovers (same reasoning as the
|
||||
/// existing `Planning` -> `PlanningItem` cascade).
|
||||
sourcePlanningItemId Int? @map("source_planning_item_id")
|
||||
/// Portions of *this* item's leftover pool explicitly thrown away (the
|
||||
/// basket's "jeter" button). Always 0 on a "leftovers" item (its own
|
||||
/// pool is null by construction).
|
||||
discardedPortions Int @default(0) @map("discarded_portions")
|
||||
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||
sourcePlanningItem PlanningItem? @relation("PlanningItemLeftoverSource", fields: [sourcePlanningItemId], references: [id], onDelete: Cascade)
|
||||
/// The "leftovers" items served from this item's own pool — inverse of `sourcePlanningItem`.
|
||||
leftoverUses PlanningItem[] @relation("PlanningItemLeftoverSource")
|
||||
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||
|
||||
@@map("planning_item")
|
||||
}
|
||||
|
|
@ -377,6 +346,7 @@ model Recipe {
|
|||
steps Step[]
|
||||
planningItems PlanningItem[]
|
||||
favoritedBy RecipeFavorite[]
|
||||
diets RecipeDiet[]
|
||||
|
||||
@@unique([sourceId, externalId])
|
||||
@@map("recipe")
|
||||
|
|
@ -397,6 +367,21 @@ model RecipeFavorite {
|
|||
@@map("recipe_favorite")
|
||||
}
|
||||
|
||||
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
||||
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
||||
/// manual reminder set by whoever creates/edits the recipe, not computed
|
||||
/// from its ingredients.
|
||||
model RecipeDiet {
|
||||
recipeId Int @map("recipe_id")
|
||||
dietId Int @map("diet_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([recipeId, dietId])
|
||||
@@map("recipe_diet")
|
||||
}
|
||||
|
||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
||||
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
||||
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
||||
|
|
@ -684,19 +669,14 @@ model Unit {
|
|||
/// quantity per recipe, so this join table carries quantity + unit
|
||||
/// (project decision, not in the original spec doc).
|
||||
model RecipeIngredient {
|
||||
recipeId Int @map("recipe_id")
|
||||
ingredientId Int @map("ingredient_id")
|
||||
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
|
||||
/// salt, pepper… added to taste, with nothing to scale by portions or to
|
||||
/// aggregate on a shopping list. The "both null or neither" invariant is
|
||||
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
|
||||
/// the schema — same posture as this file's other cross-field invariants.
|
||||
quantity Decimal? @db.Decimal(10, 2)
|
||||
unitId Int? @map("unit_id")
|
||||
recipeId Int @map("recipe_id")
|
||||
ingredientId Int @map("ingredient_id")
|
||||
quantity Decimal @db.Decimal(10, 2)
|
||||
unitId Int @map("unit_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
unit Unit? @relation(fields: [unitId], references: [id])
|
||||
unit Unit @relation(fields: [unitId], references: [id])
|
||||
|
||||
@@id([recipeId, ingredientId])
|
||||
@@map("recipe_ingredient")
|
||||
|
|
@ -721,33 +701,6 @@ model TechStep {
|
|||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
|
||||
/// `true` = a "placeholder" technique: a free-text technique a user typed
|
||||
/// while correcting a step, because nothing in the seeded catalog matched
|
||||
/// (issue #131 for the ingredient precedent this mirrors — see
|
||||
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
|
||||
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
|
||||
/// training data of its own, and is excluded from
|
||||
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
|
||||
/// like any other technique (the correction that created it applies
|
||||
/// immediately), but a maintainer reviews it at
|
||||
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
|
||||
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
|
||||
isPlaceholder Boolean @default(false) @map("is_placeholder")
|
||||
/// The exact text the user typed — `null` for a real catalog row (whose
|
||||
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
|
||||
/// `isPlaceholder`, enforced service-side.
|
||||
displayName String? @map("display_name")
|
||||
/// Profile that first proposed this technique — context for the admin
|
||||
/// review. `onDelete: SetNull` so deleting an account never blocks on the
|
||||
/// recipes still using its placeholder. `null` for a real catalog row.
|
||||
createdById Int? @map("created_by_id")
|
||||
/// When this placeholder was proposed. `null` for a real catalog row.
|
||||
createdAt DateTime? @map("created_at")
|
||||
/// Stamped once a maintainer has triaged this proposal
|
||||
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
|
||||
/// `null` while pending / for a real catalog row.
|
||||
reviewedAt DateTime? @map("reviewed_at")
|
||||
|
||||
steps StepTechStep[]
|
||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||
|
|
@ -758,10 +711,7 @@ model TechStep {
|
|||
/// Training-corpus suggestions targeting this technique — see
|
||||
/// `TechStepTrainingSuggestion`.
|
||||
trainingSuggestions TechStepTrainingSuggestion[]
|
||||
/// The profile that proposed this row when it is a placeholder — see `createdById`.
|
||||
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([isPlaceholder])
|
||||
@@map("tech_step")
|
||||
}
|
||||
|
||||
|
|
@ -1017,7 +967,7 @@ model StepTechStepCorrection {
|
|||
///
|
||||
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
||||
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
||||
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
|
||||
/// 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 {
|
||||
|
|
@ -1114,145 +1064,3 @@ model WorkerHeartbeat {
|
|||
|
||||
@@map("worker_heartbeats")
|
||||
}
|
||||
|
||||
/// Idempotency ledger for one-time data backfills run from the production
|
||||
/// image's start command (`src/scripts/backfill-runtime.ts`, invoked in
|
||||
/// apps/api/Dockerfile's CMD after `prisma migrate deploy` + `seed-runtime`).
|
||||
///
|
||||
/// The CMD re-runs on every container start, but a backfill here *rewrites*
|
||||
/// existing rows (unlike `seed-runtime`, which only upserts and is safe to
|
||||
/// repeat) — e.g. `step_tech_step_metadata_20260904` recomputes every
|
||||
/// `StepTechStep` sequence against the intent service. So each backfill
|
||||
/// gates itself on a row in this table: `key` present → already done, skip;
|
||||
/// absent → run it, then insert the row. A schema migration only ever
|
||||
/// creates the table structure; it can't run TypeScript against the intent
|
||||
/// service, which is why this is a start-command step and not a migration.
|
||||
model BackfillState {
|
||||
key String @id
|
||||
completedAt DateTime @default(now()) @map("completed_at")
|
||||
|
||||
@@map("backfill_state")
|
||||
}
|
||||
|
||||
/// Catalogue des cibles de surlignage valides pour un didacticiel guidé —
|
||||
/// même esprit que `TechStep`/`Unit`/`Diet` : une valeur de référence
|
||||
/// maintenue par le code, seedée (`reference-seed-data.ts`). Chaque `key`
|
||||
/// correspond à un attribut `data-tour-<key>` réellement posé quelque part
|
||||
/// dans le frontend ; un admin choisit parmi ces cibles en construisant un
|
||||
/// scénario, il ne peut pas en inventer une (poser un nouveau `data-tour-*`
|
||||
/// reste un changement de code + une ligne seedée ici). Voir issues #42/#44.
|
||||
model TourTarget {
|
||||
key String @id
|
||||
/// Libellé pour aider l'admin à choisir dans le formulaire, ex.
|
||||
/// "Bouton + d'un créneau du planning".
|
||||
description String
|
||||
|
||||
steps TourScenarioStep[]
|
||||
|
||||
@@map("tour_target")
|
||||
}
|
||||
|
||||
/// Un scénario de didacticiel — une séquence d'étapes surlignées, rejouable
|
||||
/// et éditable depuis l'admin (#44). `key` est une clé stable (comme
|
||||
/// `Diet.key`/`Source.key`) : `"general"` / `"techStepMetadata"` pour les
|
||||
/// deux scénarios seedés, libre pour un scénario créé par un admin.
|
||||
model TourScenario {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
/// Libellé admin, ex. "Tour d'orientation".
|
||||
name String
|
||||
/// Désactiver sans supprimer — `GET /reference/tours` ne renvoie que les
|
||||
/// scénarios activés.
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
steps TourScenarioStep[]
|
||||
progress TourProgress[]
|
||||
|
||||
@@map("tour_scenario")
|
||||
}
|
||||
|
||||
/// Une étape d'un scénario : (si `route` est défini) navigue vers cette
|
||||
/// route, puis surligne l'élément portant `data-tour-<targetKey>` avec un
|
||||
/// popover `title`/`content`. `title`/`content` sont du **texte libre**
|
||||
/// saisi par l'admin, pas des clés i18n (#44 : le chrome de l'UI vient
|
||||
/// d'i18next, le contenu administré dynamiquement est du texte, comme le
|
||||
/// nom d'une recette).
|
||||
model TourScenarioStep {
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioId Int @map("scenario_id")
|
||||
order Int
|
||||
/// Route React Router à ouvrir avant cette étape ; `null` = rester sur la
|
||||
/// route de l'étape précédente.
|
||||
route String?
|
||||
targetKey String @map("target_key")
|
||||
title String
|
||||
content String
|
||||
|
||||
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
target TourTarget @relation(fields: [targetKey], references: [key])
|
||||
|
||||
@@unique([scenarioId, order])
|
||||
@@map("tour_scenario_step")
|
||||
}
|
||||
|
||||
/// Présence d'une ligne = ce profil a *vu* ce scénario. Le terminer **ou**
|
||||
/// le passer volontairement crée la ligne (pas de re-nag après un skip
|
||||
/// délibéré). Révise la version de #42 (`tourKey: String` libre) en une
|
||||
/// vraie FK vers `TourScenario` (#44).
|
||||
model TourProgress {
|
||||
userProfileId Int @map("user_profile_id")
|
||||
scenarioId Int @map("scenario_id")
|
||||
completedAt DateTime @default(now()) @map("completed_at")
|
||||
|
||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userProfileId, scenarioId])
|
||||
@@map("tour_progress")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Contact
|
||||
// In-app "report a bug / suggestion" form — user-submitted, read-only for
|
||||
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
|
||||
// into a Forgejo issue).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// A message sent via the app's contact form (bug, suggestion, other).
|
||||
/// Written once by a signed-in user, never edited afterwards — consulted
|
||||
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
|
||||
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
|
||||
/// even if the account that sent it is deleted in the meantime.
|
||||
model ContactMessage {
|
||||
id Int @id @default(autoincrement())
|
||||
authorId Int? @map("author_id")
|
||||
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
|
||||
/// `CONTACT_MESSAGE_TYPES` const (same approach as
|
||||
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
|
||||
/// value is one line, no Postgres enum migration.
|
||||
type String
|
||||
message String
|
||||
/// Technical context captured automatically at submit time, for triage —
|
||||
/// all nullable (an older client, or a direct API call, can omit them).
|
||||
pageUrl String? @map("page_url")
|
||||
userAgent String? @map("user_agent")
|
||||
appVersion String? @map("app_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
/// Number of the Forgejo issue an admin created from this message via
|
||||
/// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the
|
||||
/// "Créer un ticket" button disappears (one message links to at most one
|
||||
/// issue).
|
||||
forgejoIssueNumber Int? @map("forgejo_issue_number")
|
||||
/// The issue's html URL (`<base>/<owner>/<repo>/issues/<n>`) — stored
|
||||
/// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays
|
||||
/// correct even if that config changes later.
|
||||
forgejoIssueUrl String? @map("forgejo_issue_url")
|
||||
|
||||
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@map("contact_message")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ import { errorLogger } from "./middlewares/error-logger.js";
|
|||
import { requestLogger } from "./middlewares/request-logger.js";
|
||||
import { adminRouter } from "./modules/admin/admin.routes.js";
|
||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||
import { contactRouter } from "./modules/contact/contact.routes.js";
|
||||
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
||||
import { houseRouter } from "./modules/house/house.routes.js";
|
||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
|
||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||
|
|
@ -36,11 +34,10 @@ export function createServer(): ExpressServer {
|
|||
// pipeline (its "finish" listener still fires for a request that never
|
||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
||||
server.addMiddleware(requestLogger);
|
||||
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
|
||||
// config/env.ts for why the packaged Android app's origin must always be
|
||||
// one of them). The admin surface (`/admin/*`) is served by this same API
|
||||
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
|
||||
// rest of the app, so no extra CORS entry.
|
||||
// One allowed origin: the app (`CORS_ORIGIN`). The admin surface
|
||||
// (`/admin/*`) is served by this same API and consumed by `apps/web`'s
|
||||
// own `/admin/*` routes — same origin as the rest of the app, so no
|
||||
// extra CORS entry.
|
||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||
|
||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||
|
|
@ -48,33 +45,17 @@ export function createServer(): ExpressServer {
|
|||
});
|
||||
|
||||
server.mountRouter("/auth", authRouter);
|
||||
// Admin application surface — its own auth (`requireAdmin`, distinct
|
||||
// cookie/secret), never the end-user session. Mounted at `/admin/api`,
|
||||
// *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too
|
||||
// (`/admin/monitoring`, `/admin/login`...), and this API is served from
|
||||
// the same origin/process as that frontend (see `serveStaticFrontend`
|
||||
// below). A bare `/admin` mount put `GET /admin/monitoring` (API) and the
|
||||
// page route `/admin/monitoring` in direct conflict — Express matches by
|
||||
// path alone, so a hard navigation/refresh on that page was answered by
|
||||
// this router's own handler (raw JSON) instead of falling through to the
|
||||
// SPA's `index.html`. The `/api` segment guarantees no current or future
|
||||
// admin page path can ever collide with an admin API path again.
|
||||
server.mountRouter("/admin/api", adminRouter);
|
||||
// Admin application surface (`apps/web`'s `/admin/*` routes) — its own
|
||||
// auth (`requireAdmin`, distinct cookie/secret), never the end-user
|
||||
// session.
|
||||
server.mountRouter("/admin", adminRouter);
|
||||
server.mountRouter("/house", houseRouter);
|
||||
// In-app "signaler un bug / suggestion" form (issue #80) — session
|
||||
// required, one POST. Admins read these rows via `/admin/api/contact`,
|
||||
// never this router.
|
||||
server.mountRouter("/contact", contactRouter);
|
||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||
// `requireInternalWorker` on every route within (see that router's own
|
||||
// doc comment), never `requireAuth`. Mounted alongside the other routers
|
||||
// rather than nested under one of them since it isn't scoped to a single
|
||||
// recipe/step the way `recipeRouter`'s own correction routes are.
|
||||
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
|
||||
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
|
||||
// mobile.service.ts's doc comment for why this can't just be called
|
||||
// directly from the browser.
|
||||
server.mountRouter("/mobile", mobileRouter);
|
||||
server.mountRouter("/planning", planningRouter);
|
||||
server.mountRouter("/preferences", preferencesRouter);
|
||||
server.mountRouter("/profile", profileRouter);
|
||||
|
|
|
|||
|
|
@ -13,26 +13,12 @@ import { z } from "zod";
|
|||
// separate test database this now requires.
|
||||
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
||||
|
||||
/**
|
||||
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
|
||||
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
|
||||
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
|
||||
* is `.dockerignore`d, so that passthrough is the container's only source
|
||||
* of these. Fields that must *fail closed* on a blank value rather than run
|
||||
* their own format check against `""` (and crash `parse()` at boot) wrap
|
||||
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
|
||||
* folds the same `"" -> undefined` step into its own `.transform`.
|
||||
*/
|
||||
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
|
||||
|
||||
/**
|
||||
* Schema for every environment variable the API reads. Parsing (below)
|
||||
* fails fast at startup if something required is missing/invalid, instead
|
||||
* of surfacing as a confusing runtime error later. Exported so the parsing
|
||||
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
|
||||
* without the process-wide `process.env` the singleton below parses.
|
||||
* of surfacing as a confusing runtime error later.
|
||||
*/
|
||||
export const envSchema = z.object({
|
||||
const envSchema = z.object({
|
||||
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
/** Port the HTTP server listens on. */
|
||||
|
|
@ -48,42 +34,8 @@ export const envSchema = z.object({
|
|||
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||
/** Name of the httpOnly cookie carrying the session JWT. */
|
||||
AUTH_COOKIE_NAME: z.string().default("session"),
|
||||
/**
|
||||
* Origin(s) allowed by CORS, comma-separated — parsed below into a
|
||||
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
|
||||
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
|
||||
* package) and reused as-is for the Socket.IO handshake's own CORS config
|
||||
* (`server.ts`, which needs its own since the WS upgrade never runs
|
||||
* through `setupCore`'s middleware).
|
||||
*
|
||||
* Must always include `https://localhost`: the packaged Android app's
|
||||
* WebView is served from exactly that fixed origin (Capacitor's default
|
||||
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
|
||||
* doc comment), and every request it makes to this API — `/auth/login`
|
||||
* and `/auth/signup` included — is genuinely cross-origin, unlike
|
||||
* apps/web's *browser* build, which is served from the very same origin
|
||||
* as this API in production (see docker-compose.yml's `app` service) and
|
||||
* so never needs an entry here at all. Missing this origin doesn't fail
|
||||
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
|
||||
* for an origin it's about to reject (it only *omits*
|
||||
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
|
||||
* looks identical to a normal one right up until the browser silently
|
||||
* drops the real `POST` — surfacing client-side as a generic "unexpected
|
||||
* error" with no 401/403 anywhere to point at the actual cause. Default
|
||||
* covers both the Vite dev server and the packaged app out of the box; a
|
||||
* real deployment's own web origin (e.g. a production domain) never needs
|
||||
* adding here for the reason above, but the env var still allows
|
||||
* extending/overriding this list (comma-separated) if that ever changes.
|
||||
*/
|
||||
CORS_ORIGIN: z
|
||||
.string()
|
||||
.default("http://localhost:5173,https://localhost")
|
||||
.transform((value) =>
|
||||
value
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0),
|
||||
),
|
||||
/** Origin allowed by CORS — must match wherever apps/web is served from. */
|
||||
CORS_ORIGIN: z.string().default("http://localhost:5173"),
|
||||
/**
|
||||
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
||||
* alongside the API. Optional, no default — only set inside the
|
||||
|
|
@ -157,26 +109,6 @@ export const envSchema = z.object({
|
|||
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
||||
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
||||
ADMIN_INITIAL_NAME: z.string().optional(),
|
||||
/**
|
||||
* Forgejo instance + credentials for "create an issue from a contact
|
||||
* message" (`/admin/messages`, issue #81). All three `.optional()` and
|
||||
* checked together by `ForgejoService.isConfigured()` — the feature
|
||||
* **fails closed** when any is missing: the endpoint answers `503` and
|
||||
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
|
||||
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
|
||||
* rest of the app never talks to Forgejo.
|
||||
*/
|
||||
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()),
|
||||
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
|
||||
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()),
|
||||
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
|
||||
FORGEJO_REPO: z.preprocess(
|
||||
blankAsUnset,
|
||||
z
|
||||
.string()
|
||||
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
||||
.optional(),
|
||||
),
|
||||
});
|
||||
|
||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||
|
|
|
|||
|
|
@ -191,114 +191,6 @@ export const UTENSILS: string[] = [
|
|||
"mandoline",
|
||||
];
|
||||
|
||||
// Guided-tour highlight targets (issues #42/#44). Each `key` is the suffix
|
||||
// of a `data-tour-<key>` attribute the frontend puts on the element the
|
||||
// tour points at; `description` is the hint an admin sees when picking a
|
||||
// target in the scenario editor. Reference data maintained here, like
|
||||
// `TECH_STEPS`/`UTENSILS` — adding a real new target needs a frontend
|
||||
// `data-tour-*` *and* a line here.
|
||||
export const TOUR_TARGETS: Array<{ key: string; description: string }> = [
|
||||
{ key: "nav-planning", description: "Lien « Planning » de la barre latérale" },
|
||||
{ key: "nav-recipes", description: "Lien « Recettes » de la barre latérale" },
|
||||
{ key: "nav-shopping-list", description: "Lien « Liste de courses » de la barre latérale" },
|
||||
{ key: "planning-grid", description: "La grille du planning de la semaine" },
|
||||
{ key: "planning-add-recipe", description: "Bouton « + » d'un créneau du planning" },
|
||||
{ key: "planning-cook-button", description: "Bouton « Cuisiner » de l'en-tête du planning" },
|
||||
{ key: "shopping-list-summary", description: "La liste de courses générée pour la semaine" },
|
||||
{
|
||||
key: "techstep-description",
|
||||
description: "Le texte d'une étape de recette, avec les techniques surlignées",
|
||||
},
|
||||
{
|
||||
key: "techstep-technique-picker",
|
||||
description: "Le sélecteur de technique du popover de correction",
|
||||
},
|
||||
];
|
||||
|
||||
// The two tours filed in issue #42, as the DB-backed scenarios #44 defines.
|
||||
// Only *created* if absent (an admin owns name/enabled/steps afterwards —
|
||||
// see `seedTourScenarios`); `content`/`title` are plain French text, not
|
||||
// i18n keys (#44). The second tour is deliberately short for now — issue
|
||||
// #36 (correction popover closes mid-selection) is still open, so v1 only
|
||||
// covers the gestures that already work (select text, pick a technique).
|
||||
export const TOUR_SCENARIOS: Array<{
|
||||
key: string;
|
||||
name: string;
|
||||
steps: Array<{ route: string | null; targetKey: string; title: string; content: string }>;
|
||||
}> = [
|
||||
{
|
||||
key: "general",
|
||||
name: "Tour d'orientation",
|
||||
steps: [
|
||||
{
|
||||
route: "/",
|
||||
targetKey: "nav-planning",
|
||||
title: "Votre semaine, ici",
|
||||
content: "Le planning est votre point de départ : ce que vous mangez, jour par jour.",
|
||||
},
|
||||
{
|
||||
route: null,
|
||||
targetKey: "nav-recipes",
|
||||
title: "Vos recettes",
|
||||
content:
|
||||
"Retrouvez vos recettes, importez-en depuis d'autres sites, et filtrez selon vos préférences.",
|
||||
},
|
||||
{
|
||||
route: null,
|
||||
targetKey: "nav-shopping-list",
|
||||
title: "La liste de courses",
|
||||
content:
|
||||
"Générée automatiquement à partir de tout ce que vous avez planifié pour la semaine.",
|
||||
},
|
||||
{
|
||||
route: null,
|
||||
targetKey: "planning-grid",
|
||||
title: "Sept jours, cinq repas",
|
||||
content: "Chaque case est un créneau. Ajoutez-y des recettes, ajustez les portions.",
|
||||
},
|
||||
{
|
||||
route: null,
|
||||
targetKey: "planning-add-recipe",
|
||||
title: "Ajouter une recette",
|
||||
content: "Ce bouton ouvre le sélecteur de recettes pour remplir un créneau.",
|
||||
},
|
||||
{
|
||||
route: null,
|
||||
targetKey: "planning-cook-button",
|
||||
title: "Passer en cuisine",
|
||||
content:
|
||||
"Une fois la semaine planifiée, ce bouton réorganise tout en une séance de cuisine optimisée.",
|
||||
},
|
||||
{
|
||||
route: "/liste-de-courses",
|
||||
targetKey: "shopping-list-summary",
|
||||
title: "Tout ce qu'il vous faut",
|
||||
content: "Les ingrédients de toutes vos recettes de la semaine, regroupés et additionnés.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "techStepMetadata",
|
||||
name: "Corriger les techniques d'une recette",
|
||||
steps: [
|
||||
{
|
||||
route: null,
|
||||
targetKey: "techstep-description",
|
||||
title: "Les techniques repérées",
|
||||
content:
|
||||
"Les mots surlignés sont des techniques de cuisson détectées automatiquement. Sélectionnez un passage pour le corriger.",
|
||||
},
|
||||
{
|
||||
route: null,
|
||||
targetKey: "techstep-technique-picker",
|
||||
title: "Choisir la bonne technique",
|
||||
content:
|
||||
"Indiquez la technique correcte pour le passage sélectionné — la correction est immédiate.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||
// businesses to declare — a standard, defensible reference list rather than
|
||||
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
||||
|
|
@ -1524,47 +1416,4 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
|||
if (dietLinks.length > 0) {
|
||||
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
|
||||
}
|
||||
|
||||
await seedTourScenarios(prisma);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the guided-tour reference data (issues #42/#44):
|
||||
*
|
||||
* - `TourTarget` rows — upserted by key with `update: { description }`, same
|
||||
* as `Unit` above: a reseed must correct an edited hint, not just skip.
|
||||
* - `TourScenario` + its steps — **create-if-absent only**. Once a scenario
|
||||
* exists, an admin owns its `name`/`enabled`/steps (that's the whole
|
||||
* point of #44's CRUD), so a later deploy's reseed must never clobber
|
||||
* those. In the test suite every `resetDatabase()` truncates first, so
|
||||
* the defaults are always recreated fresh there.
|
||||
*/
|
||||
async function seedTourScenarios(prisma: PrismaClient): Promise<void> {
|
||||
for (const { key, description } of TOUR_TARGETS) {
|
||||
await prisma.tourTarget.upsert({
|
||||
where: { key },
|
||||
update: { description },
|
||||
create: { key, description },
|
||||
});
|
||||
}
|
||||
|
||||
for (const { key, name, steps } of TOUR_SCENARIOS) {
|
||||
const existing = await prisma.tourScenario.findUnique({ where: { key } });
|
||||
if (existing) continue;
|
||||
await prisma.tourScenario.create({
|
||||
data: {
|
||||
key,
|
||||
name,
|
||||
steps: {
|
||||
create: steps.map((step, index) => ({
|
||||
order: index,
|
||||
route: step.route,
|
||||
targetKey: step.targetKey,
|
||||
title: step.title,
|
||||
content: step.content,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../logger.service.js";
|
||||
|
||||
/** The slice of Forgejo's `POST .../issues` response this integration reads. */
|
||||
interface ForgejoIssueResponse {
|
||||
number: number;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place `apps/api` talks to Forgejo — creating an issue from a
|
||||
* contact message (`/admin/messages`, issue #81). A class + singleton
|
||||
* (`forgejo` below), same convention as `logger` / `analytics`.
|
||||
*
|
||||
* **Fail-closed**: every method is a no-op / typed error unless all three
|
||||
* `FORGEJO_*` env vars are set (see `config/env.ts`). Callers check
|
||||
* {@link isConfigured} first and answer `503` themselves; {@link createIssue}
|
||||
* re-checks as a backstop.
|
||||
*/
|
||||
export class ForgejoService {
|
||||
/** True once `FORGEJO_BASE_URL`, `FORGEJO_TOKEN` and `FORGEJO_REPO` are all set. */
|
||||
public isConfigured(): boolean {
|
||||
return (
|
||||
env.FORGEJO_BASE_URL !== undefined &&
|
||||
env.FORGEJO_TOKEN !== undefined &&
|
||||
env.FORGEJO_REPO !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an issue in `FORGEJO_REPO` with the given title/body (no labels
|
||||
* — a deliberate #81 decision, the admin adds them in Forgejo). Returns
|
||||
* the new issue's number and html URL.
|
||||
*
|
||||
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` if the env isn't set,
|
||||
* `502 FORGEJO_REQUEST_FAILED` on a network error or any non-2xx / non-JSON
|
||||
* response — the caller leaves the database untouched in that case.
|
||||
*/
|
||||
public async createIssue(input: {
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<{ number: number; htmlUrl: string }> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
ErrorCode.FORGEJO_NOT_CONFIGURED,
|
||||
"Forgejo integration is not configured",
|
||||
);
|
||||
}
|
||||
const url = `${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Forgejo/Gitea's PAT scheme — `token <pat>`, not `Bearer`.
|
||||
Authorization: `token ${env.FORGEJO_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ title: input.title, body: input.body }),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Forgejo issue creation failed (network)", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw new HttpError(502, ErrorCode.FORGEJO_REQUEST_FAILED, "Forgejo request failed");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
logger.warn("Forgejo issue creation failed", {
|
||||
status: response.status,
|
||||
detail: detail.slice(0, 500),
|
||||
});
|
||||
throw new HttpError(
|
||||
502,
|
||||
ErrorCode.FORGEJO_REQUEST_FAILED,
|
||||
`Forgejo issues API responded ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = (await response.json()) as ForgejoIssueResponse;
|
||||
return { number: data.number, htmlUrl: data.html_url };
|
||||
} catch (err) {
|
||||
logger.warn("Forgejo issue creation failed (bad response)", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw new HttpError(
|
||||
502,
|
||||
ErrorCode.FORGEJO_REQUEST_FAILED,
|
||||
"Forgejo response was not valid JSON",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — stateless, same reasoning as `logger` / `analytics`. */
|
||||
export const forgejo = new ForgejoService();
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import type { MonitoringView } from "@batch-cooking/shared";
|
||||
import type { Server as SocketIoServer } from "socket.io";
|
||||
import { requireAdminSocket } from "../middlewares/require-admin-socket.js";
|
||||
import { getMonitoring } from "../modules/admin/admin-monitoring.service.js";
|
||||
import { logger } from "./logger.service.js";
|
||||
|
||||
/** How often the server re-probes every dependency, in ms — see `MonitoringBroadcastService`. */
|
||||
const PROBE_INTERVAL_MS = 60_000;
|
||||
|
||||
/** Every authenticated admin socket joins this one room — a single probe loop serves every open tab, instead of each polling independently. */
|
||||
const MONITORING_ROOM = "monitoring";
|
||||
|
||||
/** Event name every `monitoring:update` push carries — the sole message this service ever emits. */
|
||||
const UPDATE_EVENT = "monitoring:update";
|
||||
|
||||
/**
|
||||
* Pushes the admin monitoring status board to every connected admin over
|
||||
* WebSocket, replacing the old `GET /admin/api/monitoring` polling
|
||||
* endpoint entirely (removed — see `admin.routes.ts`'s doc comment): one
|
||||
* server-side probe loop, shared by every viewer, instead of each browser
|
||||
* tab re-probing Postgres/the API/the intent-service/the LLM worker on its
|
||||
* own timer.
|
||||
*
|
||||
* Owns the *whole* `socket.io` server passed to it (`io.use`, top-level
|
||||
* `"connection"` handler) — fine while this is the server's only
|
||||
* real-time feature; a second one added later should likely get its own
|
||||
* namespace rather than share this room's connection handler.
|
||||
*/
|
||||
export class MonitoringBroadcastService {
|
||||
private readonly _io: SocketIoServer;
|
||||
/**
|
||||
* Most recent probe result — replayed to a newly-joined admin
|
||||
* immediately (see the `"connection"` handler below), so a freshly
|
||||
* opened tab isn't left blank for up to a minute waiting for the next
|
||||
* scheduled tick. `null` until the very first probe (kicked off by
|
||||
* {@link start}) completes.
|
||||
*/
|
||||
private _lastSnapshot: MonitoringView | null = null;
|
||||
/** Handle for the running probe loop, or `null` before {@link start}/after {@link stop}. */
|
||||
private _intervalHandle: NodeJS.Timeout | null = null;
|
||||
|
||||
public constructor(io: SocketIoServer) {
|
||||
this._io = io;
|
||||
this._io.use(requireAdminSocket);
|
||||
this._io.on("connection", (socket) => {
|
||||
void socket.join(MONITORING_ROOM);
|
||||
if (this._lastSnapshot) {
|
||||
socket.emit(UPDATE_EVENT, this._lastSnapshot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the probe loop: one immediate probe (so the very first admin
|
||||
* to connect after a server restart doesn't wait a full minute), then
|
||||
* every {@link PROBE_INTERVAL_MS}. Call once, at server boot
|
||||
* (`server.ts`) — meant to run for the lifetime of the process, same
|
||||
* posture as the tech-step worker's own cron (`server.ts` never calls
|
||||
* {@link stop} today; the process exiting takes the interval with it).
|
||||
*/
|
||||
public start(): void {
|
||||
void this._tick();
|
||||
this._intervalHandle = setInterval(() => void this._tick(), PROBE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the probe loop. Nothing in production calls this today — it
|
||||
* exists so a graceful-shutdown hook could use it later, and so tests
|
||||
* can tear an instance down cleanly instead of leaking a live
|
||||
* `setInterval` past their own lifetime (which would otherwise hold the
|
||||
* whole Mocha process open).
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this._intervalHandle) {
|
||||
clearInterval(this._intervalHandle);
|
||||
this._intervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Probes every dependency, caches the result, and broadcasts it to {@link MONITORING_ROOM}. */
|
||||
private async _tick(): Promise<void> {
|
||||
try {
|
||||
this._lastSnapshot = await getMonitoring();
|
||||
this._io.to(MONITORING_ROOM).emit(UPDATE_EVENT, this._lastSnapshot);
|
||||
} catch (err) {
|
||||
// getMonitoring() itself never rejects — every individual probe is
|
||||
// already bounded/caught (see its own doc comment) — so this is
|
||||
// purely defensive, per the repo's await-in-try/catch rule; not
|
||||
// expected to actually fire.
|
||||
logger.error("Monitoring probe tick failed unexpectedly", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { TechStepMatch } from "./tech-step-matcher.js";
|
||||
|
||||
/**
|
||||
* Turns one step's {@link TechStepMatch}es into the Prisma nested-`create`
|
||||
* payload for its `techSteps` relation — the technique row **and** all three
|
||||
* of its clause-metadata child sets (`ingredients` / `utensils` /
|
||||
* `temperatures`).
|
||||
*
|
||||
* The single source of truth for that shape. It used to be written out by
|
||||
* hand in three places — `recipe.service.ts`'s `createRecipeInternal` and
|
||||
* `updateRecipe`, plus `src/scripts/backfill-tech-steps.ts` — and they had
|
||||
* drifted: only the create path wrote the metadata children, so **every
|
||||
* recipe resaved through the UI, and every recipe the backfill touched,
|
||||
* silently lost every ingredient / utensil / temperature the classifier had
|
||||
* attributed to its steps**. That left `cooking-optimizer.ts`'s
|
||||
* temperature-aware `preheat` grouping (the "don't preheat to 150 °C and
|
||||
* 200 °C at once" note) with no data to work from on any recipe that wasn't
|
||||
* a pristine, never-edited import. Building the payload once, here, is what
|
||||
* stops the three call sites diverging again.
|
||||
*
|
||||
* `order` is each match's index in `matches` (0-based). Callers pass matches
|
||||
* in reading order already; a caller that needs a different base (the
|
||||
* backfill, which offsets past untouched `"manual"` rows) overrides `order`
|
||||
* on the returned objects before handing them to Prisma.
|
||||
*/
|
||||
export function toStepTechStepCreateInputs(
|
||||
matches: TechStepMatch[],
|
||||
): Prisma.StepTechStepUncheckedCreateWithoutStepInput[] {
|
||||
return matches.map((match, order) => ({
|
||||
techStepId: match.techStepId,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
contextStart: match.contextStart,
|
||||
contextEnd: match.contextEnd,
|
||||
order,
|
||||
ingredients: {
|
||||
create: match.ingredients.map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity,
|
||||
unitId: ingredient.unitId,
|
||||
start: ingredient.start,
|
||||
end: ingredient.end,
|
||||
})),
|
||||
},
|
||||
utensils: {
|
||||
create: match.utensils.map((utensil) => ({
|
||||
utensilId: utensil.utensilId,
|
||||
start: utensil.start,
|
||||
end: utensil.end,
|
||||
})),
|
||||
},
|
||||
temperatures: {
|
||||
create: match.temperatures.map((temperature) => ({
|
||||
start: temperature.start,
|
||||
end: temperature.end,
|
||||
celsius: temperature.celsius,
|
||||
gasMark: temperature.gasMark,
|
||||
qualitative: temperature.qualitative,
|
||||
raw: temperature.raw,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { parseCookie } from "cookie";
|
||||
import type { Socket } from "socket.io";
|
||||
import { env } from "../config/env.js";
|
||||
import { resolveAdminFromToken } from "./require-admin.js";
|
||||
|
||||
/**
|
||||
* `socket.io` handshake middleware guarding the admin WebSocket surface —
|
||||
* the connection-level equivalent of {@link requireAdmin} (`require-
|
||||
* admin.ts`). A `socket.io` handshake is a plain HTTP request that never
|
||||
* runs through Express's own middleware stack (`cookie-parser` included),
|
||||
* so the raw `Cookie` header has to be parsed by hand here with the
|
||||
* `cookie` package (the same one `cookie-parser` itself wraps).
|
||||
*
|
||||
* Registered via `io.use(requireAdminSocket)` — `socket.io` calls every
|
||||
* `io.use` middleware once per connection attempt, before `"connection"`
|
||||
* fires, exactly like Express's per-request middleware chain. Calling
|
||||
* `next()` with no argument admits the connection; calling it with an
|
||||
* `Error` rejects it, which the client sees as a `connect_error` event
|
||||
* (`socket.io-client` never even fires `"connect"` for it).
|
||||
*
|
||||
* On success, stashes the resolved admin on `socket.data.adminUser` —
|
||||
* `socket.io`'s per-connection storage bag, the WebSocket equivalent of
|
||||
* `res.locals` — for any handler that ends up needing it later.
|
||||
*/
|
||||
export async function requireAdminSocket(
|
||||
socket: Socket,
|
||||
next: (err?: Error) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cookieHeader = socket.handshake.headers.cookie;
|
||||
const token = cookieHeader ? parseCookie(cookieHeader)[env.ADMIN_COOKIE_NAME] : undefined;
|
||||
if (typeof token !== "string") {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
socket.data.adminUser = await resolveAdminFromToken(token);
|
||||
next();
|
||||
} catch {
|
||||
// Covers everything resolveAdminFromToken can throw, plus a missing
|
||||
// cookie — same "never distinguish the reason" posture as requireAdmin.
|
||||
next(new Error("Not authenticated"));
|
||||
}
|
||||
}
|
||||
|
|
@ -19,50 +19,26 @@ export interface AdminLocals {
|
|||
}
|
||||
|
||||
/**
|
||||
* Verifies an admin session JWT and resolves the `AdminUser` it belongs
|
||||
* to — the transport-agnostic core both {@link requireAdmin} (HTTP) and
|
||||
* `requireAdminSocket` (`require-admin-socket.ts`, the WebSocket handshake
|
||||
* equivalent) build on; they only differ in *where* they read the raw
|
||||
* cookie value from (`req.cookies`, already parsed by `cookie-parser`, vs
|
||||
* a `socket.io` handshake's raw `Cookie` header, which isn't). Kept here
|
||||
* rather than in `lib/admin-jwt.ts`, since it also does the DB
|
||||
* `tokenVersion` re-check that file deliberately doesn't know about.
|
||||
*
|
||||
* @throws {Error} for any failure — malformed/expired JWT, unknown admin,
|
||||
* stale `tokenVersion`, or no `ADMIN_JWT_SECRET` configured (from
|
||||
* {@link verifyAdminToken}). Always the same generic message; callers
|
||||
* map it to their own transport's "unauthenticated" shape.
|
||||
*/
|
||||
export async function resolveAdminFromToken(token: string): Promise<AdminUserView> {
|
||||
const payload = verifyAdminToken(token);
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
|
||||
|
||||
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
return toSafeAdmin(admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware guarding every `/admin/api/*` route — the operations
|
||||
* UI (`apps/web`'s `/admin/*` pages) authenticating as an `AdminUser`.
|
||||
* Reads the admin session cookie (`ADMIN_COOKIE_NAME`, deliberately
|
||||
* **not** the same cookie as end-user sessions) and resolves it via
|
||||
* {@link resolveAdminFromToken}.
|
||||
* Express middleware guarding every `/admin/*` route — the operations UI
|
||||
* (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin
|
||||
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
|
||||
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
|
||||
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
|
||||
* against the database so a stateless JWT can still be invalidated
|
||||
* server-side.
|
||||
*
|
||||
* A completely separate mechanism from {@link requireAuth}, not layered on
|
||||
* it: an end-user session token and an admin session token are never
|
||||
* interchangeable in either direction.
|
||||
*
|
||||
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
|
||||
* that doesn't run the admin app) makes {@link resolveAdminFromToken}
|
||||
* throw, so every request is rejected rather than the surface left open —
|
||||
* same posture as `requireInternalWorker`.
|
||||
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
|
||||
* every request is rejected rather than the surface left open — same
|
||||
* posture as `requireInternalWorker`.
|
||||
*
|
||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||
* cookie or anything {@link resolveAdminFromToken} rejects on. Never
|
||||
* distinguishes the reason.
|
||||
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
|
||||
* no secret configured. Never distinguishes the reason.
|
||||
*/
|
||||
export async function requireAdmin(
|
||||
req: Request,
|
||||
|
|
@ -75,13 +51,20 @@ export async function requireAdmin(
|
|||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||
}
|
||||
|
||||
res.locals.adminUser = await resolveAdminFromToken(token);
|
||||
const payload = verifyAdminToken(token);
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
|
||||
|
||||
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
|
||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||
}
|
||||
|
||||
res.locals.adminUser = toSafeAdmin(admin);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
next(err);
|
||||
} else {
|
||||
// Covers everything resolveAdminFromToken can throw.
|
||||
// Covers jwt.verify failures and the unset-secret throw from verifyAdminToken.
|
||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,34 +13,16 @@ import { toSafeProfile } from "../lib/safe-profile.js";
|
|||
* typed, no cast needed.
|
||||
*/
|
||||
export interface AuthLocals {
|
||||
/** The authenticated profile, resolved from the session cookie's JWT (or an `Authorization: Bearer` one, for cookie-less clients). */
|
||||
/** The authenticated profile, resolved from the session cookie's JWT. */
|
||||
userProfile: SafeUserProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the raw JWT out of an `Authorization: Bearer <jwt>` header, or
|
||||
* returns `null` if it's absent or malformed. This is the fallback path for
|
||||
* clients that can't rely on the `session` cookie — a Capacitor WebView,
|
||||
* whose origin never receives a `SameSite=Lax` cookie (see `AuthResponse`
|
||||
* in packages/shared). The cookie remains the primary mechanism; this only
|
||||
* kicks in when the cookie isn't there.
|
||||
*/
|
||||
function readBearerToken(header: string | undefined): string | null {
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
const token = header.slice("Bearer ".length).trim();
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware guarding routes that require an authenticated
|
||||
* profile. Reads the session cookie (falling back to an `Authorization:
|
||||
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}),
|
||||
* verifies the JWT, and re-checks `tokenVersion` against the database — so a
|
||||
* stateless JWT can still be invalidated server-side (e.g. on password
|
||||
* change / logout-everywhere, once that feature exists) despite carrying no
|
||||
* server-side session.
|
||||
* profile. Reads the session cookie, verifies the JWT, and re-checks
|
||||
* `tokenVersion` against the database — so a stateless JWT can still be
|
||||
* invalidated server-side (e.g. on password change / logout-everywhere,
|
||||
* once that feature exists) despite carrying no server-side session.
|
||||
*
|
||||
* On success, attaches the resolved profile to `res.locals.userProfile`
|
||||
* (typed via {@link AuthLocals}) for downstream handlers to use.
|
||||
|
|
@ -51,9 +33,9 @@ function readBearerToken(header: string | undefined): string | null {
|
|||
* project-wide ambient augmentation silently changing every `Request` in
|
||||
* the codebase, whether or not it went through this middleware.
|
||||
*
|
||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie
|
||||
* and no usable Bearer header, malformed/expired JWT, unknown profile, or
|
||||
* stale tokenVersion. Never distinguishes the reason to the client.
|
||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
||||
* Never distinguishes the reason to the client.
|
||||
*/
|
||||
export async function requireAuth(
|
||||
req: Request,
|
||||
|
|
@ -61,11 +43,7 @@ export async function requireAuth(
|
|||
next: NextFunction,
|
||||
) {
|
||||
try {
|
||||
const cookieToken = req.cookies?.[env.AUTH_COOKIE_NAME];
|
||||
// Cookie first (the browser's mechanism, unchanged); `Authorization:
|
||||
// Bearer` as a fallback for cookie-less clients — see readBearerToken.
|
||||
const token =
|
||||
typeof cookieToken === "string" ? cookieToken : readBearerToken(req.headers.authorization);
|
||||
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
|
||||
if (typeof token !== "string") {
|
||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { env } from "../../config/env.js";
|
|||
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import { adminLogin } from "./admin-auth.service.js";
|
||||
|
||||
/** Router mounted at `/admin/api/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
|
||||
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
|
||||
export const adminAuthRouter = Router();
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
listPlaceholdersQuerySchema,
|
||||
listTechStepPlaceholdersQuerySchema,
|
||||
markPlaceholdersReviewedSchema,
|
||||
markTechStepPlaceholdersReviewedSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import {
|
||||
|
|
@ -12,17 +7,12 @@ import {
|
|||
markPlaceholdersReviewed,
|
||||
pruneOrphanPlaceholders,
|
||||
} from "./admin-catalog.service.js";
|
||||
import {
|
||||
listTechStepPlaceholders,
|
||||
markTechStepPlaceholdersReviewed,
|
||||
} from "./admin-tech-step-catalog.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
||||
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
|
||||
* typed when the seeded catalog fell short — free-text ingredients on a
|
||||
* recipe line, and free-text techniques while correcting a step (issue #130)
|
||||
* — so a maintainer can see what's missing and mark gaps as handled.
|
||||
* Router mounted at `/admin/catalog` (via `admin.routes.ts`) — every route
|
||||
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
|
||||
* "placeholders" users typed when the seeded catalog fell short, so a
|
||||
* maintainer can see what's missing and mark gaps as handled.
|
||||
*/
|
||||
export const adminCatalogRouter = Router();
|
||||
|
||||
|
|
@ -52,29 +42,3 @@ adminCatalogRouter.post(
|
|||
res.status(200).json(await pruneOrphanPlaceholders());
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Missing techniques (issue #130) --------------------------------------
|
||||
|
||||
adminCatalogRouter.get(
|
||||
"/tech-step-placeholders",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
res
|
||||
.status(200)
|
||||
.json(await listTechStepPlaceholders(listTechStepPlaceholdersQuerySchema.parse(req.query)));
|
||||
}),
|
||||
);
|
||||
|
||||
adminCatalogRouter.patch(
|
||||
"/tech-step-placeholders/mark-reviewed",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
res
|
||||
.status(200)
|
||||
.json(
|
||||
await markTechStepPlaceholdersReviewed(
|
||||
markTechStepPlaceholdersReviewedSchema.parse(req.body),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
createForgejoIssueSchema,
|
||||
ErrorCode,
|
||||
listContactMessagesQuerySchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import {
|
||||
createForgejoIssueFromMessage,
|
||||
getForgejoStatus,
|
||||
listContactMessages,
|
||||
} from "./admin-contact.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind
|
||||
* {@link requireAdmin}. Backs the `/admin/messages` page: the read-only list
|
||||
* of contact-form messages (issue #80), plus "create a Forgejo issue from
|
||||
* this message" (issue #81). Still no message-mutation routes — the message
|
||||
* itself stays read-only.
|
||||
*/
|
||||
export const adminContactRouter = Router();
|
||||
|
||||
adminContactRouter.get(
|
||||
"/",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
res
|
||||
.status(200)
|
||||
.json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query)));
|
||||
}),
|
||||
);
|
||||
|
||||
// Static path — declared before `/:id/...` so it's never parsed as an id.
|
||||
adminContactRouter.get(
|
||||
"/forgejo/status",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(getForgejoStatus());
|
||||
}),
|
||||
);
|
||||
|
||||
adminContactRouter.post(
|
||||
"/:id/forgejo-issue",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
`Not a valid contact message id: ${req.params.id}`,
|
||||
);
|
||||
}
|
||||
const input = createForgejoIssueSchema.parse(req.body);
|
||||
res.status(200).json(await createForgejoIssueFromMessage(id, input));
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type ContactMessageAdminView,
|
||||
type ContactMessageType,
|
||||
type CreateForgejoIssueInput,
|
||||
ErrorCode,
|
||||
type ForgejoIntegrationStatus,
|
||||
type ListContactMessagesQuery,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { forgejo } from "../../lib/forgejo/forgejo.service.js";
|
||||
|
||||
/** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */
|
||||
const CONTACT_MESSAGES_PAGE_SIZE = 200;
|
||||
|
||||
const contactMessageInclude = {
|
||||
author: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
} as const;
|
||||
|
||||
type ContactMessageRow = Awaited<
|
||||
ReturnType<
|
||||
typeof prisma.contactMessage.findFirstOrThrow<{ include: typeof contactMessageInclude }>
|
||||
>
|
||||
>;
|
||||
|
||||
/** Shapes one Prisma `ContactMessage` row (with {@link contactMessageInclude}) into its admin view. */
|
||||
function toContactMessageAdminView(row: ContactMessageRow): ContactMessageAdminView {
|
||||
return {
|
||||
id: row.id,
|
||||
// Safe cast: `POST /contact` validates `type` against
|
||||
// `CONTACT_MESSAGE_TYPES` before insert, so the column only ever holds
|
||||
// one of those values.
|
||||
type: row.type as ContactMessageType,
|
||||
message: row.message,
|
||||
pageUrl: row.pageUrl,
|
||||
userAgent: row.userAgent,
|
||||
appVersion: row.appVersion,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
// `null` when the sender's account was deleted after sending
|
||||
// (`authorId` is `onDelete: SetNull`).
|
||||
author: row.author,
|
||||
forgejoIssueNumber: row.forgejoIssueNumber,
|
||||
forgejoIssueUrl: row.forgejoIssueUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `ContactMessage` for the admin read view (`/admin/messages`, issue
|
||||
* #80) — newest first, capped at {@link CONTACT_MESSAGES_PAGE_SIZE}, with an
|
||||
* optional single-`type` filter. Read-only: this first version has no
|
||||
* triage workflow (status / assignment / notes), so there's no update
|
||||
* counterpart.
|
||||
*/
|
||||
export async function listContactMessages(
|
||||
query: ListContactMessagesQuery,
|
||||
): Promise<ContactMessageAdminView[]> {
|
||||
try {
|
||||
const rows = await prisma.contactMessage.findMany({
|
||||
where: query.type ? { type: query.type } : {},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: CONTACT_MESSAGES_PAGE_SIZE,
|
||||
include: contactMessageInclude,
|
||||
});
|
||||
return rows.map(toContactMessageAdminView);
|
||||
} catch (err) {
|
||||
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the "create a Forgejo issue from a message" feature is usable (issue #81) — drives whether the admin UI shows the button. */
|
||||
export function getForgejoStatus(): ForgejoIntegrationStatus {
|
||||
return { enabled: forgejo.isConfigured() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Forgejo issue from contact message `messageId` and records the
|
||||
* link on the row (issue #81). The `title`/`body` come from the admin's
|
||||
* pre-filled, edited modal.
|
||||
*
|
||||
* Order matters: every precondition is checked before Forgejo is called,
|
||||
* and the row is only touched *after* Forgejo succeeds — a failed call
|
||||
* leaves no partial state (no linked issue, button still shown).
|
||||
*
|
||||
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` (env not set),
|
||||
* `404 CONTACT_MESSAGE_NOT_FOUND`, `409 CONTACT_MESSAGE_ALREADY_LINKED`
|
||||
* (already has an issue), or `502 FORGEJO_REQUEST_FAILED` (the Forgejo
|
||||
* call failed — DB untouched).
|
||||
*/
|
||||
export async function createForgejoIssueFromMessage(
|
||||
messageId: number,
|
||||
input: CreateForgejoIssueInput,
|
||||
): Promise<ContactMessageAdminView> {
|
||||
try {
|
||||
if (!forgejo.isConfigured()) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
ErrorCode.FORGEJO_NOT_CONFIGURED,
|
||||
"Forgejo integration is not configured",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.contactMessage.findUnique({ where: { id: messageId } });
|
||||
if (!existing) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.CONTACT_MESSAGE_NOT_FOUND,
|
||||
`Contact message ${messageId} not found`,
|
||||
);
|
||||
}
|
||||
if (existing.forgejoIssueNumber !== null) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED,
|
||||
`Contact message ${messageId} already links to Forgejo issue #${existing.forgejoIssueNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Throws `502 FORGEJO_REQUEST_FAILED` on any failure — propagated below,
|
||||
// before the row is touched.
|
||||
const issue = await forgejo.createIssue({ title: input.title, body: input.body });
|
||||
|
||||
const updated = await prisma.contactMessage.update({
|
||||
where: { id: messageId },
|
||||
data: { forgejoIssueNumber: issue.number, forgejoIssueUrl: issue.htmlUrl },
|
||||
include: contactMessageInclude,
|
||||
});
|
||||
return toContactMessageAdminView(updated);
|
||||
} catch (err) {
|
||||
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { Router } from "express";
|
|||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import { getMetrics } from "./admin-metrics.service.js";
|
||||
|
||||
/** Router mounted at `/admin/api/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
|
||||
/** Router mounted at `/admin/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
|
||||
export const adminMetricsRouter = Router();
|
||||
|
||||
/**
|
||||
|
|
|
|||
20
apps/api/src/modules/admin/admin-monitoring.routes.ts
Normal file
20
apps/api/src/modules/admin/admin-monitoring.routes.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { Router } from "express";
|
||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import { getMonitoring } from "./admin-monitoring.service.js";
|
||||
|
||||
/** Router mounted at `/admin/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */
|
||||
export const adminMonitoringRouter = Router();
|
||||
|
||||
/**
|
||||
* Actively probes Postgres, the API, `tech-step-intent-service` and the LLM
|
||||
* worker's heartbeat, returning a {@link MonitoringView} status board (see
|
||||
* {@link getMonitoring}). No params; the admin UI polls it on an interval.
|
||||
*/
|
||||
adminMonitoringRouter.get(
|
||||
"/",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getMonitoring());
|
||||
}),
|
||||
);
|
||||
|
|
@ -155,13 +155,7 @@ async function probeWorker(): Promise<ServiceHealthView> {
|
|||
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
|
||||
* LLM worker (via its stored heartbeat). Each probe is independent and
|
||||
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
|
||||
* others or this call as a whole.
|
||||
*
|
||||
* No HTTP route calls this directly (there was one, `GET
|
||||
* /admin/api/monitoring` — removed) — `MonitoringBroadcastService`
|
||||
* (`lib/monitoring-broadcast.service.ts`) is the only caller now, on a
|
||||
* server-side timer, broadcasting the result over WebSocket instead of
|
||||
* each admin's browser polling this on its own.
|
||||
* others or the endpoint.
|
||||
*/
|
||||
export async function getMonitoring(): Promise<MonitoringView> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import type {
|
||||
ListTechStepPlaceholdersQuery,
|
||||
MarkTechStepPlaceholdersReviewedInput,
|
||||
TechStepPlaceholderView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/**
|
||||
* The missing-technique review (issue #130) — the tech-step counterpart of
|
||||
* `admin-catalog.service.ts`'s off-catalog *ingredient* review. Kept a flat
|
||||
* list rather than grouping near-duplicate spellings the way that one does:
|
||||
* correcting a technique — let alone typing one the catalog is missing — is
|
||||
* rare enough that a handful of raw rows is all a maintainer ever sees here.
|
||||
*/
|
||||
|
||||
/** Prisma `include` for the placeholder query — a bounded set of the `StepTechStep` rows pointing at this technique, each resolved to just its recipe id/name for the count + "seen in…" preview. */
|
||||
const placeholderInclude = {
|
||||
steps: {
|
||||
take: 50,
|
||||
include: { step: { select: { recipe: { select: { id: true, name: true } } } } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Every placeholder `TechStep` (see `TechStep.isPlaceholder` in
|
||||
* schema.prisma), most-impactful first (most distinct recipes, then name).
|
||||
*
|
||||
* `query.reviewed` selects which side to show: omitted / `"false"` drops
|
||||
* rows already triaged (`reviewedAt` set) — the default working list;
|
||||
* `"true"` keeps only those.
|
||||
*/
|
||||
export async function listTechStepPlaceholders(
|
||||
query: ListTechStepPlaceholdersQuery,
|
||||
): Promise<TechStepPlaceholderView[]> {
|
||||
try {
|
||||
const rows = await prisma.techStep.findMany({
|
||||
where: { isPlaceholder: true },
|
||||
include: placeholderInclude,
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
const wantReviewed = query.reviewed === "true";
|
||||
return rows
|
||||
.filter((row) => (row.reviewedAt !== null) === wantReviewed)
|
||||
.map((row) => {
|
||||
const recipeById = new Map<number, string>();
|
||||
for (const link of row.steps) {
|
||||
recipeById.set(link.step.recipe.id, link.step.recipe.name);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
displayName: row.displayName ?? "",
|
||||
createdAt: row.createdAt?.toISOString() ?? null,
|
||||
reviewedAt: row.reviewedAt?.toISOString() ?? null,
|
||||
recipeCount: recipeById.size,
|
||||
sampleRecipes: [...recipeById.entries()].slice(0, 5).map(([id, name]) => ({ id, name })),
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) => b.recipeCount - a.recipeCount || a.displayName.localeCompare(b.displayName, "fr"),
|
||||
);
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps `reviewedAt` on the given placeholder `TechStep` ids — a
|
||||
* maintainer has seen this proposal (and, if it warranted it, promoted it
|
||||
* into `reference-seed-data.ts` + `tech-step-training-data.ts` by hand; this
|
||||
* endpoint never touches the catalog itself). Scoped to
|
||||
* `isPlaceholder: true` so a stray real id is a silent no-op. Returns how
|
||||
* many rows were actually stamped.
|
||||
*/
|
||||
export async function markTechStepPlaceholdersReviewed(
|
||||
input: MarkTechStepPlaceholdersReviewedInput,
|
||||
): Promise<{ reviewed: number }> {
|
||||
try {
|
||||
const { count } = await prisma.techStep.updateMany({
|
||||
where: { id: { in: input.techStepIds }, isPlaceholder: true },
|
||||
data: { reviewedAt: new Date() },
|
||||
});
|
||||
return { reviewed: count };
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
updateSuggestion,
|
||||
} from "./admin-tech-steps.service.js";
|
||||
|
||||
/** Router mounted at `/admin/api/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
|
||||
/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
|
||||
export const adminTechStepsRouter = Router();
|
||||
|
||||
adminTechStepsRouter.get(
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
createTourScenarioSchema,
|
||||
ErrorCode,
|
||||
updateTourScenarioSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import {
|
||||
createTourScenario,
|
||||
deleteTourScenario,
|
||||
listTourScenarios,
|
||||
listTourTargets,
|
||||
updateTourScenario,
|
||||
} from "./admin-tours.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/admin/api/tours` (via `admin.routes.ts`) — every
|
||||
* route behind {@link requireAdmin}. Full CRUD over the guided-tour
|
||||
* scenarios the public `GET /reference/tours` serves to the web tour
|
||||
* engine (issues #42/#44).
|
||||
*/
|
||||
export const adminToursRouter = Router();
|
||||
|
||||
/** Express types a numeric route param `string | undefined`; parse + validate here. */
|
||||
function parseId(value: string | undefined): number {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, `Invalid tour scenario id "${value}"`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
adminToursRouter.get(
|
||||
"/",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await listTourScenarios());
|
||||
}),
|
||||
);
|
||||
|
||||
/** The catalogue of valid highlight targets — for the editor's per-step target picker. */
|
||||
adminToursRouter.get(
|
||||
"/targets",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await listTourTargets());
|
||||
}),
|
||||
);
|
||||
|
||||
adminToursRouter.post(
|
||||
"/",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const scenario = await createTourScenario(createTourScenarioSchema.parse(req.body));
|
||||
res.status(201).json(scenario);
|
||||
}),
|
||||
);
|
||||
|
||||
adminToursRouter.patch(
|
||||
"/:id",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const scenario = await updateTourScenario(
|
||||
parseId(req.params.id),
|
||||
updateTourScenarioSchema.parse(req.body),
|
||||
);
|
||||
res.status(200).json(scenario);
|
||||
}),
|
||||
);
|
||||
|
||||
adminToursRouter.delete(
|
||||
"/:id",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
await deleteTourScenario(parseId(req.params.id));
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type AdminTourScenarioView,
|
||||
type CreateTourScenarioInput,
|
||||
ErrorCode,
|
||||
type TourTargetView,
|
||||
type UpdateTourScenarioInput,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/** Shapes one Prisma `TourScenario` (with its steps) into the admin view. */
|
||||
function toAdminView(scenario: {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
steps: Array<{
|
||||
order: number;
|
||||
route: string | null;
|
||||
targetKey: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}>;
|
||||
}): AdminTourScenarioView {
|
||||
return {
|
||||
id: scenario.id,
|
||||
key: scenario.key,
|
||||
name: scenario.name,
|
||||
enabled: scenario.enabled,
|
||||
steps: scenario.steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => ({
|
||||
order: step.order,
|
||||
route: step.route,
|
||||
targetKey: step.targetKey,
|
||||
title: step.title,
|
||||
content: step.content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every tour scenario — enabled *and* disabled — with its ordered steps. */
|
||||
export async function listTourScenarios(): Promise<AdminTourScenarioView[]> {
|
||||
try {
|
||||
const scenarios = await prisma.tourScenario.findMany({
|
||||
orderBy: { key: "asc" },
|
||||
include: { steps: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
return scenarios.map(toAdminView);
|
||||
} catch (err) {
|
||||
throw err; // see the repo's service-layer try/catch convention
|
||||
}
|
||||
}
|
||||
|
||||
/** The catalogue of valid highlight targets, for the editor's target picker. */
|
||||
export async function listTourTargets(): Promise<TourTargetView[]> {
|
||||
try {
|
||||
return await prisma.tourTarget.findMany({ orderBy: { key: "asc" } });
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty scenario (no steps yet — the editor adds them via
|
||||
* {@link updateTourScenario}).
|
||||
*
|
||||
* @throws {HttpError} `409 TOUR_KEY_ALREADY_IN_USE` if `key` is taken.
|
||||
*/
|
||||
export async function createTourScenario(
|
||||
input: CreateTourScenarioInput,
|
||||
): Promise<AdminTourScenarioView> {
|
||||
try {
|
||||
const scenario = await prisma.tourScenario.create({
|
||||
data: { key: input.key, name: input.name },
|
||||
include: { steps: true },
|
||||
});
|
||||
return toAdminView(scenario);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.TOUR_KEY_ALREADY_IN_USE,
|
||||
`A tour scenario with key "${input.key}" already exists`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a scenario's `name`/`enabled` and **replaces its entire step
|
||||
* list** (delete-all then recreate with `order` = array index) — the same
|
||||
* block-replace `updateRecipe` uses for a recipe's steps, not a partial
|
||||
* patch. `key` is immutable.
|
||||
*
|
||||
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
|
||||
* @throws {HttpError} `404 TOUR_TARGET_NOT_FOUND` if a step's `targetKey`
|
||||
* isn't a seeded `TourTarget`.
|
||||
*/
|
||||
export async function updateTourScenario(
|
||||
id: number,
|
||||
input: UpdateTourScenarioInput,
|
||||
): Promise<AdminTourScenarioView> {
|
||||
try {
|
||||
const existing = await prisma.tourScenario.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
|
||||
}
|
||||
|
||||
const targetKeys = [...new Set(input.steps.map((step) => step.targetKey))];
|
||||
if (targetKeys.length > 0) {
|
||||
const known = await prisma.tourTarget.findMany({
|
||||
where: { key: { in: targetKeys } },
|
||||
select: { key: true },
|
||||
});
|
||||
const knownKeys = new Set(known.map((target) => target.key));
|
||||
const unknown = targetKeys.find((key) => !knownKeys.has(key));
|
||||
if (unknown !== undefined) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.TOUR_TARGET_NOT_FOUND,
|
||||
`No tour target with key "${unknown}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [, , scenario] = await prisma.$transaction([
|
||||
prisma.tourScenario.update({
|
||||
where: { id },
|
||||
data: { name: input.name, enabled: input.enabled },
|
||||
}),
|
||||
prisma.tourScenarioStep.deleteMany({ where: { scenarioId: id } }),
|
||||
prisma.tourScenario.update({
|
||||
where: { id },
|
||||
data: {
|
||||
steps: {
|
||||
create: input.steps.map((step, index) => ({
|
||||
order: index,
|
||||
route: step.route,
|
||||
targetKey: step.targetKey,
|
||||
title: step.title,
|
||||
content: step.content,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { steps: { orderBy: { order: "asc" } } },
|
||||
}),
|
||||
]);
|
||||
return toAdminView(scenario);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a scenario. Its steps and every user's `TourProgress` row for it
|
||||
* cascade away (a scenario that no longer exists has no "seen" state worth
|
||||
* keeping).
|
||||
*
|
||||
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
|
||||
*/
|
||||
export async function deleteTourScenario(id: number): Promise<void> {
|
||||
try {
|
||||
const existing = await prisma.tourScenario.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
|
||||
}
|
||||
await prisma.tourScenario.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { adminCreateUserSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import { createUserProfile, listUsers } from "./admin-users.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/admin/api/users` (via `admin.routes.ts`) — every route
|
||||
* behind {@link requireAdmin}. Backs the `/admin/utilisateurs` page: a
|
||||
* read-only list of end-user accounts and a "create an account by hand"
|
||||
* form. The server generates a one-time temporary password on create and
|
||||
* returns it in the response (see `admin-users.service.ts`).
|
||||
*/
|
||||
export const adminUsersRouter = Router();
|
||||
|
||||
adminUsersRouter.get(
|
||||
"/",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await listUsers());
|
||||
}),
|
||||
);
|
||||
|
||||
adminUsersRouter.post(
|
||||
"/",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const input = adminCreateUserSchema.parse(req.body);
|
||||
res.status(201).json(await createUserProfile(input));
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import { randomInt } from "node:crypto";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type AdminCreateUserInput,
|
||||
type AdminUserCreatedView,
|
||||
type AdminUserListItemView,
|
||||
ErrorCode,
|
||||
} from "@batch-cooking/shared";
|
||||
import argon2 from "argon2";
|
||||
import { env } from "../../config/env.js";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { analytics } from "../../lib/analytics.service.js";
|
||||
|
||||
/**
|
||||
* How many accounts `listUsers` returns per call. Like `CONTACT_MESSAGES_PAGE_SIZE`
|
||||
* this is a triage view, not an export — the admin creates/looks up a handful
|
||||
* of accounts, it never needs the whole table paginated.
|
||||
*/
|
||||
const USERS_PAGE_SIZE = 200;
|
||||
|
||||
// Same reasoning as `admin-auth.service.ts`'s / `auth.service.ts`'s `hashOptions`:
|
||||
// argon2's real defaults are deliberately expensive; the test suite hashes
|
||||
// against throwaway data many times per run, so a cheaper cost keeps it fast
|
||||
// without weakening anything real. Never applies outside NODE_ENV=test.
|
||||
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
||||
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||
|
||||
/**
|
||||
* Charset for {@link generateTemporaryPassword} — upper/lower letters + digits,
|
||||
* minus the visually ambiguous ones (`0`/`O`/`1`/`l`/`I`), same spirit as
|
||||
* `house.service.ts`'s `INVITE_CODE_CHARS`: this string is read off one screen
|
||||
* and typed into another (or pasted, but not always).
|
||||
*/
|
||||
const TEMP_PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
||||
|
||||
/** Length of a generated temporary password — comfortably above `signupSchema`'s 8-char floor. */
|
||||
const TEMP_PASSWORD_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* A fresh random temporary password for an admin-created account. Uses
|
||||
* `node:crypto`'s `randomInt` (not `Math.random`) exactly like
|
||||
* `generateInviteCode()` — this value guards a real account until its owner
|
||||
* changes it. Returned to the operator once and never stored in plain text.
|
||||
*/
|
||||
function generateTemporaryPassword(): string {
|
||||
let password = "";
|
||||
for (let i = 0; i < TEMP_PASSWORD_LENGTH; i++) {
|
||||
password += TEMP_PASSWORD_CHARS[randomInt(TEMP_PASSWORD_CHARS.length)];
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/** A `UserProfile` row with its household name joined in — the shape {@link toListItem} maps from. */
|
||||
type UserRow = Awaited<
|
||||
ReturnType<
|
||||
typeof prisma.userProfile.findFirstOrThrow<{
|
||||
include: { house: { select: { name: true } } };
|
||||
}>
|
||||
>
|
||||
>;
|
||||
|
||||
/** Shapes one Prisma `UserProfile` row (with its `house` name) into the admin list view — drops `passwordHash`/`tokenVersion`, resolves the household name. */
|
||||
function toListItem(row: UserRow): AdminUserListItemView {
|
||||
return {
|
||||
id: row.id,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
email: row.email,
|
||||
householdName: row.house?.name ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every end-user account for the admin `/admin/utilisateurs` list — newest
|
||||
* first, capped at {@link USERS_PAGE_SIZE}. Read-only: this first version has
|
||||
* no edit/disable/delete (deleting an account is irreversible and stays a
|
||||
* deliberate self-service action in `AccountSettingsPage`).
|
||||
*/
|
||||
export async function listUsers(): Promise<AdminUserListItemView[]> {
|
||||
try {
|
||||
const rows = await prisma.userProfile.findMany({
|
||||
// `id` as a tiebreaker so the order is total even if two accounts share
|
||||
// a `createdAt` (bulk-provisioned in the same tick) — `id` ascends with
|
||||
// `createdAt` anyway.
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take: USERS_PAGE_SIZE,
|
||||
include: { house: { select: { name: true } } },
|
||||
});
|
||||
return rows.map(toListItem);
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
||||
// this layer just isn't allowed a bare `await`. Same as `admin-contact.service.ts`.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provisions a new end-user account by hand and returns it together with the
|
||||
* one-time temporary password the caller must relay to the user. Mirrors
|
||||
* `auth.service.ts`'s `signup` for the parts that matter (email-uniqueness
|
||||
* check, argon2 hash, `UserProfile` row with `houseId`/`dietId` left `null`)
|
||||
* but issues **no session token** — the operator isn't logging in as this
|
||||
* user — and generates the password instead of accepting one.
|
||||
*
|
||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||
*/
|
||||
export async function createUserProfile(
|
||||
input: AdminCreateUserInput,
|
||||
): Promise<AdminUserCreatedView> {
|
||||
try {
|
||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
if (existing) {
|
||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
||||
}
|
||||
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await argon2.hash(temporaryPassword, hashOptions);
|
||||
|
||||
const profile = await prisma.userProfile.create({
|
||||
// No household/diet here, same as `signup` — the user picks those in the
|
||||
// onboarding wizard after their first login.
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
},
|
||||
include: { house: { select: { name: true } } },
|
||||
});
|
||||
|
||||
// Fire-and-forget, one line, no migration (see `analytics.service.ts`). A
|
||||
// distinct type from `user.signup` so the dashboard can tell hand-provisioned
|
||||
// accounts apart; `actorType: "system"` because the actor is an operator, not
|
||||
// the new user (and `AnalyticsEvent.actorId` only ever means a `UserProfile.id`).
|
||||
analytics.recordEvent("admin.user_created", {
|
||||
actorType: "system",
|
||||
context: { userId: profile.id },
|
||||
});
|
||||
|
||||
return { user: toListItem(profile), temporaryPassword };
|
||||
} catch (err) {
|
||||
throw err; // see listUsers()'s catch comment
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,22 @@
|
|||
import { Router } from "express";
|
||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
||||
import { adminContactRouter } from "./admin-contact.routes.js";
|
||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||
import { adminMonitoringRouter } from "./admin-monitoring.routes.js";
|
||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||
import { adminToursRouter } from "./admin-tours.routes.js";
|
||||
import { adminUsersRouter } from "./admin-users.routes.js";
|
||||
|
||||
/**
|
||||
* Aggregator for the admin application's API surface, mounted at
|
||||
* `/admin/api` in `app.ts` (not bare `/admin` — see that mount point's own
|
||||
* comment for why: it would collide with apps/web's `/admin/*` page
|
||||
* routes). Every sub-router here backs one of those pages —
|
||||
* `/admin/api/auth` is public (login), everything added later
|
||||
* (`/admin/api/metrics`, `/admin/api/tech-steps/*`, `/admin/api/catalog/*`)
|
||||
* sits behind `requireAdmin` (`middlewares/require-admin.ts`).
|
||||
*
|
||||
* Monitoring has **no** REST route here on purpose — it's pushed over
|
||||
* WebSocket instead (`lib/monitoring-broadcast.service.ts`, wired in
|
||||
* `server.ts`), not requested per-page-load. See that service's doc
|
||||
* comment for the full mechanism.
|
||||
* Aggregator for the admin application's API surface, mounted at `/admin`
|
||||
* in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes
|
||||
* only — `/admin/auth` is public (login), everything added later
|
||||
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`,
|
||||
* `/admin/catalog/*`) sits behind `requireAdmin`
|
||||
* (`middlewares/require-admin.ts`).
|
||||
*/
|
||||
export const adminRouter = Router();
|
||||
|
||||
adminRouter.use("/auth", adminAuthRouter);
|
||||
adminRouter.use("/metrics", adminMetricsRouter);
|
||||
adminRouter.use("/monitoring", adminMonitoringRouter);
|
||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||
adminRouter.use("/catalog", adminCatalogRouter);
|
||||
adminRouter.use("/tours", adminToursRouter);
|
||||
adminRouter.use("/contact", adminContactRouter);
|
||||
adminRouter.use("/users", adminUsersRouter);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
type AuthResponse,
|
||||
deleteAccountSchema,
|
||||
loginSchema,
|
||||
signupSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||
import type { CookieOptions, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { env } from "../../config/env.js";
|
||||
|
|
@ -48,11 +43,7 @@ authRouter.post(
|
|||
const input = signupSchema.parse(req.body);
|
||||
const { profile, token } = await signup(input);
|
||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||
// Token is echoed in the body too, not just the cookie — see
|
||||
// `AuthResponse` (packages/shared) for why (Capacitor WebView origin
|
||||
// can't receive a SameSite cookie). Purely additive: every existing
|
||||
// profile field stays exactly where it was.
|
||||
res.status(201).json({ ...profile, token } satisfies AuthResponse);
|
||||
res.status(201).json(profile);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -63,7 +54,7 @@ authRouter.post(
|
|||
const input = loginSchema.parse(req.body);
|
||||
const { profile, token } = await login(input);
|
||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||
res.status(200).json({ ...profile, token } satisfies AuthResponse);
|
||||
res.status(200).json(profile);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { submitContactMessageSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { submitContactMessage } from "./contact.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/contact` in app.ts (issue #80). A single endpoint —
|
||||
* `POST /` — for the in-app "signaler un bug / suggestion" form. Requires a
|
||||
* session: only signed-in users can send a message, so every report is
|
||||
* attributable (see `ContactMessage.authorId`). The admin-side read of
|
||||
* these rows lives entirely in the admin router, not here.
|
||||
*/
|
||||
export const contactRouter = Router();
|
||||
|
||||
contactRouter.post(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = submitContactMessageSchema.parse(req.body);
|
||||
// Read the UA from the request, never from the body — the schema omits
|
||||
// it on purpose so it can't be spoofed. Header can be an array or
|
||||
// absent; normalize to `string | null`.
|
||||
const rawUserAgent = req.headers["user-agent"];
|
||||
const userAgent =
|
||||
typeof rawUserAgent === "string" && rawUserAgent.length > 0 ? rawUserAgent : null;
|
||||
await submitContactMessage(res.locals.userProfile.id, input, userAgent);
|
||||
// 204, not 201-with-a-body: the form has nothing to do with the stored
|
||||
// row, and the shared `ApiClient._request` treats only 204 as bodyless
|
||||
// (a 201 with an empty body makes its `response.json()` throw). Same
|
||||
// "mutation returns nothing" shape as `POST /auth/logout`.
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import type { SubmitContactMessageInput } from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { analytics } from "../../lib/analytics.service.js";
|
||||
|
||||
/**
|
||||
* Persists one contact-form submission (issue #80). Write-only from the
|
||||
* user's side — there is no read/update/delete counterpart here: admins
|
||||
* consult these rows through the admin app (`admin-contact.service.ts`, PR
|
||||
* 2), never this module.
|
||||
*
|
||||
* `userAgent` comes straight from the request header, never the body — the
|
||||
* shared `submitContactMessageSchema` deliberately omits it so the client
|
||||
* can't spoof it. `pageUrl` / `appVersion` are client-supplied context and
|
||||
* default to `null` when the (older, or non-web) client didn't send them.
|
||||
*
|
||||
* Returns nothing: the form only needs to know the write succeeded (a
|
||||
* `201`), not the stored row.
|
||||
*/
|
||||
export async function submitContactMessage(
|
||||
authorId: number,
|
||||
input: SubmitContactMessageInput,
|
||||
userAgent: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.contactMessage.create({
|
||||
data: {
|
||||
authorId,
|
||||
type: input.type,
|
||||
message: input.message,
|
||||
pageUrl: input.pageUrl ?? null,
|
||||
userAgent,
|
||||
appVersion: input.appVersion ?? null,
|
||||
},
|
||||
});
|
||||
// Fire-and-forget (see AnalyticsService's contract) — `admin-metrics`
|
||||
// aggregates `AnalyticsEvent` by `type` generically, so this shows up in
|
||||
// the dashboard series with no extra code; `context.type` lets it be
|
||||
// broken down by bug/suggestion/other.
|
||||
analytics.recordEvent("contact.submitted", {
|
||||
actorType: "user",
|
||||
actorId: authorId,
|
||||
context: { type: input.type },
|
||||
});
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler` / the error middleware (which
|
||||
// already logs it) handles it; this service layer just isn't allowed a
|
||||
// bare `await` per the repo's async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,11 +22,6 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
|||
function cookingSessionPlanningInclude() {
|
||||
return {
|
||||
items: {
|
||||
// A "leftovers" item (`sourcePlanningItemId` set) reserves part of an
|
||||
// already-cooked batch — it has nothing to cook, so it must not enter
|
||||
// the optimized cooking plan (issue #34). Excluded at the query so it
|
||||
// never reaches `toOptimizerRecipe`.
|
||||
where: { sourcePlanningItemId: null },
|
||||
include: {
|
||||
recipe: {
|
||||
select: {
|
||||
|
|
@ -89,12 +84,7 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
|||
unit: line.unit === null ? null : toUnitView(line.unit),
|
||||
}));
|
||||
return {
|
||||
techStep: {
|
||||
id: techStep.techStep.id,
|
||||
key: techStep.techStep.key,
|
||||
isPlaceholder: techStep.techStep.isPlaceholder,
|
||||
displayName: techStep.techStep.displayName,
|
||||
},
|
||||
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
||||
order: techStep.order,
|
||||
ingredients,
|
||||
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { Router } from "express";
|
||||
import { getLatestApkUrl, getLatestVersion } from "./mobile.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/mobile` in app.ts. Both routes are deliberately
|
||||
* public (no `requireAuth`): the landing page's download link is shown to
|
||||
* logged-out visitors, and the native update check (issue #66) runs on
|
||||
* every app start, session or not. Both exist purely to work around
|
||||
* Forgejo's Releases API sending no CORS headers — see `mobile.service.ts`'s
|
||||
* own doc comment for the full story.
|
||||
*/
|
||||
export const mobileRouter = Router();
|
||||
|
||||
mobileRouter.get(
|
||||
"/latest-release",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json({ version: await getLatestVersion() });
|
||||
}),
|
||||
);
|
||||
|
||||
// A plain redirect, not JSON — lets any caller (the landing page's `<a>`,
|
||||
// the update banner's "Mettre à jour" link) use a static href with zero
|
||||
// client-side fetch, and always resolves to something useful even if
|
||||
// Forgejo is briefly unreachable (see getLatestApkUrl's fallback).
|
||||
mobileRouter.get(
|
||||
"/download",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.redirect(302, await getLatestApkUrl());
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
|
||||
/**
|
||||
* Forgejo's own Releases API for this repo — public, unauthenticated (the
|
||||
* same fact `local-verification-while-ci-disabled` already leans on for
|
||||
* CI-status checks without a token). **Must be called server-side, not
|
||||
* from the browser**: verified live that Forgejo sends no
|
||||
* `Access-Control-Allow-Origin` header on it, so a direct `fetch()` from
|
||||
* `apps/web` fails with an opaque CORS error even though the endpoint
|
||||
* itself is perfectly reachable — this whole module exists to proxy it
|
||||
* from a context CORS doesn't apply to.
|
||||
*/
|
||||
const LATEST_RELEASE_API_URL =
|
||||
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
|
||||
|
||||
/**
|
||||
* Human-facing Forgejo release page — safe fallback for {@link getLatestApkUrl}
|
||||
* when no matching asset is found (a release with nothing attached yet,
|
||||
* Forgejo briefly unreachable, ...). Never a dead link, just a slightly
|
||||
* less direct one: the visitor lands on the release itself and can find
|
||||
* the asset from there.
|
||||
*/
|
||||
const LATEST_RELEASE_PAGE_URL = "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest";
|
||||
|
||||
/** Stable filename `release.yml` always attaches the signed APK under — see that workflow's own comment. */
|
||||
const APK_ASSET_NAME = "batchcooking.apk";
|
||||
|
||||
interface ForgejoReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
interface ForgejoRelease {
|
||||
tag_name: string;
|
||||
assets: ForgejoReleaseAsset[];
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(): Promise<ForgejoRelease> {
|
||||
const response = await fetch(LATEST_RELEASE_API_URL);
|
||||
if (!response.ok) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`Forgejo releases API responded ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as ForgejoRelease;
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest tagged Android build's version (e.g. `"v1.4.2"`) — used by the
|
||||
* native app's update check (issue #66, `useAppUpdateCheck.ts`).
|
||||
*/
|
||||
export async function getLatestVersion(): Promise<string> {
|
||||
const release = await fetchLatestRelease();
|
||||
return release.tag_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a "download the app" click should end up right now: the actual
|
||||
* signed-APK asset of the latest release if one is attached, else
|
||||
* {@link LATEST_RELEASE_PAGE_URL} as a safe fallback. Never throws — a
|
||||
* download link must always go *somewhere* useful, even when Forgejo is
|
||||
* briefly unreachable.
|
||||
*/
|
||||
export async function getLatestApkUrl(): Promise<string> {
|
||||
try {
|
||||
const release = await fetchLatestRelease();
|
||||
const apk = release.assets.find((asset) => asset.name === APK_ASSET_NAME);
|
||||
return apk?.browser_download_url ?? LATEST_RELEASE_PAGE_URL;
|
||||
} catch {
|
||||
return LATEST_RELEASE_PAGE_URL;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,10 @@
|
|||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
addPlanningItemSchema,
|
||||
assignLeftoverSchema,
|
||||
ErrorCode,
|
||||
getPlanningByDateSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import {
|
||||
addPlanningItem,
|
||||
assignLeftoverPlanningItem,
|
||||
discardLeftover,
|
||||
getPlanningForDate,
|
||||
removePlanningItem,
|
||||
} from "./planning.service.js";
|
||||
import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";
|
||||
|
||||
/** Router mounted at `/planning` in app.ts. */
|
||||
export const planningRouter = Router();
|
||||
|
|
@ -80,44 +69,6 @@ planningRouter.post(
|
|||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Serves part of another planning item's leftover pool on a slot — creates
|
||||
* a "leftovers" `PlanningItem` (see {@link assignLeftoverPlanningItem}).
|
||||
* `POST /items/leftover`, not `/items/:id/...`: the `:id` here would be the
|
||||
* *target* slot, which doesn't exist yet — the source is in the body.
|
||||
*/
|
||||
planningRouter.post(
|
||||
"/items/leftover",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = assignLeftoverSchema.parse(req.body);
|
||||
const date = parseDateOnly(input.date);
|
||||
if (date === null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
`Not a real calendar date: ${input.date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
const item = await assignLeftoverPlanningItem(houseId, viewerId, date, input);
|
||||
res.status(201).json(item);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Throws away item `:id`'s whole remaining leftover pool (idempotent — see {@link discardLeftover}). */
|
||||
planningRouter.patch(
|
||||
"/items/:id/discard-leftover",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parsePlanningItemId(req.params.id);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
const item = await discardLeftover(id, viewerId, houseId);
|
||||
res.status(200).json(item);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Removes one recipe from a planning slot. */
|
||||
planningRouter.delete(
|
||||
"/items/:id",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type AddPlanningItemInput,
|
||||
type AssignLeftoverInput,
|
||||
computeAvailableLeftover,
|
||||
computeBatchedPortions,
|
||||
ErrorCode,
|
||||
type PlanningItemView,
|
||||
type PlanningView,
|
||||
|
|
@ -13,35 +10,6 @@ import { prisma } from "../../db/prisma.js";
|
|||
import { analytics } from "../../lib/analytics.service.js";
|
||||
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
||||
|
||||
/**
|
||||
* One `PlanningItem` row (with its recipe resolved to `{id, name}`) shaped
|
||||
* into the wire {@link PlanningItemView}. The three read/write paths below
|
||||
* that return an item — `getPlanningForDate`, `addPlanningItem`,
|
||||
* `assignLeftoverPlanningItem`, `discardLeftover` — all go through this so
|
||||
* the shape is defined once.
|
||||
*/
|
||||
function toPlanningItemView(item: {
|
||||
id: number;
|
||||
weekDay: string;
|
||||
meal: string;
|
||||
portions: number;
|
||||
portionsRequested: number;
|
||||
discardedPortions: number;
|
||||
sourcePlanningItemId: number | null;
|
||||
recipe: { id: number; name: string };
|
||||
}): PlanningItemView {
|
||||
return {
|
||||
id: item.id,
|
||||
weekDay: item.weekDay,
|
||||
meal: item.meal,
|
||||
portions: item.portions,
|
||||
portionsRequested: item.portionsRequested,
|
||||
discardedPortions: item.discardedPortions,
|
||||
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
|
||||
recipe: item.recipe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the household's planning that covers `date` and shapes it into a
|
||||
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
||||
|
|
@ -96,7 +64,13 @@ export async function getPlanningForDate(
|
|||
id: planning.id,
|
||||
startDate: planning.startDate.toISOString(),
|
||||
finishDate: planning.finishDate.toISOString(),
|
||||
items: planning.items.map(toPlanningItemView),
|
||||
items: planning.items.map((item) => ({
|
||||
id: item.id,
|
||||
weekDay: item.weekDay,
|
||||
meal: item.meal,
|
||||
portions: item.portions,
|
||||
recipe: item.recipe,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
|
|
@ -170,18 +144,6 @@ export async function addPlanningItem(
|
|||
}
|
||||
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
||||
|
||||
// `assertRecipeVisible` already guaranteed the recipe exists and is
|
||||
// visible — this second read just pulls its written yield, needed to
|
||||
// turn "portions requested" into "portions to actually prepare"
|
||||
// (a whole number of batches, see `computeBatchedPortions`). The
|
||||
// `?? 1` never triggers in practice (the assert above threw otherwise);
|
||||
// it only keeps TypeScript happy about `findUnique`'s `| null`.
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: input.recipeId },
|
||||
select: { portions: true },
|
||||
});
|
||||
const { portions } = computeBatchedPortions(recipe?.portions ?? 1, input.portionsRequested);
|
||||
|
||||
const weekStart = getWeekStart(toDateOnly(date));
|
||||
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
||||
|
||||
|
|
@ -191,22 +153,23 @@ export async function addPlanningItem(
|
|||
weekDay: input.weekDay,
|
||||
meal: input.meal,
|
||||
recipeId: input.recipeId,
|
||||
portions,
|
||||
portionsRequested: input.portionsRequested,
|
||||
portions: input.portions,
|
||||
},
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
analytics.recordEvent("planning.item_added", {
|
||||
actorId: viewerId,
|
||||
context: {
|
||||
recipeId: input.recipeId,
|
||||
portions,
|
||||
portionsRequested: input.portionsRequested,
|
||||
},
|
||||
context: { recipeId: input.recipeId, portions: input.portions },
|
||||
});
|
||||
|
||||
return toPlanningItemView(item);
|
||||
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
|
||||
}
|
||||
|
|
@ -234,139 +197,3 @@ export async function removePlanningItem(id: number, houseId: number | null): Pr
|
|||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a `PlanningItem` (scoped to `houseId`) with everything
|
||||
* {@link computeAvailableLeftover} needs — its own portion fields plus how
|
||||
* much of its pool has already been served elsewhere (`leftoverUses`) — or
|
||||
* throws `404 PLANNING_ITEM_NOT_FOUND` (same "don't confirm what exists"
|
||||
* posture as `removePlanningItem`). Shared by
|
||||
* {@link assignLeftoverPlanningItem} and {@link discardLeftover}.
|
||||
*/
|
||||
async function loadItemWithLeftoverPool(id: number, houseId: number | null) {
|
||||
const item = await prisma.planningItem.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
planning: { select: { houseId: true } },
|
||||
leftoverUses: { select: { portions: true } },
|
||||
recipe: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
||||
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves `input.portions` of another item's leftover pool on the
|
||||
* `(date, weekDay, meal)` slot — creates a "leftovers" `PlanningItem`
|
||||
* (`sourcePlanningItemId` set) on the same recipe as its source. Nothing
|
||||
* new is cooked for it, so `portions === portionsRequested` and it excludes
|
||||
* itself from the shopping list / cooking session (see those services'
|
||||
* `where: { sourcePlanningItemId: null }`).
|
||||
*
|
||||
* The source's `recipeId` is copied, never taken from the client (a
|
||||
* leftovers item can only be its source's recipe). `input.portions` is
|
||||
* re-checked against the pool actually still available
|
||||
* ({@link computeAvailableLeftover}) — trusting a client value here would
|
||||
* let a household conjure portions it never cooked.
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null`.
|
||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `sourceItemId` doesn't exist, belongs to another household, or is itself a leftovers item (no leftovers-on-leftovers).
|
||||
* @throws {HttpError} `409 LEFTOVER_QUANTITY_EXCEEDED` if `portions` exceeds the source's still-available pool.
|
||||
*/
|
||||
export async function assignLeftoverPlanningItem(
|
||||
houseId: number | null,
|
||||
viewerId: number,
|
||||
date: DateTime,
|
||||
input: AssignLeftoverInput,
|
||||
): Promise<PlanningItemView> {
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
|
||||
const source = await loadItemWithLeftoverPool(input.sourceItemId, houseId);
|
||||
if (source.sourcePlanningItemId !== null) {
|
||||
// A leftovers item has no pool of its own — serving leftovers cooks
|
||||
// nothing, so it can't be a source for more.
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.PLANNING_ITEM_NOT_FOUND,
|
||||
`Planning item ${input.sourceItemId} is not a leftovers source`,
|
||||
);
|
||||
}
|
||||
|
||||
const available = computeAvailableLeftover(source);
|
||||
if (input.portions > available) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.LEFTOVER_QUANTITY_EXCEEDED,
|
||||
`Item ${source.id} has ${available} leftover portion(s) available, asked for ${input.portions}`,
|
||||
);
|
||||
}
|
||||
|
||||
const weekStart = getWeekStart(toDateOnly(date));
|
||||
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
||||
|
||||
const item = await prisma.planningItem.create({
|
||||
data: {
|
||||
planningId: planning.id,
|
||||
weekDay: input.weekDay,
|
||||
meal: input.meal,
|
||||
recipeId: source.recipeId,
|
||||
portions: input.portions,
|
||||
portionsRequested: input.portions,
|
||||
sourcePlanningItemId: source.id,
|
||||
},
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
analytics.recordEvent("planning.leftover_assigned", {
|
||||
actorId: viewerId,
|
||||
context: { recipeId: source.recipeId, portions: input.portions, sourceItemId: source.id },
|
||||
});
|
||||
|
||||
return toPlanningItemView(item);
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws away the whole still-available leftover pool of item `id`
|
||||
* (`discardedPortions += available`) — the basket's "jeter" button.
|
||||
* Idempotent: an item with nothing left to discard is returned unchanged,
|
||||
* no event recorded.
|
||||
*
|
||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` — same guard as `removePlanningItem`.
|
||||
*/
|
||||
export async function discardLeftover(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
houseId: number | null,
|
||||
): Promise<PlanningItemView> {
|
||||
try {
|
||||
const item = await loadItemWithLeftoverPool(id, houseId);
|
||||
const available = computeAvailableLeftover(item);
|
||||
if (available === 0) {
|
||||
return toPlanningItemView(item);
|
||||
}
|
||||
|
||||
const updated = await prisma.planningItem.update({
|
||||
where: { id },
|
||||
data: { discardedPortions: item.discardedPortions + available },
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
analytics.recordEvent("planning.leftover_discarded", {
|
||||
actorId: viewerId,
|
||||
context: { recipeId: item.recipe.id, portions: available },
|
||||
});
|
||||
|
||||
return toPlanningItemView(updated);
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
type CompletedToursView,
|
||||
completeTourSchema,
|
||||
ErrorCode,
|
||||
updateAllergiesSchema,
|
||||
updateDietSchema,
|
||||
updateDislikedIngredientsSchema,
|
||||
|
|
@ -12,9 +8,7 @@ import { Router } from "express";
|
|||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import {
|
||||
getAllergyIds,
|
||||
getCompletedTourKeys,
|
||||
getDislikedIngredientIds,
|
||||
markTourSeen,
|
||||
updateAllergies,
|
||||
updateDiet,
|
||||
updateDislikedIngredients,
|
||||
|
|
@ -23,14 +17,6 @@ import {
|
|||
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
|
||||
export const profileRouter = Router();
|
||||
|
||||
/** Express types a route param `string | undefined` even for a segment that always matches when the route does — satisfies TS, unreachable in practice. */
|
||||
function requireParam(value: string | undefined): string {
|
||||
if (value === undefined) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */
|
||||
profileRouter.patch(
|
||||
"/diet",
|
||||
|
|
@ -84,29 +70,3 @@ profileRouter.patch(
|
|||
res.status(200).json(ids);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Which guided-tour scenarios this profile has already seen — the web tour engine reads this once per session (issues #42/#44). */
|
||||
profileRouter.get(
|
||||
"/tours",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||
const completedTourKeys = await getCompletedTourKeys(res.locals.userProfile.id);
|
||||
res.status(200).json({ completedTourKeys } satisfies CompletedToursView);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Marks a guided-tour scenario as seen — idempotent. An empty body means
|
||||
* "completed"; `{ "skipped": true }` means the user dismissed it (different
|
||||
* analytics event, same "don't re-nag" effect). 404s on an unknown/disabled
|
||||
* tour key.
|
||||
*/
|
||||
profileRouter.post(
|
||||
"/tours/:tourKey/complete",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const { skipped } = completeTourSchema.parse(req.body ?? {});
|
||||
await markTourSeen(res.locals.userProfile.id, requireParam(req.params.tourKey), skipped);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { analytics } from "../../lib/analytics.service.js";
|
||||
import { toSafeProfile } from "../../lib/safe-profile.js";
|
||||
|
||||
/**
|
||||
|
|
@ -150,57 +149,3 @@ export async function updateDislikedIngredients(
|
|||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `key`s of every guided-tour scenario this profile has already seen
|
||||
* (completed or skipped — both leave a `TourProgress` row). The web tour
|
||||
* engine reads this once per session to decide which tours to auto-start
|
||||
* (issues #42/#44).
|
||||
*/
|
||||
export async function getCompletedTourKeys(userProfileId: number): Promise<string[]> {
|
||||
try {
|
||||
const rows = await prisma.tourProgress.findMany({
|
||||
where: { userProfileId },
|
||||
select: { scenario: { select: { key: true } } },
|
||||
});
|
||||
return rows.map((row) => row.scenario.key);
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a guided-tour scenario as seen by this profile — idempotent upsert,
|
||||
* safe to call again (e.g. the engine retries after a flaky network). Emits
|
||||
* `tour.completed` or `tour.skipped` for the admin dashboard (issue #44's
|
||||
* analytics — the dashboard renders any new `AnalyticsEvent` type
|
||||
* generically, no frontend change needed).
|
||||
*
|
||||
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `tourKey` doesn't match an
|
||||
* **enabled** `TourScenario` (a disabled tour can't be completed).
|
||||
*/
|
||||
export async function markTourSeen(
|
||||
userProfileId: number,
|
||||
tourKey: string,
|
||||
skipped: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const scenario = await prisma.tourScenario.findUnique({ where: { key: tourKey } });
|
||||
if (!scenario?.enabled) {
|
||||
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No enabled tour with key ${tourKey}`);
|
||||
}
|
||||
|
||||
await prisma.tourProgress.upsert({
|
||||
where: { userProfileId_scenarioId: { userProfileId, scenarioId: scenario.id } },
|
||||
update: {},
|
||||
create: { userProfileId, scenarioId: scenario.id },
|
||||
});
|
||||
|
||||
analytics.recordEvent(skipped ? "tour.skipped" : "tour.completed", {
|
||||
actorId: userProfileId,
|
||||
context: { tourKey },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
|
|
@ -33,24 +32,22 @@ const correctionInclude = {
|
|||
} satisfies Prisma.StepTechStepCorrectionInclude;
|
||||
|
||||
/**
|
||||
* Loads `stepId`'s current `description` (a correction needs its full text
|
||||
* now, not just its length — see {@link submitTechStepCorrection}'s
|
||||
* `raw` derivation for a manual temperature mention), or throws —
|
||||
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
|
||||
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
|
||||
* meaningless otherwise — a request naming a real step under the wrong
|
||||
* recipe should look identical to naming one that doesn't exist, same
|
||||
* "don't leak which part was wrong" posture `assertRecipeVisible` already
|
||||
* has for visibility). Otherwise whatever {@link assertRecipeVisible}
|
||||
* throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but
|
||||
* isn't visible to the viewer.
|
||||
* 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; description: string }> {
|
||||
): Promise<{ id: number; descriptionLength: number }> {
|
||||
try {
|
||||
const step = await prisma.step.findUnique({
|
||||
where: { id: stepId },
|
||||
|
|
@ -60,7 +57,7 @@ async function loadVisibleStepOrThrow(
|
|||
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
||||
}
|
||||
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
||||
return { id: step.id, description: step.description };
|
||||
return { id: step.id, descriptionLength: step.description.length };
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
|
|
@ -211,21 +208,6 @@ interface ManualUtensilMention {
|
|||
start: number;
|
||||
end: number;
|
||||
}
|
||||
/**
|
||||
* Same as {@link ManualIngredientMention}, for a temperature — `raw` is
|
||||
* already resolved by the caller ({@link submitTechStepCorrection}, which
|
||||
* has the step's full `description` text to slice) rather than trusted
|
||||
* from the client; see `manualStepTechStepTemperatureInputSchema`'s own
|
||||
* doc comment (`packages/shared`) for why.
|
||||
*/
|
||||
interface ManualTemperatureMention {
|
||||
celsius: number | null;
|
||||
gasMark: number | null;
|
||||
qualitative: "low" | "medium" | "high" | null;
|
||||
raw: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
|
||||
|
|
@ -251,16 +233,16 @@ interface ManualTemperatureMention {
|
|||
*
|
||||
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
||||
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
||||
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
|
||||
* `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the
|
||||
* classifier's own detection) and any earlier `"manual"` set alike — with
|
||||
* the newly-submitted one. This is "le manuel remplace tout" (confirmed
|
||||
* with the user): the resolved `order` this technique ends up at
|
||||
* (whichever branch above produced it) is the same `techStepOrder` all
|
||||
* three metadata tables key on, so the same `deleteMany` + `createMany`
|
||||
* pair below is correct whether this call just updated an existing row
|
||||
* (which may already carry auto-detected metadata) or created a brand new
|
||||
* one (nothing to delete yet — a no-op `deleteMany`, not a special case).
|
||||
* 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
|
||||
|
|
@ -273,11 +255,7 @@ async function applyManualCorrection(
|
|||
span: { start: number; end: number },
|
||||
previousTechStepId: number | null,
|
||||
correctedTechStepId: number | null,
|
||||
metadata?: {
|
||||
ingredients: ManualIngredientMention[];
|
||||
utensils: ManualUtensilMention[];
|
||||
temperatures: ManualTemperatureMention[];
|
||||
},
|
||||
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
|
||||
): Promise<void> {
|
||||
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
||||
|
||||
|
|
@ -351,22 +329,6 @@ async function applyManualCorrection(
|
|||
})),
|
||||
});
|
||||
}
|
||||
await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } });
|
||||
if (metadata.temperatures.length > 0) {
|
||||
await tx.stepTechStepTemperature.createMany({
|
||||
data: metadata.temperatures.map((temperature) => ({
|
||||
stepId,
|
||||
techStepOrder: order,
|
||||
celsius: temperature.celsius,
|
||||
gasMark: temperature.gasMark,
|
||||
qualitative: temperature.qualitative,
|
||||
raw: temperature.raw,
|
||||
start: temperature.start,
|
||||
end: temperature.end,
|
||||
source: "manual",
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (target) {
|
||||
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
||||
|
|
@ -381,20 +343,10 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
|
|||
start: correction.start,
|
||||
end: correction.end,
|
||||
previousTechStep: correction.previousTechStep
|
||||
? {
|
||||
id: correction.previousTechStep.id,
|
||||
key: correction.previousTechStep.key,
|
||||
isPlaceholder: correction.previousTechStep.isPlaceholder,
|
||||
displayName: correction.previousTechStep.displayName,
|
||||
}
|
||||
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
||||
: null,
|
||||
correctedTechStep: correction.correctedTechStep
|
||||
? {
|
||||
id: correction.correctedTechStep.id,
|
||||
key: correction.correctedTechStep.key,
|
||||
isPlaceholder: correction.correctedTechStep.isPlaceholder,
|
||||
displayName: correction.correctedTechStep.displayName,
|
||||
}
|
||||
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
||||
: null,
|
||||
createdAt: correction.createdAt.toISOString(),
|
||||
};
|
||||
|
|
@ -414,11 +366,10 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
|
|||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
||||
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
||||
* `start`/`end` (the correction's own span, or any of
|
||||
* `input.ingredients`/`input.utensils`/`input.temperatures`' own spans)
|
||||
* fall outside the step's current `description` (it may have been
|
||||
* edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/
|
||||
* `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND`
|
||||
* if any referenced id
|
||||
* `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(
|
||||
|
|
@ -430,20 +381,18 @@ export async function submitTechStepCorrection(
|
|||
): Promise<SubmitTechStepCorrectionResult> {
|
||||
try {
|
||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||
const descriptionLength = step.description.length;
|
||||
|
||||
const spans = [
|
||||
{ start: input.start, end: input.end },
|
||||
...(input.ingredients ?? []),
|
||||
...(input.utensils ?? []),
|
||||
...(input.temperatures ?? []),
|
||||
];
|
||||
for (const span of spans) {
|
||||
if (span.start >= descriptionLength || span.end > descriptionLength) {
|
||||
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 ${descriptionLength})`,
|
||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -460,109 +409,71 @@ export async function submitTechStepCorrection(
|
|||
);
|
||||
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
||||
|
||||
const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// A technique the catalog doesn't have (issue #130) — mint a
|
||||
// placeholder `TechStep` for it, in the same transaction as the
|
||||
// correction that references it, then proceed exactly as if its id had
|
||||
// been sent as `correctedTechStepId`. Same shape as
|
||||
// `recipe.service.ts`'s `resolveIngredientLines` does for a
|
||||
// free-text ingredient.
|
||||
const placeholderTechStepId =
|
||||
input.correctedTechStepName !== undefined
|
||||
? (
|
||||
await tx.techStep.create({
|
||||
data: {
|
||||
key: `placeholder:${randomUUID()}`,
|
||||
isPlaceholder: true,
|
||||
displayName: input.correctedTechStepName,
|
||||
createdById: correctorId,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).id
|
||||
: null;
|
||||
const effectiveCorrectedTechStepId =
|
||||
placeholderTechStepId ?? input.correctedTechStepId ?? null;
|
||||
const { 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,
|
||||
});
|
||||
|
||||
const createdCorrection = await tx.stepTechStepCorrection.create({
|
||||
data: {
|
||||
stepId: step.id,
|
||||
correctorId,
|
||||
start: input.start,
|
||||
end: input.end,
|
||||
previousTechStepId: input.previousTechStepId ?? null,
|
||||
correctedTechStepId: effectiveCorrectedTechStepId,
|
||||
},
|
||||
include: correctionInclude,
|
||||
});
|
||||
|
||||
await applyManualCorrection(
|
||||
tx,
|
||||
step.id,
|
||||
{ start: input.start, end: input.end },
|
||||
input.previousTechStepId ?? null,
|
||||
effectiveCorrectedTechStepId,
|
||||
input.ingredients === undefined &&
|
||||
input.utensils === undefined &&
|
||||
input.temperatures === undefined
|
||||
? undefined
|
||||
: {
|
||||
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unitId: ingredient.unitId ?? null,
|
||||
start: ingredient.start,
|
||||
end: ingredient.end,
|
||||
})),
|
||||
utensils: (input.utensils ?? []).map((utensil) => ({
|
||||
utensilId: utensil.utensilId,
|
||||
start: utensil.start,
|
||||
end: utensil.end,
|
||||
})),
|
||||
// `raw` is derived here (not sent by the client) — see
|
||||
// `ManualTemperatureMention`'s own doc comment.
|
||||
temperatures: (input.temperatures ?? []).map((temperature) => ({
|
||||
celsius: temperature.celsius ?? null,
|
||||
gasMark: temperature.gasMark ?? null,
|
||||
qualitative: temperature.qualitative ?? null,
|
||||
raw: step.description.slice(temperature.start, temperature.end),
|
||||
start: temperature.start,
|
||||
end: temperature.end,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
// Same nested `ingredients`/`utensils`/`temperatures` include as
|
||||
// `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews`
|
||||
// (reused below) expects it, so the fresh sequence read right after a
|
||||
// manual correction resolves exactly the same way a normal
|
||||
// `GET /recipes/:id` would.
|
||||
const freshTechSteps = await tx.stepTechStep.findMany({
|
||||
where: { stepId: step.id },
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
techStep: true,
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
unit: true,
|
||||
},
|
||||
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,
|
||||
})),
|
||||
},
|
||||
utensils: { include: { utensil: true } },
|
||||
temperatures: true,
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId };
|
||||
},
|
||||
);
|
||||
// Same nested `ingredients`/`utensils`/`temperatures` include as
|
||||
// `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews`
|
||||
// (reused below) expects it, so the fresh sequence read right after a
|
||||
// manual correction resolves exactly the same way a normal
|
||||
// `GET /recipes/:id` would.
|
||||
const freshTechSteps = await tx.stepTechStep.findMany({
|
||||
where: { stepId: step.id },
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
techStep: true,
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
utensils: { include: { utensil: true } },
|
||||
temperatures: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { correction: createdCorrection, techSteps: freshTechSteps };
|
||||
});
|
||||
|
||||
analytics.recordEvent("tech_step.correction_submitted", {
|
||||
actorId: correctorId,
|
||||
|
|
@ -570,20 +481,9 @@ export async function submitTechStepCorrection(
|
|||
recipeId,
|
||||
stepId,
|
||||
previousTechStepId: input.previousTechStepId ?? null,
|
||||
correctedTechStepId: correction.correctedTechStepId,
|
||||
correctedTechStepId: input.correctedTechStepId ?? null,
|
||||
},
|
||||
});
|
||||
if (placeholderTechStepId !== null) {
|
||||
analytics.recordEvent("tech_step.placeholder_proposed", {
|
||||
actorId: correctorId,
|
||||
context: {
|
||||
recipeId,
|
||||
stepId,
|
||||
techStepId: placeholderTechStepId,
|
||||
name: input.correctedTechStepName ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { HttpError } from "@batch-cooking/error-tools";
|
|||
import {
|
||||
type AllergyView,
|
||||
type CreateRecipeInput,
|
||||
computeRecipeDietViews,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type IngredientView,
|
||||
|
|
@ -21,7 +20,6 @@ import {
|
|||
type TechStepMatch,
|
||||
techStepClassifier,
|
||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||
import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-persistence.js";
|
||||
|
||||
// No user-language preference exists anywhere in the app yet (a single
|
||||
// "fr" translation file, no locale field on User/UserProfile) — steps are
|
||||
|
|
@ -30,7 +28,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
|
|||
// 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 (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */
|
||||
/** 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: {
|
||||
|
|
@ -75,38 +73,18 @@ function recipeInclude(viewerId: number) {
|
|||
},
|
||||
},
|
||||
},
|
||||
diets: { include: { diet: true } },
|
||||
favoritedBy: { where: { userProfileId: viewerId } },
|
||||
} satisfies Prisma.RecipeInclude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reference `Diet` row (`{ id, key }`), loaded once and memoized —
|
||||
* `Diet` is seeded reference data that never changes at runtime, so caching
|
||||
* it for the process's lifetime is safe. Used to attach a real `DietView` to
|
||||
* the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to
|
||||
* resolve the planning picker's `dietIds` filter / a household's members'
|
||||
* regimes for the post-filter in {@link listRecipes}.
|
||||
*/
|
||||
let referenceDietsPromise: Promise<DietView[]> | null = null;
|
||||
function loadReferenceDiets(): Promise<DietView[]> {
|
||||
if (referenceDietsPromise === null) {
|
||||
referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } });
|
||||
}
|
||||
return referenceDietsPromise;
|
||||
}
|
||||
|
||||
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
|
||||
async function loadGlutenFreeDiet(): Promise<DietView | undefined> {
|
||||
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
|
||||
}
|
||||
|
||||
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`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */
|
||||
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>;
|
||||
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
|
||||
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||
|
||||
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
||||
export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||
|
|
@ -142,6 +120,10 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
|
|||
};
|
||||
}
|
||||
|
||||
function toDietView(diet: { id: number; key: string }): DietView {
|
||||
return { id: diet.id, key: diet.key };
|
||||
}
|
||||
|
||||
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
||||
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||
const byId = new Map<number, AllergyView>();
|
||||
|
|
@ -153,13 +135,10 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
|||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */
|
||||
function toRecipeSummaryView(
|
||||
recipe: RecipeWithDetails,
|
||||
glutenFreeDiet: DietView | undefined,
|
||||
): RecipeSummaryView {
|
||||
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
|
||||
toIngredientView(recipeIngredient.ingredient),
|
||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
|
||||
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||
const allergens = aggregateAllergens(
|
||||
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
|
||||
);
|
||||
return {
|
||||
id: recipe.id,
|
||||
|
|
@ -169,9 +148,8 @@ function toRecipeSummaryView(
|
|||
portions: recipe.portions,
|
||||
authorId: recipe.authorId,
|
||||
visibility: recipe.visibility,
|
||||
allergens: aggregateAllergens(ingredientViews),
|
||||
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`.
|
||||
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
|
||||
allergens,
|
||||
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
|
||||
isFavorite: recipe.favoritedBy.length > 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -211,12 +189,7 @@ export function toStepTechStepViews(
|
|||
} = stepTechStep;
|
||||
if (start === null || end === null) continue;
|
||||
views.push({
|
||||
techStep: {
|
||||
id: techStep.id,
|
||||
key: techStep.key,
|
||||
isPlaceholder: techStep.isPlaceholder,
|
||||
displayName: techStep.displayName,
|
||||
},
|
||||
techStep: { id: techStep.id, key: techStep.key },
|
||||
start,
|
||||
end,
|
||||
// `source` is a plain DB `String`, not a Prisma enum (see
|
||||
|
|
@ -266,16 +239,15 @@ export function toStepTechStepViews(
|
|||
return views;
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
|
||||
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView {
|
||||
/** 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),
|
||||
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
|
||||
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity),
|
||||
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
||||
quantity: Number(recipeIngredient.quantity),
|
||||
unit: toUnitView(recipeIngredient.unit),
|
||||
}));
|
||||
return {
|
||||
...toRecipeSummaryView(recipe, glutenFreeDiet),
|
||||
...toRecipeSummaryView(recipe),
|
||||
ingredients,
|
||||
steps: recipe.steps.map((step) => ({
|
||||
id: step.id,
|
||||
|
|
@ -328,57 +300,54 @@ function visibleToViewerWhere(
|
|||
}
|
||||
|
||||
/**
|
||||
* The two halves of the planning recipe picker's "convient à tout le foyer"
|
||||
* toggle (`suitableForHousehold` on `listRecipes`):
|
||||
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that
|
||||
* contains an allergen declared by a member (still expressible in SQL).
|
||||
* - `requiredDietKeys` — every regime declared by a member (`omnivore`
|
||||
* dropped: it constrains nothing). A recipe must satisfy *all* of them —
|
||||
* a recipe suiting a vegetarian member doesn't automatically suit a
|
||||
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the
|
||||
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a
|
||||
* stored column to query (issue #131).
|
||||
*
|
||||
* Computed server-side (a small extra query for the members' allergy/regime
|
||||
* data) 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. Deliberately excludes
|
||||
* `UserProfileDislikedIngredient` — a taste preference, not a safety
|
||||
* constraint (see that model's doc comment).
|
||||
* `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 suitableForHousehold(
|
||||
houseId: number,
|
||||
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
|
||||
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
|
||||
try {
|
||||
const members = await prisma.userProfile.findMany({
|
||||
where: { houseId },
|
||||
select: {
|
||||
diet: { select: { key: true } },
|
||||
allergies: { select: { allergyId: true } },
|
||||
},
|
||||
select: { dietId: true, allergies: { select: { allergyId: true } } },
|
||||
});
|
||||
const requiredDietKeys = [
|
||||
...new Set(
|
||||
members
|
||||
.map((member) => member.diet?.key)
|
||||
.filter((key): key is string => key !== undefined && key !== "omnivore"),
|
||||
),
|
||||
const requiredDietIds = [
|
||||
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
|
||||
];
|
||||
const excludedAllergyIds = [
|
||||
...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))),
|
||||
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
|
||||
];
|
||||
|
||||
const allergenWhere: Prisma.RecipeWhereInput =
|
||||
excludedAllergyIds.length > 0
|
||||
? {
|
||||
ingredients: {
|
||||
none: {
|
||||
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } },
|
||||
},
|
||||
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 { allergenWhere, requiredDietKeys };
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
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,
|
||||
|
|
@ -412,7 +381,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
|
|||
).map((row) => row.sourceId);
|
||||
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -426,11 +395,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
|
|||
export interface ListRecipesFilters {
|
||||
/** Case-insensitive name substring. */
|
||||
search?: string;
|
||||
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
|
||||
/** 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's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */
|
||||
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
|
||||
dietIds?: number[];
|
||||
}
|
||||
|
||||
|
|
@ -450,32 +419,31 @@ export async function listRecipes(
|
|||
filters: ListRecipesFilters = {},
|
||||
): Promise<RecipeSummaryView[]> {
|
||||
try {
|
||||
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters;
|
||||
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||
if (search) {
|
||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||
}
|
||||
// Regimes are computed from ingredients, not a stored column (issue
|
||||
// #131), so the household-regime and `dietIds` filters can't be SQL
|
||||
// anymore — they run as post-filters on the mapped summaries below.
|
||||
// Everything that still *can* be a `where` stays one.
|
||||
let requiredDietKeys: string[] = [];
|
||||
// No-op without a household — nothing to filter against, same posture as
|
||||
// the `foyer` tab returning everything it can rather than throwing.
|
||||
if (suitable && viewerHouseId !== null) {
|
||||
const household = await suitableForHousehold(viewerHouseId);
|
||||
conditions.push(household.allergenWhere);
|
||||
requiredDietKeys = household.requiredDietKeys;
|
||||
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.
|
||||
// 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":
|
||||
|
|
@ -495,38 +463,14 @@ export async function listRecipes(
|
|||
break;
|
||||
}
|
||||
|
||||
const [recipes, referenceDiets] = await Promise.all([
|
||||
prisma.recipe.findMany({
|
||||
where: { AND: conditions },
|
||||
include: recipeInclude(viewerId),
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
loadReferenceDiets(),
|
||||
]);
|
||||
const glutenFreeDiet = referenceDiets.find((diet) => diet.key === "glutenFree");
|
||||
|
||||
let summaries = recipes.map((recipe) => toRecipeSummaryView(recipe, glutenFreeDiet));
|
||||
|
||||
// Post-filters on the *computed* `diets` (see the comment where the SQL
|
||||
// conditions are built). "Every one, not any one" for both.
|
||||
if (requiredDietKeys.length > 0) {
|
||||
summaries = summaries.filter((summary) =>
|
||||
requiredDietKeys.every((key) => summary.diets.some((diet) => diet.key === key)),
|
||||
);
|
||||
}
|
||||
if (dietIds && dietIds.length > 0) {
|
||||
// `omnivore` in the filter would match nothing (it's never emitted) —
|
||||
// treat it as "no constraint" rather than silently returning an empty list.
|
||||
const requiredIds = dietIds.filter(
|
||||
(id) => referenceDiets.find((diet) => diet.id === id)?.key !== "omnivore",
|
||||
);
|
||||
summaries = summaries.filter((summary) =>
|
||||
requiredIds.every((id) => summary.diets.some((diet) => diet.id === id)),
|
||||
);
|
||||
}
|
||||
return summaries;
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
where: { AND: conditions },
|
||||
include: recipeInclude(viewerId),
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return recipes.map(toRecipeSummaryView);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -545,23 +489,23 @@ export async function getRecipe(
|
|||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
return toRecipeView(recipe, await loadGlutenFreeDiet());
|
||||
return toRecipeView(recipe);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a recipe with its ingredients and ordered steps in one go —
|
||||
* steps' `order` is derived from their position in `input.steps`,
|
||||
* Creates a recipe with its ingredients, ordered steps and diet tags in one
|
||||
* go — steps' `order` is derived from their position in `input.steps`,
|
||||
* ingredients reference existing reference `Ingredient` rows by id (see
|
||||
* `GET /reference/ingredients`; there's no way to create one here). Its
|
||||
* regimes aren't stored — they're computed from the ingredients on read
|
||||
* (issue #131). `authorId`/`authorHouseId` are fixed at creation and never
|
||||
* change on later edits (see {@link updateRecipe}).
|
||||
* `GET /reference/ingredients`; there's no way to create one here).
|
||||
* `authorId`/`authorHouseId` are fixed at creation and never change on
|
||||
* later edits (see {@link updateRecipe}).
|
||||
*
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function createRecipe(
|
||||
input: CreateRecipeInput,
|
||||
|
|
@ -571,7 +515,7 @@ export async function createRecipe(
|
|||
try {
|
||||
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -588,6 +532,7 @@ export async function createRecipe(
|
|||
*
|
||||
* @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,
|
||||
|
|
@ -598,7 +543,7 @@ export async function createImportedRecipe(
|
|||
try {
|
||||
return await createRecipeInternal(input, authorId, authorHouseId, source);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -632,11 +577,11 @@ async function matchStepsTechSteps<T extends { description: string }>(
|
|||
);
|
||||
}
|
||||
|
||||
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */
|
||||
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */
|
||||
interface ResolvedIngredientLine {
|
||||
ingredientId: number;
|
||||
quantity: number | null;
|
||||
unitId: number | null;
|
||||
quantity: number;
|
||||
unitId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -669,8 +614,8 @@ async function resolveIngredientLines(
|
|||
if (line.ingredientId !== undefined) {
|
||||
resolved.push({
|
||||
ingredientId: line.ingredientId,
|
||||
quantity: line.quantity ?? null,
|
||||
unitId: line.unitId ?? null,
|
||||
quantity: line.quantity,
|
||||
unitId: line.unitId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -686,16 +631,12 @@ async function resolveIngredientLines(
|
|||
},
|
||||
select: { id: true },
|
||||
});
|
||||
resolved.push({
|
||||
ingredientId: placeholder.id,
|
||||
quantity: line.quantity ?? null,
|
||||
unitId: line.unitId ?? null,
|
||||
});
|
||||
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
|
||||
createdPlaceholders.push({ id: placeholder.id, name });
|
||||
}
|
||||
return { resolved, createdPlaceholders };
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -709,9 +650,8 @@ async function createRecipeInternal(
|
|||
await assertIngredientsExist(
|
||||
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
|
||||
);
|
||||
await assertUnitsExist(
|
||||
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
||||
);
|
||||
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
|
||||
|
|
@ -754,13 +694,45 @@ async function createRecipeInternal(
|
|||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
// Technique rows + their clause metadata (ingredients /
|
||||
// utensils / temperatures) — see `toStepTechStepCreateInputs`.
|
||||
// `updateRecipe` below builds the exact same payload from the
|
||||
// same helper so a resave never drops what a create persisted.
|
||||
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
||||
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,
|
||||
})),
|
||||
},
|
||||
temperatures: {
|
||||
create: match.temperatures.map((temperature) => ({
|
||||
start: temperature.start,
|
||||
end: temperature.end,
|
||||
celsius: temperature.celsius,
|
||||
gasMark: temperature.gasMark,
|
||||
qualitative: temperature.qualitative,
|
||||
raw: temperature.raw,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
include: recipeInclude(authorId),
|
||||
});
|
||||
|
|
@ -778,25 +750,24 @@ async function createRecipeInternal(
|
|||
});
|
||||
}
|
||||
|
||||
return toRecipeView(created, await loadGlutenFreeDiet());
|
||||
return toRecipeView(created);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||
* and the complete ingredient/step lists (not a partial merge: a line
|
||||
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
||||
* missing from `input` is removed, same contract as `PATCH
|
||||
* /profile/allergies`). Regimes aren't part of the payload — they're
|
||||
* recomputed from the ingredients on the next read (issue #131).
|
||||
* `authorId`/`authorHouseId` are untouched — editing never transfers
|
||||
* ownership.
|
||||
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
||||
* never transfers ownership.
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function updateRecipe(
|
||||
id: number,
|
||||
|
|
@ -811,9 +782,8 @@ export async function updateRecipe(
|
|||
.map((i) => i.ingredientId)
|
||||
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
|
||||
);
|
||||
await assertUnitsExist(
|
||||
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
||||
);
|
||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||
await assertDietsExist(input.dietIds);
|
||||
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||
|
||||
// Interactive transaction (not the array form) so any new free-text
|
||||
|
|
@ -824,6 +794,7 @@ export async function updateRecipe(
|
|||
const createdPlaceholders = await prisma.$transaction(async (tx) => {
|
||||
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
||||
await tx.step.deleteMany({ where: { recipeId: id } });
|
||||
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
|
||||
const { resolved, createdPlaceholders } = await resolveIngredientLines(
|
||||
input.ingredients,
|
||||
viewerId,
|
||||
|
|
@ -849,15 +820,19 @@ export async function updateRecipe(
|
|||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
// Same builder as `createRecipeInternal` — a resave must
|
||||
// persist the clause metadata (ingredients / utensils /
|
||||
// temperatures), not just the technique span. Omitting it
|
||||
// here is what left every edited recipe with temperature-less
|
||||
// `preheat` steps, so `cooking-optimizer.ts` could never spot
|
||||
// an oven-temperature conflict between two of them.
|
||||
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
||||
techSteps: {
|
||||
create: matches.map((match, order) => ({
|
||||
techStepId: match.techStepId,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
contextStart: match.contextStart,
|
||||
contextEnd: match.contextEnd,
|
||||
order,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
});
|
||||
return createdPlaceholders;
|
||||
|
|
@ -870,9 +845,9 @@ export async function updateRecipe(
|
|||
});
|
||||
}
|
||||
|
||||
return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet());
|
||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -905,7 +880,7 @@ export async function deleteRecipe(
|
|||
|
||||
await prisma.recipe.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -933,7 +908,7 @@ export async function addFavorite(
|
|||
create: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -944,7 +919,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
|
|||
where: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -967,7 +942,7 @@ export async function assertRecipeVisible(
|
|||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -983,7 +958,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
|
|||
}
|
||||
return recipe;
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1002,7 +977,7 @@ async function assertIsAuthor(
|
|||
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1024,7 +999,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
|||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1046,6 +1021,29 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
|||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
||||
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||
try {
|
||||
const uniqueIds = [...new Set(dietIds)];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const found = await prisma.diet.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((diet) => diet.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.DIET_NOT_FOUND,
|
||||
`Diet(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
getIngredients,
|
||||
getSources,
|
||||
getTechSteps,
|
||||
getTours,
|
||||
getUnits,
|
||||
getUtensils,
|
||||
} from "./reference.service.js";
|
||||
|
|
@ -70,14 +69,3 @@ referenceRouter.get(
|
|||
res.status(200).json(await getSources());
|
||||
}),
|
||||
);
|
||||
|
||||
// Enabled guided-tour scenarios + their steps — read by the web tour
|
||||
// engine (issue #42). Public like every route here: the general tour can
|
||||
// start right after signup, before onboarding finishes. Admin CRUD of
|
||||
// these lives under `/admin/api/tours` (issue #44).
|
||||
referenceRouter.get(
|
||||
"/tours",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getTours());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type {
|
|||
IngredientView,
|
||||
SourceView,
|
||||
TechStepView,
|
||||
TourScenarioView,
|
||||
UnitView,
|
||||
UtensilView,
|
||||
} from "@batch-cooking/shared";
|
||||
|
|
@ -77,21 +76,11 @@ export async function getUnits(): Promise<UnitView[]> {
|
|||
/**
|
||||
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
||||
* for why) — small, static list (see `reference-seed-data.ts`'s
|
||||
* `TECH_STEPS`).
|
||||
*
|
||||
* Excludes placeholder rows (`TechStep.isPlaceholder` — the free-text
|
||||
* techniques users propose when the catalog falls short, issue #130): this
|
||||
* is the *browsable* catalog, and a placeholder is a per-correction
|
||||
* stand-in, not a real entry anyone should pick again.
|
||||
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
||||
*/
|
||||
export async function getTechSteps(): Promise<TechStepView[]> {
|
||||
try {
|
||||
const techSteps = await prisma.techStep.findMany({
|
||||
where: { isPlaceholder: false },
|
||||
select: { id: true, key: true },
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null }));
|
||||
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
|
|
@ -184,32 +173,3 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
|||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every **enabled** guided-tour scenario with its ordered steps, for the
|
||||
* web tour engine (issues #42/#44). A disabled scenario is hidden here
|
||||
* without being deleted (an admin can toggle it back on). Steps come back
|
||||
* ordered; `route`/`title`/`content` pass straight through.
|
||||
*/
|
||||
export async function getTours(): Promise<TourScenarioView[]> {
|
||||
try {
|
||||
const scenarios = await prisma.tourScenario.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { key: "asc" },
|
||||
include: { steps: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
return scenarios.map((scenario) => ({
|
||||
key: scenario.key,
|
||||
name: scenario.name,
|
||||
steps: scenario.steps.map((step) => ({
|
||||
order: step.order,
|
||||
route: step.route,
|
||||
targetKey: step.targetKey,
|
||||
title: step.title,
|
||||
content: step.content,
|
||||
})),
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
|||
function shoppingListPlanningInclude() {
|
||||
return {
|
||||
items: {
|
||||
// "Leftovers" items (`sourcePlanningItemId` set) reserve a portion of
|
||||
// an already-cooked batch — nothing new to buy for them. Excluded
|
||||
// here so they never reach `aggregateShoppingList`; counting them
|
||||
// would double up the source recipe's ingredients (issue #34).
|
||||
where: { sourcePlanningItemId: null },
|
||||
include: {
|
||||
recipe: {
|
||||
select: {
|
||||
|
|
@ -44,11 +39,11 @@ 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}. `unit`/`quantity` are both `null` for a "selon le goût" seasoning line: nothing to sum, surfaced once. */
|
||||
/** 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 | null;
|
||||
quantity: number | null;
|
||||
unit: UnitView;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -70,31 +65,16 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
|
|||
for (const item of items) {
|
||||
const scale = item.portions / item.recipe.portions;
|
||||
for (const recipeIngredient of item.recipe.ingredients) {
|
||||
// A "selon le goût" seasoning line (no quantity/unit) — surfaced once
|
||||
// per ingredient, never scaled or summed. Its key can't collide with a
|
||||
// real `(ingredient, unit)` line for the same ingredient.
|
||||
if (recipeIngredient.quantity === null) {
|
||||
const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`;
|
||||
if (!totals.has(seasoningKey)) {
|
||||
totals.set(seasoningKey, {
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
unit: null,
|
||||
quantity: null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
||||
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
||||
|
||||
const existing = totals.get(key);
|
||||
if (existing && existing.quantity !== null) {
|
||||
if (existing) {
|
||||
existing.quantity += addedQuantity;
|
||||
} else {
|
||||
totals.set(key, {
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
||||
unit: toUnitView(recipeIngredient.unit),
|
||||
quantity: addedQuantity,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,20 +180,9 @@ export async function previewSourceItem(
|
|||
),
|
||||
loadIngredientCatalog(adapter.locale),
|
||||
loadUnitCatalog(adapter.locale),
|
||||
// Real catalog techniques only — a draft preview is never a saved
|
||||
// recipe, so it can't carry a user-proposed placeholder technique
|
||||
// (issue #130); `isPlaceholder`/`displayName` are the neutral values.
|
||||
prisma.techStep.findMany({
|
||||
where: { isPlaceholder: false },
|
||||
select: { id: true, key: true },
|
||||
}),
|
||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||
]);
|
||||
const techStepById = new Map(
|
||||
techStepsByKey.map((techStep) => [
|
||||
techStep.id,
|
||||
{ ...techStep, isPlaceholder: false, displayName: null },
|
||||
]),
|
||||
);
|
||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||
|
||||
const translatedIngredients = translateRecipeIngredients(
|
||||
parsed.ingredients,
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { prisma } from "../db/prisma.js";
|
||||
import { backfillTechSteps } from "./backfill-tech-steps.js";
|
||||
|
||||
/**
|
||||
* `backfill_state.key` for the tech-step clause-metadata backfill. Opaque,
|
||||
* dated so a future metadata rework can add a second key rather than
|
||||
* re-using this one.
|
||||
*
|
||||
* Why it's needed: `updateRecipe` and `backfill-tech-steps.ts` used to
|
||||
* persist only a detected technique's span, never its ingredient / utensil /
|
||||
* temperature children (only `createRecipeInternal` did). So every recipe
|
||||
* ever edited through the UI, and every recipe imported before the create
|
||||
* path learned temperatures, is missing the clause metadata that
|
||||
* `cooking-optimizer.ts` needs to spot an oven-temperature conflict between
|
||||
* two `preheat` steps. {@link backfillTechSteps} recomputes the whole
|
||||
* `StepTechStep` sequence with the now-complete writer.
|
||||
*/
|
||||
const TECH_STEP_METADATA_BACKFILL_KEY = "step_tech_step_metadata_20260904";
|
||||
|
||||
/**
|
||||
* One-time data backfills for the production image — run via
|
||||
* `node dist/scripts/backfill-runtime.js` in apps/api/Dockerfile's CMD,
|
||||
* after `seed-runtime` and before the server starts.
|
||||
*
|
||||
* Unlike `seed-runtime` (idempotent by construction — it only upserts, so
|
||||
* repeating it every container start is free), a backfill here *rewrites*
|
||||
* existing rows and would be wasteful-to-harmful to repeat: the tech-step
|
||||
* one re-hits `services/tech-step-intent-service` once per `Step`. So each
|
||||
* backfill gates itself on a `backfill_state` row — present means "done,
|
||||
* skip"; absent means "run it, then record it".
|
||||
*
|
||||
* A backfill failing must **not** keep the API down: it's a data-quality
|
||||
* catch-up, not a correctness prerequisite (contrast `seed-runtime`, whose
|
||||
* failure genuinely means missing reference data). So a failure here is
|
||||
* logged, the marker is left unwritten (the next container start retries),
|
||||
* and the process still exits 0 so the CMD chain reaches `node dist/server.js`.
|
||||
* Only a failure to even read `backfill_state` propagates — at that point
|
||||
* the database is unreachable and the server wouldn't start anyway.
|
||||
*/
|
||||
async function runOneTimeBackfills(): Promise<void> {
|
||||
const alreadyDone = await prisma.backfillState.findUnique({
|
||||
where: { key: TECH_STEP_METADATA_BACKFILL_KEY },
|
||||
});
|
||||
if (alreadyDone !== null) {
|
||||
console.info(
|
||||
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" already completed at ${alreadyDone.completedAt.toISOString()} — skipping.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(`Running one-time backfill "${TECH_STEP_METADATA_BACKFILL_KEY}"...`);
|
||||
try {
|
||||
const { total, changed } = await backfillTechSteps();
|
||||
await prisma.backfillState.create({ data: { key: TECH_STEP_METADATA_BACKFILL_KEY } });
|
||||
console.info(
|
||||
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" done — ${changed}/${total} step(s) recomputed.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" failed — leaving it unrecorded so the next start retries. The API will still start.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
runOneTimeBackfills()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,27 +1,23 @@
|
|||
import { pathToFileURL } from "node:url";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
||||
import { toStepTechStepCreateInputs } from "../lib/recipe-matching/tech-step-persistence.js";
|
||||
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
|
||||
|
||||
/**
|
||||
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
|
||||
* entries — technique spans **and** their clause metadata (ingredients,
|
||||
* utensils, temperatures) — against the *current* classifier
|
||||
* 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: both build the row payload from the shared
|
||||
* {@link toStepTechStepCreateInputs}. 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).
|
||||
* 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), or before
|
||||
* the clause-metadata writers were wired into the *update* path at all,
|
||||
* keeps whatever it was matched with at the time until it's next resaved.
|
||||
* `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`)
|
||||
|
|
@ -76,19 +72,18 @@ export async function backfillTechSteps(): Promise<{ total: number; changed: num
|
|||
// 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;
|
||||
// One `create` per row rather than a single `createMany`:
|
||||
// `createMany` cannot write the nested `ingredients` / `utensils` /
|
||||
// `temperatures` children, and silently dropping those is exactly
|
||||
// the bug this script shared with the old `updateRecipe` — see
|
||||
// `toStepTechStepCreateInputs`. A backfill that leaves recipes
|
||||
// without their clause temperatures defeats the whole point of
|
||||
// running it (`cooking-optimizer.ts` needs them).
|
||||
const inputs = toStepTechStepCreateInputs(nonOverlappingMatches);
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
await tx.stepTechStep.create({
|
||||
data: { ...input, stepId: step.id, order: startOrder + index, source: "auto" },
|
||||
});
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { Server as SocketIoServer } from "socket.io";
|
||||
import { createServer } from "./app.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { logger } from "./lib/logger.service.js";
|
||||
import { MonitoringBroadcastService } from "./lib/monitoring-broadcast.service.js";
|
||||
import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
|
||||
import { registerAllRecipeSources } from "./sources/index.js";
|
||||
|
||||
|
|
@ -52,17 +50,6 @@ await warmUpTechStepClassifier();
|
|||
|
||||
const server = createServer();
|
||||
|
||||
// Attaches to the same underlying `http.Server` `server.listen()` below
|
||||
// will start — `socket.io` binds to the HTTP server itself, not to
|
||||
// Express or a port, hence `ExpressServer.httpServer` (packages/express-
|
||||
// tools) rather than going through `server.instance` (the Express app).
|
||||
// Own CORS config: the WebSocket handshake is a separate HTTP upgrade
|
||||
// request that never runs through `setupCore`'s `cors()` middleware.
|
||||
const io = new SocketIoServer(server.httpServer, {
|
||||
cors: { origin: env.CORS_ORIGIN, credentials: true },
|
||||
});
|
||||
new MonitoringBroadcastService(io).start();
|
||||
|
||||
server.listen(env.PORT, () => {
|
||||
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,17 +2,14 @@ import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-regist
|
|||
import { sevenFiftyGAdapter } from "./750g.js";
|
||||
import { mangerBougerAdapter } from "./manger-bouger.js";
|
||||
import { marmitonAdapter } from "./marmiton.js";
|
||||
import { ohMonBentoAdapter } from "./ohmonbento.js";
|
||||
import { quitoqueAdapter } from "./quitoque.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`,
|
||||
* `mangerBougerAdapter`, `quitoqueAdapter` and `ohMonBentoAdapter`. Called
|
||||
* once, explicitly, by the two real entry points that need the registry
|
||||
* populated:
|
||||
* — `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
|
||||
|
|
@ -42,6 +39,4 @@ export function registerAllRecipeSources(): void {
|
|||
registerRecipeSource(marmitonAdapter);
|
||||
registerRecipeSource(sevenFiftyGAdapter);
|
||||
registerRecipeSource(mangerBougerAdapter);
|
||||
registerRecipeSource(quitoqueAdapter);
|
||||
registerRecipeSource(ohMonBentoAdapter);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,11 @@ const SOURCE_KEY = "mangerBouger";
|
|||
// 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 for the *search* path
|
||||
// (`?diet=ALL&page=N&query=<free text>`): `query` 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 *catalogue browse* path (no `query` term) must be the bare
|
||||
// `?diet=ALL&page=N` — a trailing empty `&query=` made the deployed server
|
||||
// repeat page 1 for every page (issue #78); `list()` below now omits it.
|
||||
// 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/";
|
||||
|
||||
|
|
@ -44,7 +42,7 @@ interface MangerBougerListPageData {
|
|||
initialState?: {
|
||||
recipes?: {
|
||||
list?: MangerBougerListEntry[];
|
||||
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live on the `query`-term path: 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). The catalogue-browse path (no `query`) only paginates when the request URL omits `&query=` entirely — see `LIST_URL` and issue #78. */
|
||||
/** 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;
|
||||
};
|
||||
};
|
||||
|
|
@ -286,18 +284,8 @@ export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: strin
|
|||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||
try {
|
||||
const page = params.cursor ? Number(params.cursor) : 1;
|
||||
// Only append `&query=` when there's an actual search term. A trailing
|
||||
// empty `&query=` on the "browse everything" path (filter absent) made
|
||||
// the *deployed* server serve page 1 for every `page=N` — the
|
||||
// infinite scroll then stopped after 9 recipes (issue #38's
|
||||
// "0 new externalIds ⇒ end of list" guard cutting in). The bare
|
||||
// `?diet=ALL&page=N` catalogue request paginates correctly. The
|
||||
// sibling adapters (marmiton/quitoque/ohmonbento) already make their
|
||||
// search param conditional; this one didn't.
|
||||
const query = (params.query ?? "").trim();
|
||||
const listUrl = `${LIST_URL}?diet=ALL&page=${page}${
|
||||
query ? `&query=${encodeURIComponent(query)}` : ""
|
||||
}`;
|
||||
const query = params.query ?? "";
|
||||
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,170 +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 = "ohMonBento";
|
||||
const SITE_ORIGIN = "https://ohmonbento.com";
|
||||
|
||||
/** One recipe card scraped off an ohmonbento.com search page — see {@link extractRecipeCards}. */
|
||||
interface OhMonBentoCard {
|
||||
url: string;
|
||||
title: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrapes every recipe card out of an ohmonbento.com search results page.
|
||||
* Each card is a `<div class="entry-thumb">` wrapping a single
|
||||
* `<a href="/<slug>/">` with one `<img>` inside — no `ItemList` JSON-LD
|
||||
* (unlike marmiton.ts) and no lazy-load `data-src` dance (unlike
|
||||
* quitoque.ts): the real image is just the `<img>`'s `src`. `href` is a
|
||||
* root-relative path, resolved against {@link SITE_ORIGIN} here.
|
||||
* Attribute-order-agnostic.
|
||||
*/
|
||||
function extractRecipeCards(html: string): OhMonBentoCard[] {
|
||||
const cards: OhMonBentoCard[] = [];
|
||||
const thumbPattern = /<div\s+[^>]*class="[^"]*\bentry-thumb\b[^"]*"[^>]*>([\s\S]*?)<\/div>/g;
|
||||
for (const match of html.matchAll(thumbPattern)) {
|
||||
const block = match[1] ?? "";
|
||||
const href = /<a\s+[^>]*\bhref="([^"]+)"/.exec(block)?.[1];
|
||||
if (href === undefined) continue;
|
||||
const title = /\balt="([^"]*)"/.exec(block)?.[1] ?? "";
|
||||
if (title.length === 0) continue;
|
||||
const url = href.startsWith("http") ? href : `${SITE_ORIGIN}${href}`;
|
||||
cards.push({ url, title, image: /\bsrc="([^"]+)"/.exec(block)?.[1] ?? null });
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
|
||||
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as this
|
||||
* adapter's (`sourceKey` `"ohMonBento"`) — same reasoning as marmiton.ts's
|
||||
* identically-named helper. (Not factored out into a shared helper —
|
||||
* marmiton/750g/manger-bouger/quitoque each keep their own copy; the
|
||||
* near-identical five-liner isn't worth a module.)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* ohmonbento.com — a French Japanese-cuisine / bento cooking blog
|
||||
* (WordPress). Unofficial (`official: false`, same reasoning as every
|
||||
* other scraped adapter): no published API, this reads ordinary pages and
|
||||
* the schema.org JSON-LD they embed. `robots.txt` allows everything except
|
||||
* `/wp-admin/`.
|
||||
*
|
||||
* The simplest of the scraped adapters: like marmiton.ts, `fetchDetail`/
|
||||
* `parse` delegate straight to {@link jsonLdRecipeAdapter} — a recipe page
|
||||
* carries a WP Recipe Maker `Recipe` inside a Yoast `@graph`, which
|
||||
* `findRecipeNode` already handles (recurses into the array). Only `list()`
|
||||
* is site-specific: WordPress search at `/?s={query}&paged={page}` — the
|
||||
* **same URL whether `query` is empty (whole catalogue) or not** (no
|
||||
* branching, unlike 750g.ts) — with a `<div class="entry-thumb">` card
|
||||
* scrape (see {@link extractRecipeCards}). A page past the last one 404s,
|
||||
* the same clean end-of-list signal as marmiton.ts.
|
||||
*/
|
||||
export const ohMonBentoAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
|
||||
key: SOURCE_KEY,
|
||||
name: "Oh mon Bento",
|
||||
official: false,
|
||||
iconUrl: `${SITE_ORIGIN}/favicon.ico`,
|
||||
// Le contenu d'ohmonbento.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 {
|
||||
// Curseur opaque = numéro de page suivant ; WordPress pagine via
|
||||
// `&paged=N` (page 1 implicite quand le paramètre est absent). Le
|
||||
// paramètre `?s=` est toujours présent, même vide — c'est ce qui
|
||||
// liste tout le catalogue (vérifié sur le ticket).
|
||||
const page = params.cursor ? Number(params.cursor) : 1;
|
||||
const query = params.query ?? "";
|
||||
const listUrl = `${SITE_ORIGIN}/?s=${encodeURIComponent(query)}${
|
||||
page > 1 ? `&paged=${page}` : ""
|
||||
}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(listUrl);
|
||||
} catch (cause) {
|
||||
throw new RecipeSourceFetchError(
|
||||
SOURCE_KEY,
|
||||
`Network error listing Oh mon Bento recipes (${listUrl})`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
// Une page au-delà de la dernière renvoie une vraie 404 — pas un
|
||||
// échec, juste « il n'y a plus rien » (même signal que marmiton.ts).
|
||||
if (response.status === 404) {
|
||||
return { items: [], nextCursor: null };
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RecipeSourceFetchError(
|
||||
SOURCE_KEY,
|
||||
`Oh mon Bento responded ${response.status} (${listUrl})`,
|
||||
);
|
||||
}
|
||||
const html = await response.text();
|
||||
|
||||
const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
|
||||
externalId: card.url,
|
||||
title: card.title,
|
||||
picture: card.image,
|
||||
url: card.url,
|
||||
}));
|
||||
|
||||
// La 404 ci-dessus est le vrai signal de fin ; on ne propose une page
|
||||
// suivante que si celle-ci a renvoyé au moins une carte (même logique
|
||||
// que marmiton.ts).
|
||||
const nextCursor = items.length > 0 ? String(page + 1) : null;
|
||||
|
||||
return { items, nextCursor };
|
||||
} catch (err) {
|
||||
// Rethrown as-is (already keyed "ohMonBento" by whichever branch
|
||||
// above threw it) — same reasoning as `marmiton.ts`/`quitoque.ts`.
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// `externalId` est directement l'URL canonique de la recette sur
|
||||
// ohmonbento.com (renvoyée telle quelle par `list()` ci-dessus) — même
|
||||
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
|
||||
// délègue entièrement.
|
||||
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
|
||||
try {
|
||||
return await jsonLdRecipeAdapter.fetchDetail(externalId);
|
||||
} catch (err) {
|
||||
// `rekeySourceError` est le traitement utile de l'erreur à ce point
|
||||
// d'appel (relabelliser sa `sourceKey`), pas un simple re-throw.
|
||||
throw rekeySourceError(err);
|
||||
}
|
||||
},
|
||||
|
||||
parse(raw: { html: string; url: string }): ParsedRecipe {
|
||||
try {
|
||||
return jsonLdRecipeAdapter.parse(raw);
|
||||
} catch (err) {
|
||||
throw rekeySourceError(err);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,188 +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 = "quitoque";
|
||||
const SITE_ORIGIN = "https://www.quitoque.fr";
|
||||
const LIST_URL = `${SITE_ORIGIN}/recettes`;
|
||||
|
||||
/** One recipe card scraped off a quitoque.fr `/recettes` listing page — see {@link extractRecipeCards}. */
|
||||
interface QuitoqueCard {
|
||||
url: string;
|
||||
title: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrapes every recipe card out of a quitoque.fr `/recettes` listing page.
|
||||
* There's no `ItemList` JSON-LD to lean on (unlike marmiton.ts) — but each
|
||||
* card is a single `<a class="card__content_header" href="/recettes/…">`
|
||||
* wrapping its own `<img>`, so no "nearest preceding image" pairing is
|
||||
* needed (unlike 750g.ts). The real image URL is the `<img>`'s `data-src`
|
||||
* (a lazy-load attribute); its `src` is only a placeholder. `href` is a
|
||||
* root-relative path, resolved against {@link SITE_ORIGIN} here so callers
|
||||
* get a canonical absolute URL.
|
||||
*
|
||||
* Attribute-order-agnostic: matches every `<a>…</a>`, keeps the ones whose
|
||||
* `class` contains `card__content_header`, and reads `href`/`alt`/`data-src`
|
||||
* wherever they sit.
|
||||
*/
|
||||
function extractRecipeCards(html: string): QuitoqueCard[] {
|
||||
const cards: QuitoqueCard[] = [];
|
||||
const anchorPattern = /<a\s+([^>]*?)>([\s\S]*?)<\/a>/g;
|
||||
for (const match of html.matchAll(anchorPattern)) {
|
||||
const attrs = match[1] ?? "";
|
||||
if (!/class="[^"]*\bcard__content_header\b[^"]*"/.test(attrs)) continue;
|
||||
const href = /\bhref="(\/recettes\/[^"]+)"/.exec(attrs)?.[1];
|
||||
if (href === undefined) continue;
|
||||
const inner = match[2] ?? "";
|
||||
const title = /\balt="([^"]*)"/.exec(inner)?.[1] ?? "";
|
||||
if (title.length === 0) continue;
|
||||
cards.push({
|
||||
url: `${SITE_ORIGIN}${href}`,
|
||||
title,
|
||||
image: /\bdata-src="([^"]+)"/.exec(inner)?.[1] ?? null,
|
||||
});
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
|
||||
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
|
||||
* come from this adapter (`sourceKey` `"quitoque"`) — same reasoning as
|
||||
* marmiton.ts's identically-named helper: `fetchDetail`/`parse` are thin
|
||||
* wrappers around the generic adapter, but a caller reading `.sourceKey`
|
||||
* off a caught `RecipeSourceError` should see the source it asked about.
|
||||
* (Not factored out into a shared helper — 750g/manger-bouger each keep
|
||||
* their own copy too; three near-identical five-liners aren't worth a
|
||||
* module.)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* quitoque.fr — a French meal-kit company whose site publishes a large
|
||||
* browsable recipe catalogue (`/recettes`, ~2700 recipes). Unofficial
|
||||
* (`official: false`, same reasoning as jsonLdRecipeAdapter/marmiton/750g/
|
||||
* mangerBouger): no published API, this reads ordinary pages and the
|
||||
* schema.org JSON-LD they embed for search engines. `robots.txt` allows
|
||||
* `/recettes` (and lists `ClaudeBot`/`GPTBot`/`Google-Extended` explicitly
|
||||
* under `allow: /`).
|
||||
*
|
||||
* The thinnest kind of wrapper — like marmiton.ts, `fetchDetail`/`parse`
|
||||
* delegate straight to {@link jsonLdRecipeAdapter} (a quitoque.fr recipe
|
||||
* page's JSON-LD is a plain schema.org `Recipe`, the sole element of a
|
||||
* top-level array, which `findRecipeNode` already handles). Only `list()`
|
||||
* is site-specific: `/recettes?page=N` with a card scrape (see
|
||||
* {@link extractRecipeCards}). The site has no real free-text search, so a
|
||||
* `query` is a best-effort substring filter over each page's card titles
|
||||
* (same accepted gap 750g.ts documents for its own search).
|
||||
*/
|
||||
export const quitoqueAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
|
||||
key: SOURCE_KEY,
|
||||
name: "Quitoque",
|
||||
official: false,
|
||||
iconUrl: `${SITE_ORIGIN}/favicon.ico`,
|
||||
// Le contenu de quitoque.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 {
|
||||
// Curseur opaque = numéro de page suivant ; quitoque.fr pagine via
|
||||
// `?page=N` (page 1 implicite quand le paramètre est absent), même
|
||||
// esprit que marmiton.ts/750g.ts.
|
||||
const page = params.cursor ? Number(params.cursor) : 1;
|
||||
const query = (params.query ?? "").trim().toLowerCase();
|
||||
const listUrl = `${LIST_URL}${page > 1 ? `?page=${page}` : ""}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(listUrl);
|
||||
} catch (cause) {
|
||||
throw new RecipeSourceFetchError(
|
||||
SOURCE_KEY,
|
||||
`Network error listing Quitoque recipes (${listUrl})`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RecipeSourceFetchError(
|
||||
SOURCE_KEY,
|
||||
`Quitoque responded ${response.status} (${listUrl})`,
|
||||
);
|
||||
}
|
||||
const html = await response.text();
|
||||
|
||||
const rawCards = extractRecipeCards(html);
|
||||
const cards =
|
||||
query.length > 0
|
||||
? rawCards.filter((card) => card.title.toLowerCase().includes(query))
|
||||
: rawCards;
|
||||
|
||||
const items: RecipeSourceListItem[] = cards.map((card) => ({
|
||||
externalId: card.url,
|
||||
title: card.title,
|
||||
picture: card.image,
|
||||
url: card.url,
|
||||
}));
|
||||
|
||||
// Une page qui ne renvoie **aucune** carte est le signal de fin
|
||||
// (même logique que marmiton.ts). Basé sur le nombre de cartes
|
||||
// brutes, pas filtrées : une page qui a des cartes mais dont aucune
|
||||
// ne matche `query` peut quand même avoir une suite qui matche.
|
||||
const nextCursor = rawCards.length > 0 ? String(page + 1) : null;
|
||||
|
||||
return { items, nextCursor };
|
||||
} catch (err) {
|
||||
// Rethrown as-is (already keyed "quitoque" 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
|
||||
// quitoque.fr (renvoyée telle quelle par `list()` ci-dessus) — même
|
||||
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
|
||||
// délègue entièrement.
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -66,91 +66,20 @@ function detailUrl(idMeal: string): string {
|
|||
return `https://www.themealdb.com/meal/${idMeal}`;
|
||||
}
|
||||
|
||||
/** Category-list response shape (`/list.php?c=list`) — same `meals` envelope as everything else, but each row only carries `strCategory`. */
|
||||
interface TheMealDbCategoryListResponse {
|
||||
meals: { strCategory: string | null }[] | null;
|
||||
}
|
||||
|
||||
/** Narrows to meals that actually carry a name — a titleless row isn't worth surfacing in a browse list. */
|
||||
function hasName(meal: TheMealDbMeal): meal is TheMealDbMeal & { strMeal: string } {
|
||||
return Boolean(meal.strMeal);
|
||||
}
|
||||
|
||||
/** Maps one raw meal (from `/search.php` or `/filter.php`) into the generic list-item shape. */
|
||||
function toListItem(meal: TheMealDbMeal & { strMeal: string }) {
|
||||
return {
|
||||
externalId: meal.idMeal,
|
||||
title: meal.strMeal,
|
||||
picture: meal.strMealThumb,
|
||||
url: detailUrl(meal.idMeal),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Name search (`/search.php?s=`) — the only mode TheMealDB's free tier
|
||||
* offers for a non-empty query. One response holds every match, so there's
|
||||
* never a next page.
|
||||
*/
|
||||
async function searchByName(query: string): Promise<RecipeSourceListResult> {
|
||||
try {
|
||||
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
||||
`/search.php?s=${encodeURIComponent(query)}`,
|
||||
);
|
||||
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor: null };
|
||||
} catch (err) {
|
||||
throw err; // see fetchTheMealDb()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Browse everything" (an empty/omitted query). TheMealDB's free tier has
|
||||
* no catalog-wide listing endpoint — `/search.php?s=` only answers with a
|
||||
* fixed ~25-meal sample — so this walks the category list instead:
|
||||
* `/list.php?c=list` for the categories, then `/filter.php?c={category}`
|
||||
* for one category at a time, exposing **one category per cursor step** so
|
||||
* the infinite scroll keeps pulling real, distinct pages instead of
|
||||
* stalling on that sample (issue #38). The category list is re-fetched on
|
||||
* each step rather than cached in module state — it's a single small
|
||||
* request, it keeps this adapter stateless like the others, and the walk
|
||||
* is paced one step per scroll anyway.
|
||||
*/
|
||||
async function listByCategory(cursor: string | null | undefined): Promise<RecipeSourceListResult> {
|
||||
try {
|
||||
const parsed = Number.parseInt(cursor ?? "0", 10);
|
||||
const startAt = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
|
||||
|
||||
const categoryList = await fetchTheMealDb<TheMealDbCategoryListResponse>("/list.php?c=list");
|
||||
const categories = (categoryList.meals ?? [])
|
||||
.map((row) => row.strCategory)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
|
||||
const category = categories[startAt];
|
||||
if (category === undefined) {
|
||||
// Cursor has walked past the last category — the clean end of the list.
|
||||
return { items: [], nextCursor: null };
|
||||
}
|
||||
|
||||
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
||||
`/filter.php?c=${encodeURIComponent(category)}`,
|
||||
);
|
||||
const nextCursor = startAt + 1 < categories.length ? String(startAt + 1) : null;
|
||||
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor };
|
||||
} catch (err) {
|
||||
throw err; // see fetchTheMealDb()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()` has two modes (see the helpers above): a non-empty `query` is a
|
||||
* name search (`/search.php?s=`, single unpaginated response,
|
||||
* `nextCursor: null`); an empty/omitted `query` — this contract's "browse
|
||||
* everything" — walks the category list one category per cursor step,
|
||||
* since the free tier has no real catalog-wide endpoint.
|
||||
* `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,
|
||||
|
|
@ -164,8 +93,22 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
|
|||
|
||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||
try {
|
||||
const query = (params.query ?? "").trim();
|
||||
return query.length > 0 ? await searchByName(query) : await listByCategory(params.cursor);
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ export async function resetDatabase() {
|
|||
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
||||
"recipe", "ingredients", "sources", "unit",
|
||||
"user_profiles", "diet", "house",
|
||||
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
|
||||
"contact_message",
|
||||
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
|
||||
"admin_users", "analytics_events", "worker_heartbeats"
|
||||
RESTART IDENTITY CASCADE;
|
||||
`);
|
||||
await seedReferenceData(prisma);
|
||||
|
|
|
|||
|
|
@ -44,16 +44,16 @@ describe("Admin auth", () => {
|
|||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("POST /admin/api/auth/login", () => {
|
||||
describe("POST /admin/auth/login", () => {
|
||||
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
|
||||
const res = await request(app).post("/admin/api/auth/login").send({});
|
||||
const res = await request(app).post("/admin/auth/login").send({});
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
||||
const res = await request(app)
|
||||
.post("/admin/api/auth/login")
|
||||
.post("/admin/auth/login")
|
||||
.send({ email: "nobody@example.com", password: "whatever" });
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||
|
|
@ -62,7 +62,7 @@ describe("Admin auth", () => {
|
|||
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
||||
const { email } = await seedAdmin();
|
||||
const res = await request(app)
|
||||
.post("/admin/api/auth/login")
|
||||
.post("/admin/auth/login")
|
||||
.send({ email, password: "not-the-password" });
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||
|
|
@ -76,7 +76,7 @@ describe("Admin auth", () => {
|
|||
}
|
||||
const { email, password } = await seedAdmin();
|
||||
|
||||
const res = await request(app).post("/admin/api/auth/login").send({ email, password });
|
||||
const res = await request(app).post("/admin/auth/login").send({ email, password });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.email).to.equal(email);
|
||||
|
|
@ -94,9 +94,9 @@ describe("Admin auth", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("GET /admin/api/auth/me", () => {
|
||||
describe("GET /admin/auth/me", () => {
|
||||
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/admin/api/auth/me");
|
||||
const res = await request(app).get("/admin/auth/me");
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
|
@ -109,9 +109,9 @@ describe("Admin auth", () => {
|
|||
}
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
|
||||
const res = await agent.get("/admin/api/auth/me");
|
||||
const res = await agent.get("/admin/auth/me");
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.email).to.equal(email);
|
||||
});
|
||||
|
|
@ -124,12 +124,12 @@ describe("Admin auth", () => {
|
|||
}
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
|
||||
const logoutRes = await agent.post("/admin/api/auth/logout");
|
||||
const logoutRes = await agent.post("/admin/auth/logout");
|
||||
expect(logoutRes.status).to.equal(204);
|
||||
|
||||
const meRes = await agent.get("/admin/api/auth/me");
|
||||
const meRes = await agent.get("/admin/auth/me");
|
||||
expect(meRes.status).to.equal(401);
|
||||
});
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ describe("Admin auth", () => {
|
|||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/admin/api/auth/me");
|
||||
const res = await agent.get("/admin/auth/me");
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ async function seedPlaceholderRecipe(name: string): Promise<number> {
|
|||
}
|
||||
|
||||
/**
|
||||
* `/admin/api/catalog/*` — the off-catalog ingredient review. Every route is
|
||||
* `/admin/catalog/*` — the off-catalog ingredient review. Every route is
|
||||
* behind `requireAdmin`; the list groups placeholder rows by normalized
|
||||
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
|
||||
* no recipe references any more.
|
||||
|
|
@ -66,18 +66,18 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
|||
async function adminAgent() {
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
return agent;
|
||||
}
|
||||
|
||||
it("rejects every route without an admin session", async () => {
|
||||
const get = await request(app).get("/admin/api/catalog/placeholders");
|
||||
const get = await request(app).get("/admin/catalog/placeholders");
|
||||
expect(get.status).to.equal(401);
|
||||
const patch = await request(app)
|
||||
.patch("/admin/api/catalog/placeholders/mark-reviewed")
|
||||
.patch("/admin/catalog/placeholders/mark-reviewed")
|
||||
.send({ ingredientIds: [1] });
|
||||
expect(patch.status).to.equal(401);
|
||||
const post = await request(app).post("/admin/api/catalog/placeholders/prune-orphans");
|
||||
const post = await request(app).post("/admin/catalog/placeholders/prune-orphans");
|
||||
expect(post.status).to.equal(401);
|
||||
});
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
|||
await seedPlaceholderRecipe("Sumac");
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/catalog/placeholders");
|
||||
const res = await agent.get("/admin/catalog/placeholders");
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(2);
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
|||
const agent = await adminAgent();
|
||||
|
||||
const patched = await agent
|
||||
.patch("/admin/api/catalog/placeholders/mark-reviewed")
|
||||
.patch("/admin/catalog/placeholders/mark-reviewed")
|
||||
.send({ ingredientIds: [id] });
|
||||
expect(patched.status).to.equal(200);
|
||||
expect(patched.body.reviewed).to.equal(1);
|
||||
|
|
@ -114,9 +114,9 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
|||
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
||||
).to.be.an.instanceOf(Date);
|
||||
|
||||
const pending = await agent.get("/admin/api/catalog/placeholders");
|
||||
const pending = await agent.get("/admin/catalog/placeholders");
|
||||
expect(pending.body).to.have.length(0);
|
||||
const reviewed = await agent.get("/admin/api/catalog/placeholders").query({ reviewed: "true" });
|
||||
const reviewed = await agent.get("/admin/catalog/placeholders").query({ reviewed: "true" });
|
||||
expect(reviewed.body).to.have.length(1);
|
||||
expect(reviewed.body[0].allReviewed).to.equal(true);
|
||||
});
|
||||
|
|
@ -133,124 +133,9 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
|||
});
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.post("/admin/api/catalog/placeholders/prune-orphans");
|
||||
const res = await agent.post("/admin/catalog/placeholders/prune-orphans");
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.deleted).to.equal(1);
|
||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `/admin/api/catalog/tech-step-placeholders` — the missing-technique review
|
||||
* (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route
|
||||
* behind `requireAdmin`.
|
||||
*/
|
||||
describe("Admin catalog — missing techniques", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function adminAgent() {
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */
|
||||
async function seedPlaceholderTechnique(name: string): Promise<number> {
|
||||
const author = await prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: "T",
|
||||
lastName: "A",
|
||||
email: `${faker.string.uuid()}@example.test`,
|
||||
passwordHash: "x",
|
||||
},
|
||||
});
|
||||
const techStep = await prisma.techStep.create({
|
||||
data: {
|
||||
key: `placeholder:${faker.string.uuid()}`,
|
||||
isPlaceholder: true,
|
||||
displayName: name,
|
||||
createdById: author.id,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
await prisma.recipe.create({
|
||||
data: {
|
||||
name: faker.lorem.words(3),
|
||||
authorId: author.id,
|
||||
portions: 2,
|
||||
visibility: "PUBLIC",
|
||||
steps: {
|
||||
create: [
|
||||
{
|
||||
description: "Une étape.",
|
||||
order: 0,
|
||||
techSteps: {
|
||||
create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return techStep.id;
|
||||
}
|
||||
|
||||
it("rejects both routes without an admin session", async () => {
|
||||
expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal(
|
||||
401,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await request(app)
|
||||
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
||||
.send({ techStepIds: [1] })
|
||||
).status,
|
||||
).to.equal(401);
|
||||
});
|
||||
|
||||
it("lists proposed techniques with their recipe count, impact-ordered", async () => {
|
||||
await seedPlaceholderTechnique("Flamber au chalumeau");
|
||||
await seedPlaceholderTechnique("Confire à basse température");
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/catalog/tech-step-placeholders");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(2);
|
||||
expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([
|
||||
"Flamber au chalumeau",
|
||||
"Confire à basse température",
|
||||
]);
|
||||
expect(res.body[0].recipeCount).to.equal(1);
|
||||
expect(res.body[0].reviewedAt).to.equal(null);
|
||||
});
|
||||
|
||||
it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => {
|
||||
const id = await seedPlaceholderTechnique("Snacker à la plancha");
|
||||
const agent = await adminAgent();
|
||||
|
||||
const patched = await agent
|
||||
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
||||
.send({ techStepIds: [id] });
|
||||
expect(patched.status).to.equal(200);
|
||||
expect(patched.body.reviewed).to.equal(1);
|
||||
expect(
|
||||
(await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
||||
).to.be.an.instanceOf(Date);
|
||||
|
||||
expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0);
|
||||
const reviewed = await agent
|
||||
.get("/admin/api/catalog/tech-step-placeholders")
|
||||
.query({ reviewed: "true" });
|
||||
expect(reviewed.body).to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,346 +0,0 @@
|
|||
import { ErrorCode } 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 { env } from "../src/config/env.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const password = faker.internet.password({ length: 16 });
|
||||
await prisma.adminUser.create({
|
||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||
});
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
/** Creates a `UserProfile` to attribute contact messages to. */
|
||||
async function seedUser() {
|
||||
return prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: faker.person.firstName(),
|
||||
lastName: faker.person.lastName(),
|
||||
email: `${faker.string.uuid()}@example.test`,
|
||||
passwordHash: "x",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** One `ContactMessage`, `createdAt` overridable so ordering is deterministic. */
|
||||
async function seedMessage(overrides: {
|
||||
authorId: number | null;
|
||||
type?: string;
|
||||
message?: string;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
return prisma.contactMessage.create({
|
||||
data: {
|
||||
authorId: overrides.authorId,
|
||||
type: overrides.type ?? "bug",
|
||||
message: overrides.message ?? faker.lorem.sentence(),
|
||||
pageUrl: "/",
|
||||
userAgent: "test-agent/1.0",
|
||||
appVersion: "0.2.1",
|
||||
...(overrides.createdAt ? { createdAt: overrides.createdAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
||||
const forgejoConfigured =
|
||||
env.FORGEJO_BASE_URL !== undefined &&
|
||||
env.FORGEJO_TOKEN !== undefined &&
|
||||
env.FORGEJO_REPO !== undefined;
|
||||
|
||||
describe("Admin contact messages", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function adminAgent() {
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
return agent;
|
||||
}
|
||||
|
||||
it("rejects the route without an admin session", async () => {
|
||||
const res = await request(app).get("/admin/api/contact");
|
||||
expect(res.status).to.equal(401);
|
||||
});
|
||||
|
||||
it("returns messages newest first, with the author resolved", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const user = await seedUser();
|
||||
await seedMessage({
|
||||
authorId: user.id,
|
||||
message: "le plus ancien",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
await seedMessage({
|
||||
authorId: user.id,
|
||||
message: "le plus récent",
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/contact");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.map((row: { message: string }) => row.message)).to.deep.equal([
|
||||
"le plus récent",
|
||||
"le plus ancien",
|
||||
]);
|
||||
expect(res.body[0].author).to.deep.equal({
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by type", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const user = await seedUser();
|
||||
await seedMessage({ authorId: user.id, type: "bug" });
|
||||
await seedMessage({ authorId: user.id, type: "suggestion" });
|
||||
await seedMessage({ authorId: user.id, type: "suggestion" });
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/contact").query({ type: "suggestion" });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.lengthOf(2);
|
||||
expect(res.body.every((row: { type: string }) => row.type === "suggestion")).to.equal(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown type filter with 400", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/contact").query({ type: "complaint" });
|
||||
expect(res.status).to.equal(400);
|
||||
});
|
||||
|
||||
it("caps the page at 200 rows", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const user = await seedUser();
|
||||
await prisma.contactMessage.createMany({
|
||||
data: Array.from({ length: 205 }, () => ({
|
||||
authorId: user.id,
|
||||
type: "bug",
|
||||
message: faker.lorem.sentence(),
|
||||
})),
|
||||
});
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/contact");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.lengthOf(200);
|
||||
});
|
||||
|
||||
it("reports author: null once the sender's account is deleted", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const user = await seedUser();
|
||||
await seedMessage({ authorId: user.id, message: "signalé puis compte supprimé" });
|
||||
await prisma.userProfile.delete({ where: { id: user.id } });
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/contact");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.lengthOf(1);
|
||||
expect(res.body[0].message).to.equal("signalé puis compte supprimé");
|
||||
expect(res.body[0].author).to.equal(null);
|
||||
});
|
||||
|
||||
describe("Forgejo issue creation (#81)", () => {
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
/** Makes `forgejo.createIssue`'s `fetch` return `body` with `status`. */
|
||||
function stubForgejoFetch(body: unknown, status = 201) {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify(body), { status })) as typeof fetch;
|
||||
}
|
||||
|
||||
it("returns 401 without an admin session", async () => {
|
||||
const res = await request(app).post("/admin/api/contact/1/forgejo-issue").send({
|
||||
title: "t",
|
||||
body: "b",
|
||||
});
|
||||
expect(res.status).to.equal(401);
|
||||
});
|
||||
|
||||
it("exposes the integration status", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/contact/forgejo/status");
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal({ enabled: forgejoConfigured });
|
||||
});
|
||||
|
||||
it("responds 503 FORGEJO_NOT_CONFIGURED when the env isn't set", async function () {
|
||||
if (!adminSecretConfigured || forgejoConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const user = await seedUser();
|
||||
const message = await seedMessage({ authorId: user.id });
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent
|
||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
||||
.send({ title: "Titre", body: "Corps du ticket" });
|
||||
|
||||
expect(res.status).to.equal(503);
|
||||
expect(res.body.code).to.equal(ErrorCode.FORGEJO_NOT_CONFIGURED);
|
||||
});
|
||||
|
||||
it("creates the issue and records the link on the message", async function () {
|
||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
stubForgejoFetch({ number: 77, html_url: "https://forge.example/ci/repo/issues/77" });
|
||||
const user = await seedUser();
|
||||
const message = await seedMessage({ authorId: user.id, message: "un bug précis" });
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent
|
||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
||||
.send({ title: "[Bug] un bug précis", body: "un bug précis\n\n---\ncontexte" });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.forgejoIssueNumber).to.equal(77);
|
||||
expect(res.body.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
|
||||
|
||||
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
|
||||
expect(row.forgejoIssueNumber).to.equal(77);
|
||||
expect(row.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
|
||||
});
|
||||
|
||||
it("responds 404 for an unknown message id", async function () {
|
||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
stubForgejoFetch({ number: 1, html_url: "x" });
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent
|
||||
.post("/admin/api/contact/999999/forgejo-issue")
|
||||
.send({ title: "Titre", body: "Corps" });
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("responds 409 when the message already links to an issue", async function () {
|
||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
stubForgejoFetch({ number: 2, html_url: "x" });
|
||||
const user = await seedUser();
|
||||
const message = await seedMessage({ authorId: user.id });
|
||||
await prisma.contactMessage.update({
|
||||
where: { id: message.id },
|
||||
data: { forgejoIssueNumber: 5, forgejoIssueUrl: "https://forge.example/ci/repo/issues/5" },
|
||||
});
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent
|
||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
||||
.send({ title: "Titre", body: "Corps" });
|
||||
|
||||
expect(res.status).to.equal(409);
|
||||
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED);
|
||||
});
|
||||
|
||||
it("leaves the message untouched when the Forgejo call fails (502)", async function () {
|
||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
stubForgejoFetch("upstream boom", 500);
|
||||
const user = await seedUser();
|
||||
const message = await seedMessage({ authorId: user.id });
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent
|
||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
||||
.send({ title: "Titre", body: "Corps" });
|
||||
|
||||
expect(res.status).to.equal(502);
|
||||
expect(res.body.code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
||||
|
||||
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
|
||||
expect(row.forgejoIssueNumber).to.equal(null);
|
||||
expect(row.forgejoIssueUrl).to.equal(null);
|
||||
});
|
||||
|
||||
it("rejects an empty title with 400", async function () {
|
||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const user = await seedUser();
|
||||
const message = await seedMessage({ authorId: user.id });
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent
|
||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
||||
.send({ title: " ", body: "Corps" });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -78,7 +78,7 @@ describe("Admin metrics", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("GET /admin/api/metrics", () => {
|
||||
describe("GET /admin/metrics", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -90,7 +90,7 @@ describe("Admin metrics", () => {
|
|||
});
|
||||
|
||||
it("rejects a request with no admin session with 401", async () => {
|
||||
const res = await request(app).get("/admin/api/metrics");
|
||||
const res = await request(app).get("/admin/metrics");
|
||||
expect(res.status).to.equal(401);
|
||||
});
|
||||
|
||||
|
|
@ -109,9 +109,9 @@ describe("Admin metrics", () => {
|
|||
await userB.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const adminAgent = request.agent(app);
|
||||
await adminAgent.post("/admin/api/auth/login").send({ email, password });
|
||||
await adminAgent.post("/admin/auth/login").send({ email, password });
|
||||
|
||||
const res = await adminAgent.get("/admin/api/metrics").query({ days: 14 });
|
||||
const res = await adminAgent.get("/admin/metrics").query({ days: 14 });
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.rangeDays).to.equal(14);
|
||||
expect(res.body.snapshot.users).to.equal(2);
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
import type { AddressInfo } from "node:net";
|
||||
import type { MonitoringView } from "@batch-cooking/shared";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
import { Server as SocketIoServer } from "socket.io";
|
||||
import { type Socket as ClientSocket, io as ioClient } from "socket.io-client";
|
||||
import { createServer } from "../src/app.js";
|
||||
import { env } from "../src/config/env.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { signAdminToken } from "../src/lib/admin-jwt.js";
|
||||
import { MonitoringBroadcastService } from "../src/lib/monitoring-broadcast.service.js";
|
||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
|
||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
||||
|
||||
async function seedAdmin(): Promise<{ adminUserId: number; tokenVersion: number }> {
|
||||
const admin = await prisma.adminUser.create({
|
||||
data: {
|
||||
email: faker.internet.email().toLowerCase(),
|
||||
name: faker.person.fullName(),
|
||||
passwordHash: await hashAdminPassword(faker.internet.password({ length: 16 })),
|
||||
},
|
||||
});
|
||||
return { adminUserId: admin.id, tokenVersion: admin.tokenVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for `event` on `socket`, rejecting after `timeoutMs` (default
|
||||
* tuned for the auth-rejection tests, which never touch a real probe and
|
||||
* so resolve almost immediately — the probe-touching tests below pass a
|
||||
* larger value explicitly) — every real WebSocket round-trip in this file
|
||||
* goes through this instead of a bare `socket.once`, so a broken push
|
||||
* fails the test with a clear message instead of hanging until Mocha's
|
||||
* own default test timeout.
|
||||
*/
|
||||
function waitForEvent<T>(socket: ClientSocket, event: string, timeoutMs = 1500): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`Timed out waiting for "${event}"`)),
|
||||
timeoutMs,
|
||||
);
|
||||
socket.once(event, (payload: T) => {
|
||||
clearTimeout(timer);
|
||||
resolve(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("Admin monitoring — WebSocket push", () => {
|
||||
let baseUrl: string;
|
||||
let broadcast: MonitoringBroadcastService;
|
||||
// Hoisted to suite scope (not a local in `before`) so `after` can shut
|
||||
// it down. `broadcast.stop()` alone only clears the probe interval — the
|
||||
// socket.io server keeps engine.io's ping timers and the listening
|
||||
// http.Server (port 0) alive, and Mocha runs without `--exit`
|
||||
// (`.mocharc.json`), so that one leaked handle stops the whole process
|
||||
// from ever draining once the last test passes. That is exactly what
|
||||
// stalled CI: the `test` job printed "N passing" and then hung for
|
||||
// hours until the runner was killed.
|
||||
let io: SocketIoServer;
|
||||
const clientSockets: ClientSocket[] = [];
|
||||
|
||||
// A real listening server, not just createApp()'s bare Express instance
|
||||
// (supertest's usual approach elsewhere in this suite) — socket.io needs
|
||||
// an actual http.Server to attach to, and a real client (socket.io-
|
||||
// client) needs a real port to connect to. Port 0 lets the OS pick a
|
||||
// free one, so this suite never collides with anything else listening.
|
||||
before(async () => {
|
||||
const httpServer = createServer().httpServer;
|
||||
io = new SocketIoServer(httpServer, { cors: { origin: "*" } });
|
||||
broadcast = new MonitoringBroadcastService(io);
|
||||
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
|
||||
const { port } = httpServer.address() as AddressInfo;
|
||||
baseUrl = `http://localhost:${port}`;
|
||||
broadcast.start();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
broadcast.stop();
|
||||
// Disconnects any socket still open, stops engine.io's timers, and
|
||||
// closes the http.Server socket.io was attached to — without this the
|
||||
// suite never lets Node's event loop drain (see the `io` field's note).
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
io.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const socket of clientSockets.splice(0)) {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
/** Connects a client with the given cookie header (or none), tracked for automatic teardown in `afterEach`. */
|
||||
function connect(cookie?: string): ClientSocket {
|
||||
const socket = ioClient(baseUrl, {
|
||||
extraHeaders: cookie ? { Cookie: cookie } : undefined,
|
||||
// This suite creates several short-lived connections in a row —
|
||||
// socket.io-client's default reconnection would otherwise keep
|
||||
// retrying a deliberately-rejected connection in the background
|
||||
// after its own test has already moved on.
|
||||
reconnection: false,
|
||||
forceNew: true,
|
||||
});
|
||||
clientSockets.push(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
it("rejects a connection with no admin cookie at all", async () => {
|
||||
const socket = connect();
|
||||
const err = await waitForEvent<Error>(socket, "connect_error");
|
||||
expect(err.message).to.equal("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects a connection carrying an end-user session cookie, not an admin one", async () => {
|
||||
const socket = connect("session=whatever-not-an-admin-token");
|
||||
const err = await waitForEvent<Error>(socket, "connect_error");
|
||||
expect(err.message).to.equal("Not authenticated");
|
||||
});
|
||||
|
||||
it("accepts a valid admin session and pushes a monitoring:update covering all four targets", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
// Longer than Mocha's 2000ms default: the very first probe tick can
|
||||
// take close to PROBE_TIMEOUT_MS (2s) if e.g. the intent-service isn't
|
||||
// reachable from this test environment (a probe timing out is a valid,
|
||||
// expected outcome here — this test doesn't assert *which* status
|
||||
// comes back, only that the shape is right).
|
||||
this.timeout(8000);
|
||||
const { adminUserId, tokenVersion } = await seedAdmin();
|
||||
const token = signAdminToken({ adminUserId, tokenVersion });
|
||||
|
||||
const socket = connect(`${env.ADMIN_COOKIE_NAME}=${token}`);
|
||||
const snapshot = await waitForEvent<MonitoringView>(socket, "monitoring:update", 6000);
|
||||
|
||||
expect(typeof snapshot.generatedAt).to.equal("string");
|
||||
const keys = snapshot.services.map((service) => service.key);
|
||||
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
|
||||
for (const service of snapshot.services) {
|
||||
expect(VALID_STATUSES).to.include(service.status);
|
||||
}
|
||||
});
|
||||
|
||||
it("broadcasts the same update to every connected admin, not one probe per socket", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
this.timeout(8000); // see the previous test's comment
|
||||
const { adminUserId, tokenVersion } = await seedAdmin();
|
||||
const token = signAdminToken({ adminUserId, tokenVersion });
|
||||
const cookie = `${env.ADMIN_COOKIE_NAME}=${token}`;
|
||||
|
||||
const first = connect(cookie);
|
||||
const second = connect(cookie);
|
||||
const [firstSnapshot, secondSnapshot] = await Promise.all([
|
||||
waitForEvent<MonitoringView>(first, "monitoring:update", 6000),
|
||||
waitForEvent<MonitoringView>(second, "monitoring:update", 6000),
|
||||
]);
|
||||
|
||||
expect(firstSnapshot.generatedAt).to.equal(secondSnapshot.generatedAt);
|
||||
});
|
||||
});
|
||||
160
apps/api/test/admin-monitoring.test.ts
Normal file
160
apps/api/test/admin-monitoring.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { faker } from "@faker-js/faker";
|
||||
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 { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
const SECRET_HEADER = "X-Internal-Worker-Secret";
|
||||
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
|
||||
|
||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const password = faker.internet.password({ length: 16 });
|
||||
await prisma.adminUser.create({
|
||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||
});
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
||||
const workerSecretConfigured = env.INTERNAL_WORKER_SECRET !== undefined;
|
||||
|
||||
describe("Admin monitoring", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("POST /internal/tech-steps/heartbeat", () => {
|
||||
it("rejects a request with no worker secret with 401", async () => {
|
||||
const res = await request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" });
|
||||
expect(res.status).to.equal(401);
|
||||
});
|
||||
|
||||
it("rejects a malformed body with 400", async function () {
|
||||
if (!workerSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const res = await request(app)
|
||||
.post("/internal/tech-steps/heartbeat")
|
||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
||||
.send({ event: "not-a-real-event" });
|
||||
expect(res.status).to.equal(400);
|
||||
});
|
||||
|
||||
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async function () {
|
||||
if (!workerSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const res = await request(app)
|
||||
.post("/internal/tech-steps/heartbeat")
|
||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
||||
.send({
|
||||
event: "job",
|
||||
job: "audit-low-confidence",
|
||||
ok: true,
|
||||
counts: { suggestions: 3 },
|
||||
});
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal({ ok: true });
|
||||
|
||||
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
||||
where: { workerKey: "tech-step-llm-worker" },
|
||||
});
|
||||
expect(stored.lastRunAt).to.not.equal(null);
|
||||
expect(stored.lastResult).to.deep.equal({
|
||||
job: "audit-low-confidence",
|
||||
ok: true,
|
||||
counts: { suggestions: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves lastRunAt null for a boot/tick ping", async function () {
|
||||
if (!workerSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
await request(app)
|
||||
.post("/internal/tech-steps/heartbeat")
|
||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
||||
.send({ event: "boot" });
|
||||
|
||||
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
||||
where: { workerKey: "tech-step-llm-worker" },
|
||||
});
|
||||
expect(stored.lastSeenAt).to.be.instanceOf(Date);
|
||||
expect(stored.lastRunAt).to.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /admin/monitoring", () => {
|
||||
it("rejects a request with no admin session with 401", async () => {
|
||||
const res = await request(app).get("/admin/monitoring");
|
||||
expect(res.status).to.equal(401);
|
||||
});
|
||||
|
||||
it("returns a status board covering all four targets, never crashing on an unreachable probe", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
|
||||
const res = await agent.get("/admin/monitoring");
|
||||
expect(res.status).to.equal(200);
|
||||
|
||||
const keys = res.body.services.map((s: { key: string }) => s.key);
|
||||
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
|
||||
for (const service of res.body.services) {
|
||||
expect(VALID_STATUSES).to.include(service.status);
|
||||
}
|
||||
|
||||
const byKey = Object.fromEntries(res.body.services.map((s: { key: string }) => [s.key, s]));
|
||||
// The DB is up during the test run, and the API is answering us.
|
||||
expect(byKey.postgres.status).to.equal("up");
|
||||
expect(byKey.api.status).to.equal("up");
|
||||
// No heartbeat has ever been recorded (resetDatabase truncated it).
|
||||
expect(byKey["tech-step-llm-worker"].status).to.equal("unknown");
|
||||
});
|
||||
|
||||
it("reports the worker as up once it has sent a recent heartbeat", async function () {
|
||||
if (!adminSecretConfigured || !workerSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
await request(app)
|
||||
.post("/internal/tech-steps/heartbeat")
|
||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
||||
.send({ event: "job", job: "transform-corrections", ok: true, counts: { suggestions: 0 } });
|
||||
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
|
||||
const res = await agent.get("/admin/monitoring");
|
||||
const worker = res.body.services.find(
|
||||
(s: { key: string }) => s.key === "tech-step-llm-worker",
|
||||
);
|
||||
expect(worker.status).to.equal("up");
|
||||
expect(worker.lastResult.job).to.equal("transform-corrections");
|
||||
expect(worker.lastRunAt).to.be.a("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -92,20 +92,20 @@ describe("Admin tech-steps triage", () => {
|
|||
async function adminAgent() {
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
return agent;
|
||||
}
|
||||
|
||||
it("rejects every route without an admin session", async () => {
|
||||
for (const path of [
|
||||
"/admin/api/tech-steps/suggestions",
|
||||
"/admin/api/tech-steps/corrections",
|
||||
"/admin/api/tech-steps/training-data-snippet?techStepKey=simmer",
|
||||
"/admin/tech-steps/suggestions",
|
||||
"/admin/tech-steps/corrections",
|
||||
"/admin/tech-steps/training-data-snippet?techStepKey=simmer",
|
||||
]) {
|
||||
const res = await request(app).get(path);
|
||||
expect(res.status, path).to.equal(401);
|
||||
}
|
||||
const post = await request(app).post("/admin/api/tech-steps/retrain").send({});
|
||||
const post = await request(app).post("/admin/tech-steps/retrain").send({});
|
||||
expect(post.status).to.equal(401);
|
||||
});
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ describe("Admin tech-steps triage", () => {
|
|||
|
||||
const agent = await adminAgent();
|
||||
|
||||
const all = await agent.get("/admin/api/tech-steps/suggestions");
|
||||
const all = await agent.get("/admin/tech-steps/suggestions");
|
||||
expect(all.status).to.equal(200);
|
||||
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
|
||||
"chop",
|
||||
|
|
@ -139,7 +139,7 @@ describe("Admin tech-steps triage", () => {
|
|||
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
|
||||
|
||||
const pendingOnly = await agent
|
||||
.get("/admin/api/tech-steps/suggestions")
|
||||
.get("/admin/tech-steps/suggestions")
|
||||
.query({ status: "pending" });
|
||||
expect(pendingOnly.body).to.have.length(1);
|
||||
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
|
||||
|
|
@ -159,7 +159,7 @@ describe("Admin tech-steps triage", () => {
|
|||
withSuggestion: { status: "pending", synonyms: ["x"] },
|
||||
});
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`).send({});
|
||||
const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({});
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
|
@ -172,7 +172,7 @@ describe("Admin tech-steps triage", () => {
|
|||
}
|
||||
const agent = await adminAgent();
|
||||
const res = await agent
|
||||
.patch("/admin/api/tech-steps/suggestions/999999")
|
||||
.patch("/admin/tech-steps/suggestions/999999")
|
||||
.send({ status: "applied" });
|
||||
expect(res.status).to.equal(404);
|
||||
});
|
||||
|
|
@ -191,7 +191,7 @@ describe("Admin tech-steps triage", () => {
|
|||
const agent = await adminAgent();
|
||||
|
||||
const patched = await agent
|
||||
.patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`)
|
||||
.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`)
|
||||
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
|
||||
expect(patched.status).to.equal(200);
|
||||
expect(patched.body.status).to.equal("applied");
|
||||
|
|
@ -215,12 +215,12 @@ describe("Admin tech-steps triage", () => {
|
|||
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/api/tech-steps/corrections");
|
||||
const res = await agent.get("/admin/tech-steps/corrections");
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(2);
|
||||
|
||||
const removals = await agent
|
||||
.get("/admin/api/tech-steps/corrections")
|
||||
.get("/admin/tech-steps/corrections")
|
||||
.query({ hasCorrectedTechStep: "false" });
|
||||
expect(removals.body).to.have.length(1);
|
||||
expect(removals.body[0].clauseText).to.equal("Rien ici");
|
||||
|
|
@ -242,7 +242,7 @@ describe("Admin tech-steps triage", () => {
|
|||
});
|
||||
const agent = await adminAgent();
|
||||
const res = await agent
|
||||
.get("/admin/api/tech-steps/training-data-snippet")
|
||||
.get("/admin/tech-steps/training-data-snippet")
|
||||
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.suggestionCount).to.equal(1);
|
||||
|
|
@ -261,7 +261,7 @@ describe("Admin tech-steps triage", () => {
|
|||
}
|
||||
this.timeout(60000);
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.post("/admin/api/tech-steps/retrain").send({});
|
||||
const res = await agent.post("/admin/tech-steps/retrain").send({});
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.keys([
|
||||
"f1",
|
||||
|
|
@ -292,14 +292,14 @@ describe("Admin tech-steps triage", () => {
|
|||
// (100ms later) checks it. Swallow its result/rejection — this test
|
||||
// only asserts on the second request.
|
||||
const first = agent
|
||||
.post("/admin/api/tech-steps/retrain")
|
||||
.post("/admin/tech-steps/retrain")
|
||||
.send({})
|
||||
.then(
|
||||
(res) => res,
|
||||
(err) => err,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const second = await agent.post("/admin/api/tech-steps/retrain").send({});
|
||||
const second = await agent.post("/admin/tech-steps/retrain").send({});
|
||||
expect(second.status).to.equal(409);
|
||||
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
|
||||
await first;
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
import { ErrorCode } 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 { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const password = faker.internet.password({ length: 16 });
|
||||
await prisma.adminUser.create({
|
||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||
});
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
/**
|
||||
* `/admin/api/tours/*` — CRUD over the guided-tour scenarios that
|
||||
* `GET /reference/tours` serves to the web tour engine (issues #42/#44).
|
||||
* Every route behind `requireAdmin`. The DB starts seeded with the two
|
||||
* default scenarios (`general`, `techStepMetadata`) and the `TourTarget`
|
||||
* catalogue (see `reference-seed-data.ts`).
|
||||
*/
|
||||
describe("Admin tours — scenario CRUD", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function adminAgent() {
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
return agent;
|
||||
}
|
||||
|
||||
it("rejects every route without an admin session", async () => {
|
||||
expect((await request(app).get("/admin/api/tours")).status).to.equal(401);
|
||||
expect((await request(app).get("/admin/api/tours/targets")).status).to.equal(401);
|
||||
expect(
|
||||
(await request(app).post("/admin/api/tours").send({ key: "x", name: "X" })).status,
|
||||
).to.equal(401);
|
||||
expect((await request(app).patch("/admin/api/tours/1").send({})).status).to.equal(401);
|
||||
expect((await request(app).delete("/admin/api/tours/1")).status).to.equal(401);
|
||||
});
|
||||
|
||||
it("lists the seeded scenarios (enabled and disabled) with their ordered steps", async () => {
|
||||
const agent = await adminAgent();
|
||||
await prisma.tourScenario.update({
|
||||
where: { key: "techStepMetadata" },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
const res = await agent.get("/admin/api/tours");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
const byKey = (key: string) =>
|
||||
res.body.find((scenario: { key: string }) => scenario.key === key);
|
||||
expect(byKey("general").enabled).to.equal(true);
|
||||
expect(byKey("techStepMetadata").enabled).to.equal(false);
|
||||
expect(byKey("general").steps.length).to.be.greaterThan(0);
|
||||
expect(byKey("general").steps[0]).to.have.keys([
|
||||
"order",
|
||||
"route",
|
||||
"targetKey",
|
||||
"title",
|
||||
"content",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists the highlight-target catalogue", async () => {
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent.get("/admin/api/tours/targets");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.map((t: { key: string }) => t.key)).to.include("planning-grid");
|
||||
expect(res.body[0]).to.have.keys(["key", "description"]);
|
||||
});
|
||||
|
||||
it("creates an empty scenario, then fills its steps with a block PATCH", async () => {
|
||||
const agent = await adminAgent();
|
||||
|
||||
const created = await agent
|
||||
.post("/admin/api/tours")
|
||||
.send({ key: "welcomeBack", name: "Retour utilisateur" });
|
||||
expect(created.status).to.equal(201);
|
||||
expect(created.body).to.include({
|
||||
key: "welcomeBack",
|
||||
name: "Retour utilisateur",
|
||||
enabled: true,
|
||||
});
|
||||
expect(created.body.steps).to.deep.equal([]);
|
||||
|
||||
const patched = await agent.patch(`/admin/api/tours/${created.body.id}`).send({
|
||||
name: "Retour utilisateur",
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ route: "/", targetKey: "planning-grid", title: "Le planning", content: "Votre semaine." },
|
||||
{
|
||||
route: null,
|
||||
targetKey: "planning-cook-button",
|
||||
title: "Cuisiner",
|
||||
content: "Le bouton pour lancer la séance.",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(patched.status).to.equal(200);
|
||||
expect(patched.body.steps.map((s: { order: number }) => s.order)).to.deep.equal([0, 1]);
|
||||
expect(patched.body.steps[1].targetKey).to.equal("planning-cook-button");
|
||||
expect(patched.body.steps[1].route).to.equal(null);
|
||||
});
|
||||
|
||||
it("replaces the whole step list on PATCH (delete-all then recreate)", async () => {
|
||||
const agent = await adminAgent();
|
||||
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
|
||||
|
||||
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
|
||||
name: "Tour d'orientation",
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ route: null, targetKey: "nav-recipes", title: "Recettes", content: "Une seule étape." },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.steps).to.have.length(1);
|
||||
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(1);
|
||||
});
|
||||
|
||||
it("rejects a duplicate key with 409 TOUR_KEY_ALREADY_IN_USE", async () => {
|
||||
const agent = await adminAgent();
|
||||
|
||||
const res = await agent.post("/admin/api/tours").send({ key: "general", name: "Doublon" });
|
||||
|
||||
expect(res.status).to.equal(409);
|
||||
expect(res.body.code).to.equal(ErrorCode.TOUR_KEY_ALREADY_IN_USE);
|
||||
});
|
||||
|
||||
it("rejects a PATCH whose step names an unknown target with 404 TOUR_TARGET_NOT_FOUND", async () => {
|
||||
const agent = await adminAgent();
|
||||
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
|
||||
|
||||
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
|
||||
name: "x",
|
||||
enabled: true,
|
||||
steps: [{ route: null, targetKey: "not-a-real-target", title: "t", content: "c" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.TOUR_TARGET_NOT_FOUND);
|
||||
// The scenario's real steps are untouched when the PATCH is rejected.
|
||||
expect(
|
||||
await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } }),
|
||||
).to.be.greaterThan(1);
|
||||
});
|
||||
|
||||
it("404s a PATCH/DELETE against an unknown scenario id", async () => {
|
||||
const agent = await adminAgent();
|
||||
|
||||
const patch = await agent
|
||||
.patch("/admin/api/tours/999999")
|
||||
.send({ name: "x", enabled: true, steps: [] });
|
||||
expect(patch.status).to.equal(404);
|
||||
expect(patch.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
|
||||
|
||||
const del = await agent.delete("/admin/api/tours/999999");
|
||||
expect(del.status).to.equal(404);
|
||||
});
|
||||
|
||||
it("deletes a scenario and cascades its steps and users' progress", async () => {
|
||||
const agent = await adminAgent();
|
||||
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
|
||||
const user = await prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: "T",
|
||||
lastName: "A",
|
||||
email: `${faker.string.uuid()}@example.test`,
|
||||
passwordHash: "x",
|
||||
},
|
||||
});
|
||||
await prisma.tourProgress.create({
|
||||
data: { userProfileId: user.id, scenarioId: general.id },
|
||||
});
|
||||
|
||||
const res = await agent.delete(`/admin/api/tours/${general.id}`);
|
||||
|
||||
expect(res.status).to.equal(204);
|
||||
expect(await prisma.tourScenario.findUnique({ where: { id: general.id } })).to.equal(null);
|
||||
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(0);
|
||||
expect(await prisma.tourProgress.count({ where: { scenarioId: general.id } })).to.equal(0);
|
||||
});
|
||||
|
||||
it("hides a disabled scenario from the public GET /reference/tours but keeps it in the admin list", async () => {
|
||||
const agent = await adminAgent();
|
||||
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
|
||||
|
||||
await agent
|
||||
.patch(`/admin/api/tours/${general.id}`)
|
||||
.send({ name: "Tour d'orientation", enabled: false, steps: [] });
|
||||
|
||||
const publicRes = await request(app).get("/reference/tours");
|
||||
expect(publicRes.body.map((t: { key: string }) => t.key)).to.not.include("general");
|
||||
|
||||
const adminRes = await agent.get("/admin/api/tours");
|
||||
expect(adminRes.body.map((t: { key: string }) => t.key)).to.include("general");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import { ErrorCode } 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 { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const password = faker.internet.password({ length: 16 });
|
||||
await prisma.adminUser.create({
|
||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||
});
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
/** A fresh, fake (never real-looking) create-user payload — same rationale as `auth.test.ts`'s `buildSignupPayload`. */
|
||||
function buildCreateUserPayload() {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/admin/api/users/*` — the admin surface for provisioning end-user
|
||||
* accounts by hand (`/admin/utilisateurs` page). Every route behind
|
||||
* `requireAdmin`. Creation generates a one-time temporary password and
|
||||
* returns it in the response; the account starts with no household, exactly
|
||||
* like a self-service signup.
|
||||
*/
|
||||
describe("Admin users — account provisioning", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function adminAgent() {
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||
return agent;
|
||||
}
|
||||
|
||||
it("rejects every route without an admin session", async () => {
|
||||
expect((await request(app).get("/admin/api/users")).status).to.equal(401);
|
||||
expect(
|
||||
(await request(app).post("/admin/api/users").send(buildCreateUserPayload())).status,
|
||||
).to.equal(401);
|
||||
});
|
||||
|
||||
it("creates a profile with no household and returns a one-time temporary password", async () => {
|
||||
const agent = await adminAgent();
|
||||
const payload = buildCreateUserPayload();
|
||||
|
||||
const res = await agent.post("/admin/api/users").send(payload);
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.user).to.include({
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
email: payload.email,
|
||||
householdName: null,
|
||||
});
|
||||
expect(res.body.user).to.not.have.property("passwordHash");
|
||||
expect(res.body.user).to.not.have.property("tokenVersion");
|
||||
expect(res.body.temporaryPassword).to.be.a("string").with.length.greaterThan(8);
|
||||
|
||||
const stored = await prisma.userProfile.findUnique({ where: { email: payload.email } });
|
||||
expect(stored?.houseId).to.equal(null);
|
||||
expect(stored?.dietId).to.equal(null);
|
||||
});
|
||||
|
||||
it("issues a password the new account can actually log in with", async () => {
|
||||
const agent = await adminAgent();
|
||||
const payload = buildCreateUserPayload();
|
||||
|
||||
const created = await agent.post("/admin/api/users").send(payload);
|
||||
const login = await request(app)
|
||||
.post("/auth/login")
|
||||
.send({ email: payload.email, password: created.body.temporaryPassword });
|
||||
|
||||
expect(login.status).to.equal(200);
|
||||
expect(login.body.email).to.equal(payload.email);
|
||||
});
|
||||
|
||||
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
||||
const agent = await adminAgent();
|
||||
const payload = buildCreateUserPayload();
|
||||
await agent.post("/admin/api/users").send(payload);
|
||||
|
||||
const res = await agent
|
||||
.post("/admin/api/users")
|
||||
.send({ ...buildCreateUserPayload(), email: payload.email });
|
||||
|
||||
expect(res.status).to.equal(409);
|
||||
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
|
||||
});
|
||||
|
||||
it("rejects an invalid body with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = await adminAgent();
|
||||
|
||||
const badEmail = await agent
|
||||
.post("/admin/api/users")
|
||||
.send({ ...buildCreateUserPayload(), email: "not-an-email" });
|
||||
expect(badEmail.status).to.equal(400);
|
||||
expect(badEmail.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
|
||||
const noFirstName = await agent
|
||||
.post("/admin/api/users")
|
||||
.send({ ...buildCreateUserPayload(), firstName: "" });
|
||||
expect(noFirstName.status).to.equal(400);
|
||||
});
|
||||
|
||||
it("lists accounts newest-first with their household name resolved, never the password", async () => {
|
||||
const agent = await adminAgent();
|
||||
const first = buildCreateUserPayload();
|
||||
const second = buildCreateUserPayload();
|
||||
const firstRes = await agent.post("/admin/api/users").send(first);
|
||||
await agent.post("/admin/api/users").send(second);
|
||||
|
||||
// Put the first account in a household so `householdName` has something to
|
||||
// resolve — `House.adminId` is required, so the account is its own admin.
|
||||
const house = await prisma.house.create({
|
||||
data: {
|
||||
name: "Le Foyer Test",
|
||||
adminId: firstRes.body.user.id,
|
||||
inviteCode: faker.string.alphanumeric(8).toUpperCase(),
|
||||
},
|
||||
});
|
||||
await prisma.userProfile.update({
|
||||
where: { email: first.email },
|
||||
data: { houseId: house.id },
|
||||
});
|
||||
|
||||
const res = await agent.get("/admin/api/users");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(2);
|
||||
// Newest first: `second` was created after `first`.
|
||||
expect(res.body[0].email).to.equal(second.email);
|
||||
expect(res.body[1].email).to.equal(first.email);
|
||||
expect(res.body[1].householdName).to.equal("Le Foyer Test");
|
||||
expect(res.body[0].householdName).to.equal(null);
|
||||
for (const row of res.body) {
|
||||
expect(row).to.not.have.property("temporaryPassword");
|
||||
expect(row).to.not.have.property("passwordHash");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -55,17 +55,6 @@ describe("Auth", () => {
|
|||
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
|
||||
});
|
||||
|
||||
it("also echoes the session JWT in the response body (for cookie-less clients)", async () => {
|
||||
const res = await request(app).post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
// Same token that's set as the cookie — see `AuthResponse`
|
||||
// (packages/shared). Lets a Capacitor WebView, whose origin can't
|
||||
// receive the SameSite cookie, store it and send it as a Bearer.
|
||||
expect(res.body.token).to.be.a("string").with.length.greaterThan(0);
|
||||
expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`);
|
||||
});
|
||||
|
||||
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
||||
const payload = buildSignupPayload();
|
||||
await request(app).post("/auth/signup").send(payload);
|
||||
|
|
@ -106,16 +95,6 @@ describe("Auth", () => {
|
|||
expect(res.body.email).to.equal(payload.email);
|
||||
});
|
||||
|
||||
it("echoes the session JWT in the response body too", async () => {
|
||||
const res = await request(app)
|
||||
.post("/auth/login")
|
||||
.send({ email: payload.email, password: payload.password });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.token).to.be.a("string").with.length.greaterThan(0);
|
||||
expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`);
|
||||
});
|
||||
|
||||
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
||||
const res = await request(app)
|
||||
.post("/auth/login")
|
||||
|
|
@ -155,41 +134,6 @@ describe("Auth", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The Bearer path exists for clients whose origin can't carry the
|
||||
// `SameSite=Lax` session cookie (a Capacitor WebView — issue #39). The
|
||||
// cookie stays the primary mechanism; the header is only consulted when
|
||||
// no cookie is present. `request(app)` (no `.agent`) sends no cookie, so
|
||||
// these requests exercise the fallback in isolation.
|
||||
describe("Authorization: Bearer fallback (cookie-less clients)", () => {
|
||||
it("authenticates a protected route with a valid Bearer token and no cookie", async () => {
|
||||
const payload = buildSignupPayload();
|
||||
const signupRes = await request(app).post("/auth/signup").send(payload);
|
||||
|
||||
const res = await request(app)
|
||||
.get("/auth/me")
|
||||
.set("Authorization", `Bearer ${signupRes.body.token}`);
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.email).to.equal(payload.email);
|
||||
});
|
||||
|
||||
it("rejects a malformed Bearer token with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/auth/me").set("Authorization", "Bearer not-a-real-jwt");
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("ignores a non-Bearer Authorization scheme and 401s", async () => {
|
||||
const res = await request(app)
|
||||
.get("/auth/me")
|
||||
.set("Authorization", "Basic dXNlcjpwYXNzd29yZA==");
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /auth/me", () => {
|
||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).delete("/auth/me").send({ password: "whatever" });
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import { ErrorCode, 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 { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
||||
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 valid `POST /contact` body — message comfortably over the 10-char minimum. */
|
||||
function buildContactPayload() {
|
||||
return {
|
||||
type: "bug" as const,
|
||||
message: "Le bouton « Envoyer » ne réagit pas sur la page planning.",
|
||||
pageUrl: "/",
|
||||
appVersion: "0.2.1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("Contact", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("POST /contact", () => {
|
||||
it("rejects requests without a session with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).post("/contact").send(buildContactPayload());
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
||||
});
|
||||
|
||||
it("stores the message with its fields, attributed to the caller, and captures the user-agent from the header", async () => {
|
||||
const agent = request.agent(app);
|
||||
const signup = buildSignupPayload();
|
||||
await agent.post("/auth/signup").send(signup);
|
||||
const author = await prisma.userProfile.findFirstOrThrow({ where: { email: signup.email } });
|
||||
|
||||
const res = await agent
|
||||
.post("/contact")
|
||||
.set("User-Agent", "BatchCookingApp/0.2.1 (Android 14)")
|
||||
.send(buildContactPayload());
|
||||
|
||||
expect(res.status).to.equal(204);
|
||||
|
||||
const rows = await prisma.contactMessage.findMany();
|
||||
expect(rows).to.have.lengthOf(1);
|
||||
const [row] = rows;
|
||||
expect(row.authorId).to.equal(author.id);
|
||||
expect(row.type).to.equal("bug");
|
||||
expect(row.message).to.equal(buildContactPayload().message);
|
||||
expect(row.pageUrl).to.equal("/");
|
||||
expect(row.appVersion).to.equal("0.2.1");
|
||||
// Read from the request header, never the body.
|
||||
expect(row.userAgent).to.equal("BatchCookingApp/0.2.1 (Android 14)");
|
||||
});
|
||||
|
||||
it("ignores any user-agent sent in the body — the header is the only source", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent
|
||||
.post("/contact")
|
||||
.set("User-Agent", "real-agent/1.0")
|
||||
.send({ ...buildContactPayload(), userAgent: "spoofed-agent/9.9" });
|
||||
|
||||
expect(res.status).to.equal(204);
|
||||
const row = await prisma.contactMessage.findFirstOrThrow();
|
||||
expect(row.userAgent).to.equal("real-agent/1.0");
|
||||
});
|
||||
|
||||
it("keeps pageUrl / appVersion null when the client omits them", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.post("/contact").send({
|
||||
type: "suggestion",
|
||||
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(204);
|
||||
const row = await prisma.contactMessage.findFirstOrThrow();
|
||||
expect(row.type).to.equal("suggestion");
|
||||
expect(row.pageUrl).to.equal(null);
|
||||
expect(row.appVersion).to.equal(null);
|
||||
});
|
||||
|
||||
it("rejects a message shorter than 10 characters with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
// 5 chars — and the schema trims before counting, so leading/trailing
|
||||
// whitespace can't pad it to the minimum.
|
||||
const res = await agent.post("/contact").send({ type: "bug", message: " court " });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
||||
});
|
||||
|
||||
it("rejects a message longer than 5000 characters with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.post("/contact").send({ type: "bug", message: "a".repeat(5001) });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
||||
});
|
||||
|
||||
it("rejects an unknown type with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent
|
||||
.post("/contact")
|
||||
.send({ type: "complaint", message: "Ceci est un message de test suffisamment long." });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -177,7 +177,6 @@ describe("Cooking session", () => {
|
|||
meal: "dejeuner",
|
||||
recipeId: soupe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
{
|
||||
planningId: planning.id,
|
||||
|
|
@ -185,7 +184,6 @@ describe("Cooking session", () => {
|
|||
meal: "diner",
|
||||
recipeId: tarte.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -282,7 +280,6 @@ describe("Cooking session", () => {
|
|||
meal: i === 0 ? "dejeuner" : "diner",
|
||||
recipeId,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -328,42 +325,5 @@ describe("Cooking session", () => {
|
|||
180, 210,
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a leftovers item out of the cooking plan — it has nothing to cook (issue #34)", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
name: "Soupe",
|
||||
authorId: houseRes.body.adminId,
|
||||
portions: 4,
|
||||
steps: { create: [{ order: 0, description: "Faire mijoter la soupe." }] },
|
||||
},
|
||||
});
|
||||
|
||||
// One real slot (12 prepared for 10 requested), then a leftovers slot
|
||||
// reusing 2 of it — the plan must still contain only "Soupe" once.
|
||||
const source = await agent.post("/planning/items").send({
|
||||
date: today(),
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portionsRequested: 10,
|
||||
});
|
||||
await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.body.id,
|
||||
portions: 2,
|
||||
});
|
||||
|
||||
const res = await agent.get("/cooking-session").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.deep.equal(["Soupe"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import { expect } from "chai";
|
||||
import { envSchema } from "../src/config/env.js";
|
||||
|
||||
/**
|
||||
* Pure schema test — no DB, no `process.env`. Covers `blankAsUnset` on the
|
||||
* `FORGEJO_*` trio: `docker-compose.yml` forwards every optional var as
|
||||
* `${FOO:-}`, which the container receives as `""` (not an absent key) when
|
||||
* the stack's `.env` leaves it blank. Those three must read a blank value
|
||||
* as "not configured" and still boot, rather than trip `FORGEJO_BASE_URL`'s
|
||||
* `.url()` / `FORGEJO_REPO`'s regex and abort `parse()` at startup.
|
||||
*/
|
||||
describe("envSchema — FORGEJO_* blank handling", () => {
|
||||
// The only two vars with no default and no `.optional()`.
|
||||
const base = {
|
||||
JWT_SECRET: "x".repeat(32),
|
||||
INTENT_SERVICE_SECRET: "y".repeat(32),
|
||||
};
|
||||
|
||||
it("treats blank FORGEJO_* values as unset instead of failing validation", () => {
|
||||
const parsed = envSchema.parse({
|
||||
...base,
|
||||
FORGEJO_BASE_URL: "",
|
||||
FORGEJO_TOKEN: "",
|
||||
FORGEJO_REPO: "",
|
||||
});
|
||||
|
||||
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
|
||||
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
|
||||
expect(parsed.FORGEJO_REPO).to.equal(undefined);
|
||||
});
|
||||
|
||||
it("treats a fully absent FORGEJO_* trio the same way", () => {
|
||||
const parsed = envSchema.parse({ ...base });
|
||||
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
|
||||
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
|
||||
expect(parsed.FORGEJO_REPO).to.equal(undefined);
|
||||
});
|
||||
|
||||
it("still passes through and validates non-blank FORGEJO_* values", () => {
|
||||
const parsed = envSchema.parse({
|
||||
...base,
|
||||
FORGEJO_BASE_URL: "https://git.example.com",
|
||||
FORGEJO_TOKEN: "a-token",
|
||||
FORGEJO_REPO: "owner/repo",
|
||||
});
|
||||
|
||||
expect(parsed.FORGEJO_BASE_URL).to.equal("https://git.example.com");
|
||||
expect(parsed.FORGEJO_TOKEN).to.equal("a-token");
|
||||
expect(parsed.FORGEJO_REPO).to.equal("owner/repo");
|
||||
});
|
||||
|
||||
it("still rejects a non-blank but malformed FORGEJO_BASE_URL", () => {
|
||||
expect(() => envSchema.parse({ ...base, FORGEJO_BASE_URL: "git.example.com" })).to.throw();
|
||||
});
|
||||
|
||||
it("still rejects a non-blank but malformed FORGEJO_REPO", () => {
|
||||
expect(() => envSchema.parse({ ...base, FORGEJO_REPO: "not-a-repo" })).to.throw();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
import { expect } from "chai";
|
||||
import { env } from "../src/config/env.js";
|
||||
import { forgejo } from "../src/lib/forgejo/forgejo.service.js";
|
||||
|
||||
/**
|
||||
* Stubs `globalThis.fetch` for one test — same approach as
|
||||
* `recipe-sources/the-meal-db.test.ts` (no HTTP-mock library in this repo).
|
||||
* `capture` collects the `(url, init)` each call was made with, so a test
|
||||
* can assert the request shape. Restored by `afterEach`.
|
||||
*/
|
||||
function stubFetch(
|
||||
responder: (url: string, init: RequestInit | undefined) => Response | Promise<Response>,
|
||||
capture: { url: string; init: RequestInit | undefined }[],
|
||||
) {
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
capture.push({ url, init });
|
||||
return responder(url, init);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const configured = forgejo.isConfigured();
|
||||
|
||||
describe("ForgejoService", () => {
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("isConfigured() reports whether all three FORGEJO_* vars are set", () => {
|
||||
expect(forgejo.isConfigured()).to.equal(
|
||||
env.FORGEJO_BASE_URL !== undefined &&
|
||||
env.FORGEJO_TOKEN !== undefined &&
|
||||
env.FORGEJO_REPO !== undefined,
|
||||
);
|
||||
});
|
||||
|
||||
describe("createIssue", () => {
|
||||
it("POSTs to the repo's issues endpoint with a `token` auth header and { title, body }", async function () {
|
||||
if (!configured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const calls: { url: string; init: RequestInit | undefined }[] = [];
|
||||
stubFetch(
|
||||
() =>
|
||||
new Response(JSON.stringify({ number: 42, html_url: "https://forge/x/y/issues/42" }), {
|
||||
status: 201,
|
||||
}),
|
||||
calls,
|
||||
);
|
||||
|
||||
const result = await forgejo.createIssue({ title: "Un titre", body: "Un corps" });
|
||||
|
||||
expect(result).to.deep.equal({ number: 42, htmlUrl: "https://forge/x/y/issues/42" });
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
const [call] = calls;
|
||||
expect(call.url).to.equal(`${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`);
|
||||
expect(call.init?.method).to.equal("POST");
|
||||
const headers = call.init?.headers as Record<string, string>;
|
||||
expect(headers.Authorization).to.equal(`token ${env.FORGEJO_TOKEN}`);
|
||||
expect(JSON.parse(String(call.init?.body))).to.deep.equal({
|
||||
title: "Un titre",
|
||||
body: "Un corps",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws 502 FORGEJO_REQUEST_FAILED on a non-2xx response", async function () {
|
||||
if (!configured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
stubFetch(() => new Response("nope", { status: 403 }), []);
|
||||
|
||||
try {
|
||||
await forgejo.createIssue({ title: "t", body: "b" });
|
||||
expect.fail("expected createIssue to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(HttpError);
|
||||
expect((err as HttpError).status).to.equal(502);
|
||||
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws 502 FORGEJO_REQUEST_FAILED on a network error", async function () {
|
||||
if (!configured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
stubFetch(() => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}, []);
|
||||
|
||||
try {
|
||||
await forgejo.createIssue({ title: "t", body: "b" });
|
||||
expect.fail("expected createIssue to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(HttpError);
|
||||
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -302,58 +302,5 @@ describe("Internal tech-step worker routes", () => {
|
|||
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
// Moved here from the now-deleted admin-monitoring.test.ts when that
|
||||
// file's own REST route (GET /admin/api/monitoring) was replaced by a
|
||||
// WebSocket push (see admin-monitoring-socket.test.ts) — this route
|
||||
// (POST /internal/tech-steps/heartbeat) is unrelated to that change
|
||||
// and belongs with the rest of this router's own tests. No separate
|
||||
// "rejects with no secret" case here: `requireInternalWorker` above
|
||||
// already proves that generically for this whole router.
|
||||
describe("POST /internal/tech-steps/heartbeat", () => {
|
||||
it("rejects a malformed body with 400", async () => {
|
||||
const res = await withSecret(
|
||||
request(app).post("/internal/tech-steps/heartbeat").send({ event: "not-a-real-event" }),
|
||||
);
|
||||
expect(res.status).to.equal(400);
|
||||
});
|
||||
|
||||
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async () => {
|
||||
const res = await withSecret(
|
||||
request(app)
|
||||
.post("/internal/tech-steps/heartbeat")
|
||||
.send({
|
||||
event: "job",
|
||||
job: "audit-low-confidence",
|
||||
ok: true,
|
||||
counts: { suggestions: 3 },
|
||||
}),
|
||||
);
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal({ ok: true });
|
||||
|
||||
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
||||
where: { workerKey: "tech-step-llm-worker" },
|
||||
});
|
||||
expect(stored.lastRunAt).to.not.equal(null);
|
||||
expect(stored.lastResult).to.deep.equal({
|
||||
job: "audit-low-confidence",
|
||||
ok: true,
|
||||
counts: { suggestions: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves lastRunAt null for a boot/tick ping", async () => {
|
||||
await withSecret(
|
||||
request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" }),
|
||||
);
|
||||
|
||||
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
||||
where: { workerKey: "tech-step-llm-worker" },
|
||||
});
|
||||
expect(stored.lastSeenAt).to.be.instanceOf(Date);
|
||||
expect(stored.lastRunAt).to.equal(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import { computeAvailableLeftover, computeBatchedPortions } from "@batch-cooking/shared";
|
||||
import { expect } from "chai";
|
||||
|
||||
/**
|
||||
* Pure portion arithmetic from `@batch-cooking/shared` — no DB, so this
|
||||
* suite skips `resetDatabase`. The API (`addPlanningItem`, and later the
|
||||
* leftover-assignment endpoints) and the web app both call these; the
|
||||
* numbers below are the two worked examples from issue #34 plus the
|
||||
* boundary cases.
|
||||
*/
|
||||
describe("planning portion arithmetic", () => {
|
||||
describe("computeBatchedPortions", () => {
|
||||
it("rounds up to one batch when fewer portions are requested than the recipe yields", () => {
|
||||
expect(computeBatchedPortions(4, 3)).to.deep.equal({ portions: 4, leftover: 1 });
|
||||
});
|
||||
|
||||
it("cooks several batches when more portions are requested than one batch yields", () => {
|
||||
expect(computeBatchedPortions(4, 10)).to.deep.equal({ portions: 12, leftover: 2 });
|
||||
});
|
||||
|
||||
it("leaves no leftover on an exact batch boundary", () => {
|
||||
expect(computeBatchedPortions(4, 4)).to.deep.equal({ portions: 4, leftover: 0 });
|
||||
expect(computeBatchedPortions(4, 8)).to.deep.equal({ portions: 8, leftover: 0 });
|
||||
});
|
||||
|
||||
it("still prepares one batch when a single portion is requested", () => {
|
||||
expect(computeBatchedPortions(6, 1)).to.deep.equal({ portions: 6, leftover: 5 });
|
||||
});
|
||||
|
||||
it("handles a one-portion recipe (every request is an exact number of batches)", () => {
|
||||
expect(computeBatchedPortions(1, 5)).to.deep.equal({ portions: 5, leftover: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAvailableLeftover", () => {
|
||||
const base = { portions: 12, portionsRequested: 10, discardedPortions: 0, leftoverUses: [] };
|
||||
|
||||
it("is the prepared-minus-requested pool when nothing has been discarded or served", () => {
|
||||
expect(computeAvailableLeftover(base)).to.equal(2);
|
||||
});
|
||||
|
||||
it("subtracts portions already served to other slots", () => {
|
||||
expect(
|
||||
computeAvailableLeftover({ ...base, leftoverUses: [{ portions: 1 }, { portions: 1 }] }),
|
||||
).to.equal(0);
|
||||
});
|
||||
|
||||
it("subtracts explicitly discarded portions", () => {
|
||||
expect(computeAvailableLeftover({ ...base, discardedPortions: 2 })).to.equal(0);
|
||||
});
|
||||
|
||||
it("never goes negative", () => {
|
||||
expect(
|
||||
computeAvailableLeftover({
|
||||
...base,
|
||||
discardedPortions: 5,
|
||||
leftoverUses: [{ portions: 3 }],
|
||||
}),
|
||||
).to.equal(0);
|
||||
});
|
||||
|
||||
it("is 0 for an item whose request landed exactly on a batch (no pool at all)", () => {
|
||||
expect(
|
||||
computeAvailableLeftover({
|
||||
portions: 8,
|
||||
portionsRequested: 8,
|
||||
discardedPortions: 0,
|
||||
leftoverUses: [],
|
||||
}),
|
||||
).to.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -114,7 +114,6 @@ describe("Planning", () => {
|
|||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 3,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -123,14 +122,7 @@ describe("Planning", () => {
|
|||
expect(res.status).to.equal(200);
|
||||
expect(res.body.id).to.equal(planning.id);
|
||||
expect(res.body.items).to.have.length(1);
|
||||
expect(res.body.items[0]).to.include({
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
portions: 4,
|
||||
portionsRequested: 3,
|
||||
discardedPortions: 0,
|
||||
});
|
||||
expect(res.body.items[0].sourceItem).to.equal(null);
|
||||
expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner" });
|
||||
expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" });
|
||||
});
|
||||
|
||||
|
|
@ -179,7 +171,6 @@ describe("Planning", () => {
|
|||
meal: "dejeuner",
|
||||
recipeId: recipe.id,
|
||||
portions: 2,
|
||||
portionsRequested: 2,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -192,261 +183,4 @@ describe("Planning", () => {
|
|||
expect(thisWeekRes.body).to.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/items", () => {
|
||||
/** Signs up, creates a household, and returns an authenticated agent + a saved recipe with the given yield. */
|
||||
async function setup(recipePortions: number) {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Lasagnes", authorId: houseRes.body.adminId, portions: recipePortions },
|
||||
});
|
||||
return { agent, recipe };
|
||||
}
|
||||
|
||||
it("rounds the prepared portions up to a whole batch of the recipe and reports the leftover", async () => {
|
||||
const { agent, recipe } = await setup(4);
|
||||
|
||||
const res = await agent.post("/planning/items").send({
|
||||
date: today(),
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portionsRequested: 3,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
// 3 requested, 4-portion recipe -> one batch of 4 prepared, 1 leftover.
|
||||
expect(res.body).to.include({ portions: 4, portionsRequested: 3, discardedPortions: 0 });
|
||||
expect(res.body.sourceItem).to.equal(null);
|
||||
|
||||
const stored = await prisma.planningItem.findUnique({ where: { id: res.body.id } });
|
||||
expect(stored).to.include({ portions: 4, portionsRequested: 3 });
|
||||
});
|
||||
|
||||
it("cooks several batches when more portions are requested than one batch yields", async () => {
|
||||
const { agent, recipe } = await setup(4);
|
||||
|
||||
const res = await agent.post("/planning/items").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
recipeId: recipe.id,
|
||||
portionsRequested: 10,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
// 10 requested, 4-portion recipe -> three batches (12) prepared, 2 leftover.
|
||||
expect(res.body).to.include({ portions: 12, portionsRequested: 10 });
|
||||
});
|
||||
|
||||
it("leaves no leftover when the request lands exactly on a batch boundary", async () => {
|
||||
const { agent, recipe } = await setup(4);
|
||||
|
||||
const res = await agent.post("/planning/items").send({
|
||||
date: today(),
|
||||
weekDay: "mercredi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portionsRequested: 8,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body).to.include({ portions: 8, portionsRequested: 8 });
|
||||
});
|
||||
|
||||
it("rejects a non-positive portionsRequested with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent, recipe } = await setup(4);
|
||||
|
||||
const res = await agent.post("/planning/items").send({
|
||||
date: today(),
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portionsRequested: 0,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leftovers — POST /planning/items/leftover and PATCH .../discard-leftover", () => {
|
||||
/**
|
||||
* Signs up + creates a household, saves a 4-portion recipe, and plans
|
||||
* it once with `requested` portions — so the resulting item has a
|
||||
* leftover pool of `ceil(requested/4)*4 - requested`. Returns the agent,
|
||||
* the recipe, and that source item.
|
||||
*/
|
||||
async function setupWithSource(requested: number) {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Lasagnes", authorId: houseRes.body.adminId, portions: 4 },
|
||||
});
|
||||
const addRes = await agent.post("/planning/items").send({
|
||||
date: today(),
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portionsRequested: requested,
|
||||
});
|
||||
return { agent, recipe, source: addRes.body };
|
||||
}
|
||||
|
||||
it("serves part of a source item's leftover pool as a new leftovers item on another slot", async () => {
|
||||
// 10 requested on a 4-portion recipe -> 12 prepared, pool of 2.
|
||||
const { agent, recipe, source } = await setupWithSource(10);
|
||||
expect(source.portions).to.equal(12);
|
||||
|
||||
const res = await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.id,
|
||||
portions: 2,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body).to.include({
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
portions: 2,
|
||||
portionsRequested: 2,
|
||||
discardedPortions: 0,
|
||||
});
|
||||
expect(res.body.sourceItem).to.deep.equal({ id: source.id });
|
||||
// The recipe is copied from the source, not sent by the client.
|
||||
expect(res.body.recipe.id).to.equal(recipe.id);
|
||||
});
|
||||
|
||||
it("rejects assigning more portions than the source pool still has, with 409 LEFTOVER_QUANTITY_EXCEEDED", async () => {
|
||||
const { agent, source } = await setupWithSource(10); // pool of 2
|
||||
|
||||
// Take 2 of the 2 available...
|
||||
await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.id,
|
||||
portions: 2,
|
||||
});
|
||||
// ...then ask for 1 more — nothing left.
|
||||
const res = await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mercredi",
|
||||
meal: "diner",
|
||||
sourceItemId: source.id,
|
||||
portions: 1,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(409);
|
||||
expect(res.body.code).to.equal(ErrorCode.LEFTOVER_QUANTITY_EXCEEDED);
|
||||
});
|
||||
|
||||
it("404s when the source item belongs to another household", async () => {
|
||||
const { source } = await setupWithSource(10);
|
||||
|
||||
const stranger = request.agent(app);
|
||||
await stranger.post("/auth/signup").send(buildSignupPayload());
|
||||
await stranger.post("/house").send({ name: "Ailleurs" });
|
||||
|
||||
const res = await stranger.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.id,
|
||||
portions: 1,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("404s when the source item is itself a leftovers item (no leftovers-on-leftovers)", async () => {
|
||||
const { agent, source } = await setupWithSource(10);
|
||||
const leftover = await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.id,
|
||||
portions: 1,
|
||||
});
|
||||
|
||||
const res = await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mercredi",
|
||||
meal: "diner",
|
||||
sourceItemId: leftover.body.id,
|
||||
portions: 1,
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("deleting the source item cascades to every leftovers item served from it", async () => {
|
||||
const { agent, source } = await setupWithSource(10);
|
||||
const leftover = await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.id,
|
||||
portions: 2,
|
||||
});
|
||||
|
||||
await agent.delete(`/planning/items/${source.id}`).expect(204);
|
||||
|
||||
const stillThere = await prisma.planningItem.findUnique({ where: { id: leftover.body.id } });
|
||||
expect(stillThere).to.equal(null);
|
||||
});
|
||||
|
||||
it("discards the whole remaining pool, and is idempotent", async () => {
|
||||
const { agent, source } = await setupWithSource(10); // pool of 2
|
||||
|
||||
const first = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
|
||||
expect(first.status).to.equal(200);
|
||||
expect(first.body.discardedPortions).to.equal(2);
|
||||
|
||||
// Nothing left — a second call changes nothing.
|
||||
const second = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
|
||||
expect(second.status).to.equal(200);
|
||||
expect(second.body.discardedPortions).to.equal(2);
|
||||
|
||||
// And the pool is now empty for an assignment too.
|
||||
const assign = await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.id,
|
||||
portions: 1,
|
||||
});
|
||||
expect(assign.status).to.equal(409);
|
||||
});
|
||||
|
||||
it("only discards what's still available after some was already served", async () => {
|
||||
const { agent, source } = await setupWithSource(10); // pool of 2
|
||||
await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.id,
|
||||
portions: 1,
|
||||
});
|
||||
|
||||
const res = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
|
||||
expect(res.status).to.equal(200);
|
||||
// 1 of the 2 was served; only the other 1 gets discarded.
|
||||
expect(res.body.discardedPortions).to.equal(1);
|
||||
});
|
||||
|
||||
it("404s discarding an unknown planning item", async () => {
|
||||
const { agent } = await setupWithSource(4);
|
||||
const res = await agent.patch("/planning/items/999999/discard-leftover");
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -195,95 +195,4 @@ describe("Profile", () => {
|
|||
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Guided tours (GET /profile/tours, POST /profile/tours/:key/complete)", () => {
|
||||
it("rejects an unauthenticated request with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/profile/tours");
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("starts with no completed tours for a fresh profile", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/profile/tours");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal({ completedTourKeys: [] });
|
||||
});
|
||||
|
||||
it("marks a tour seen and reports it back", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const post = await agent.post("/profile/tours/general/complete").send({});
|
||||
expect(post.status).to.equal(204);
|
||||
|
||||
const res = await agent.get("/profile/tours");
|
||||
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
|
||||
});
|
||||
|
||||
it("is idempotent — completing the same tour twice stays a single row", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
await agent.post("/profile/tours/general/complete").send({});
|
||||
const second = await agent.post("/profile/tours/general/complete").send({});
|
||||
expect(second.status).to.equal(204);
|
||||
|
||||
const res = await agent.get("/profile/tours");
|
||||
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
|
||||
});
|
||||
|
||||
it("accepts { skipped: true } and still marks the tour seen", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const post = await agent
|
||||
.post("/profile/tours/techStepMetadata/complete")
|
||||
.send({ skipped: true });
|
||||
expect(post.status).to.equal(204);
|
||||
|
||||
const res = await agent.get("/profile/tours");
|
||||
expect(res.body.completedTourKeys).to.deep.equal(["techStepMetadata"]);
|
||||
});
|
||||
|
||||
it("rejects an unknown tour key with 404 TOUR_NOT_FOUND", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.post("/profile/tours/does-not-exist/complete").send({});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects completing a disabled tour with 404 TOUR_NOT_FOUND", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
await prisma.tourScenario.update({
|
||||
where: { key: "techStepMetadata" },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
const res = await agent.post("/profile/tours/techStepMetadata/complete").send({});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("keeps tours per-profile — one user's completion doesn't leak to another", async () => {
|
||||
const alice = request.agent(app);
|
||||
await alice.post("/auth/signup").send(buildSignupPayload());
|
||||
await alice.post("/profile/tours/general/complete").send({});
|
||||
|
||||
const bob = request.agent(app);
|
||||
await bob.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await bob.get("/profile/tours");
|
||||
expect(res.body.completedTourKeys).to.deep.equal([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
import { computeRecipeDietViews, type DietView, type IngredientView } from "@batch-cooking/shared";
|
||||
import { expect } from "chai";
|
||||
|
||||
/**
|
||||
* Pure regime derivation from `@batch-cooking/shared` — no DB, so this suite
|
||||
* skips `resetDatabase`. `recipe.service.ts`'s `toRecipeSummaryView` calls
|
||||
* it for every recipe read (issue #131 dropped the hand-tagged `RecipeDiet`).
|
||||
*/
|
||||
|
||||
const VEGETARIAN: DietView = { id: 1, key: "vegetarian" };
|
||||
const VEGAN: DietView = { id: 2, key: "vegan" };
|
||||
const PESCATARIAN: DietView = { id: 3, key: "pescatarian" };
|
||||
const GLUTEN_FREE: DietView = { id: 4, key: "glutenFree" };
|
||||
|
||||
/** A minimal ingredient fixture — only the fields `computeRecipeDietViews` reads. */
|
||||
function ingredient(
|
||||
diets: DietView[],
|
||||
opts: { gluten?: boolean; placeholder?: boolean } = {},
|
||||
): Pick<IngredientView, "diets" | "allergens" | "isPlaceholder"> {
|
||||
return {
|
||||
diets,
|
||||
allergens: opts.gluten ? [{ id: 9, key: "gluten", kind: "INTOLERANCE" }] : [],
|
||||
isPlaceholder: opts.placeholder ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeRecipeDietViews", () => {
|
||||
it("keeps only the regimes every ingredient is compatible with", () => {
|
||||
const result = computeRecipeDietViews(
|
||||
[
|
||||
ingredient([VEGETARIAN, VEGAN, PESCATARIAN]),
|
||||
ingredient([VEGETARIAN, PESCATARIAN]), // e.g. egg — not vegan
|
||||
],
|
||||
GLUTEN_FREE,
|
||||
);
|
||||
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "pescatarian", "glutenFree"]);
|
||||
});
|
||||
|
||||
it("drops every ingredient-derived regime when one ingredient is compatible with none", () => {
|
||||
const result = computeRecipeDietViews(
|
||||
[ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([])],
|
||||
GLUTEN_FREE,
|
||||
);
|
||||
// Still gluten-free — nothing carries the allergen.
|
||||
expect(result.map((d) => d.key)).to.deep.equal(["glutenFree"]);
|
||||
});
|
||||
|
||||
it("is not gluten-free when any ingredient carries the gluten allergen", () => {
|
||||
const result = computeRecipeDietViews(
|
||||
[
|
||||
ingredient([VEGETARIAN, VEGAN, PESCATARIAN]),
|
||||
ingredient([VEGETARIAN, VEGAN, PESCATARIAN], { gluten: true }),
|
||||
],
|
||||
GLUTEN_FREE,
|
||||
);
|
||||
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]);
|
||||
});
|
||||
|
||||
it("returns [] for a recipe with any placeholder (off-catalog) ingredient", () => {
|
||||
const result = computeRecipeDietViews(
|
||||
[ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([], { placeholder: true })],
|
||||
GLUTEN_FREE,
|
||||
);
|
||||
expect(result).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("returns [] when there are no ingredients", () => {
|
||||
expect(computeRecipeDietViews([], GLUTEN_FREE)).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("omits gluten-free entirely when no glutenFree DietView is supplied", () => {
|
||||
const result = computeRecipeDietViews(
|
||||
[ingredient([VEGAN, VEGETARIAN, PESCATARIAN])],
|
||||
undefined,
|
||||
);
|
||||
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]);
|
||||
});
|
||||
|
||||
it("borrows each regime's DietView (id + key) from the ingredients themselves", () => {
|
||||
const result = computeRecipeDietViews(
|
||||
[ingredient([VEGETARIAN]), ingredient([VEGETARIAN])],
|
||||
undefined,
|
||||
);
|
||||
expect(result).to.deep.equal([VEGETARIAN]);
|
||||
});
|
||||
});
|
||||
|
|
@ -62,6 +62,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Poulet basquaise",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: tomato, quantity: 3, unitId: piece },
|
||||
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
|
||||
|
|
@ -98,6 +99,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
|||
await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "x" }],
|
||||
});
|
||||
|
|
@ -120,6 +122,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
|||
const created = await agent.post("/recipes").send({
|
||||
name: "Édition",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "x" }],
|
||||
});
|
||||
|
|
@ -129,6 +132,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
|||
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Édition",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
|
||||
{ ingredientId: tomato, quantity: 1, unitId: piece },
|
||||
|
|
@ -142,6 +146,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
|||
await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Édition",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "x" }],
|
||||
});
|
||||
|
|
@ -158,6 +163,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Invalide",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
|
||||
],
|
||||
|
|
@ -175,6 +181,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Doublons libres",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
|
||||
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
|
||||
|
|
|
|||
|
|
@ -132,59 +132,6 @@ describe("mangerBougerAdapter", () => {
|
|||
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
|
||||
});
|
||||
|
||||
it("omits the query param entirely on the catalogue-browse path (no filter) — issue #78", async () => {
|
||||
let requestedUrl: string | undefined;
|
||||
globalThis.fetch = (async (url: string) => {
|
||||
requestedUrl = url;
|
||||
return new Response(htmlWithListNextData({ list: [], hasMorePages: true }), {
|
||||
status: 200,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
await mangerBougerAdapter.list({});
|
||||
|
||||
expect(requestedUrl).to.equal(
|
||||
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes?diet=ALL&page=1",
|
||||
);
|
||||
expect(requestedUrl).not.to.include("query=");
|
||||
});
|
||||
|
||||
it("treats a blank/whitespace query the same as no filter (no query param)", 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: " " });
|
||||
|
||||
expect(requestedUrl).not.to.include("query=");
|
||||
});
|
||||
|
||||
it("still paginates the catalogue-browse path (nextCursor set when hasMorePages)", async () => {
|
||||
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
|
||||
|
||||
const result = await mangerBougerAdapter.list({});
|
||||
|
||||
expect(result.nextCursor).to.equal("2");
|
||||
});
|
||||
|
||||
it("still appends the query param when a real search term is given", 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: "salade" });
|
||||
|
||||
expect(requestedUrl).to.include("&query=salade");
|
||||
});
|
||||
|
||||
it("skips a list entry missing a slug or a name", async () => {
|
||||
stubFetchHtml(
|
||||
htmlWithListNextData({
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
import { expect } from "chai";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
||||
import { ohMonBentoAdapter } from "../../src/sources/ohmonbento.js";
|
||||
|
||||
/** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`quitoque.test.ts`. */
|
||||
function stubFetchHtml(html: string, status = 200) {
|
||||
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
|
||||
}
|
||||
|
||||
/** Like {@link stubFetchHtml} but records the URL each call was made with — for asserting `list()`'s search/pagination URL. */
|
||||
function stubFetchHtmlCapturing(html: string): { calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
calls.push(typeof input === "string" ? input : input.toString());
|
||||
return new Response(html, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
return { calls };
|
||||
}
|
||||
|
||||
/** A search-results page with `cards` `<div class="entry-thumb">` blocks, each an `<a href="/<slug>/">` wrapping one `<img alt src>`. */
|
||||
function searchHtml(cards: { slug: string; title: string; image: string }[]): string {
|
||||
const blocks = cards
|
||||
.map(
|
||||
(card) =>
|
||||
`<div class="entry-thumb"><a href="/${card.slug}/">` +
|
||||
`<img alt="${card.title}" src="${card.image}" /></a></div>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<!doctype html><html><body><div class="search-results">${blocks}</div></body></html>`;
|
||||
}
|
||||
|
||||
/** A recipe page: a WP Recipe Maker `Recipe` inside a Yoast `@graph` array. */
|
||||
function detailHtml(): string {
|
||||
const payload = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{ "@type": "WebSite", name: "Oh mon Bento" },
|
||||
{ "@type": "BreadcrumbList" },
|
||||
{
|
||||
"@type": "Recipe",
|
||||
name: "Kamameshi",
|
||||
description: "Riz japonais mijoté en cocotte.",
|
||||
image: "https://ohmonbento.com/wp-content/uploads/kamameshi.jpg",
|
||||
recipeYield: ["2", "2 portions"],
|
||||
recipeIngredient: ["300 g de riz rond japonais (Japonica)", "1 c. à soupe de sauce soja"],
|
||||
recipeInstructions: [
|
||||
{ "@type": "HowToStep", name: "Rincer le riz", text: "Rincez le riz à l'eau claire." },
|
||||
{ "@type": "HowToStep", name: "Cuire", text: "Faites cuire à couvert 12 minutes." },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
||||
payload,
|
||||
)}</script></head><body></body></html>`;
|
||||
}
|
||||
|
||||
describe("ohMonBentoAdapter", () => {
|
||||
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(ohMonBentoAdapter.key).to.equal("ohMonBento");
|
||||
expect(ohMonBentoAdapter.name).to.equal("Oh mon Bento");
|
||||
expect(ohMonBentoAdapter.official).to.equal(false);
|
||||
expect(ohMonBentoAdapter.iconUrl).to.be.a("string");
|
||||
expect(ohMonBentoAdapter.locale).to.equal("fr");
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("scrapes each entry-thumb card into a RecipeSourceListItem and offers a next page", async () => {
|
||||
stubFetchHtml(
|
||||
searchHtml([
|
||||
{ slug: "kamameshi", title: "Kamameshi", image: "https://img.test/1.jpg" },
|
||||
{ slug: "onigiri", title: "Onigiri", image: "https://img.test/2.jpg" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await ohMonBentoAdapter.list({});
|
||||
|
||||
expect(result.items).to.deep.equal([
|
||||
{
|
||||
externalId: "https://ohmonbento.com/kamameshi/",
|
||||
title: "Kamameshi",
|
||||
picture: "https://img.test/1.jpg",
|
||||
url: "https://ohmonbento.com/kamameshi/",
|
||||
},
|
||||
{
|
||||
externalId: "https://ohmonbento.com/onigiri/",
|
||||
title: "Onigiri",
|
||||
picture: "https://img.test/2.jpg",
|
||||
url: "https://ohmonbento.com/onigiri/",
|
||||
},
|
||||
]);
|
||||
expect(result.nextCursor).to.equal("2");
|
||||
});
|
||||
|
||||
it("treats a 404 (past the last page) as the clean end of the list", async () => {
|
||||
stubFetchHtml("", 404);
|
||||
|
||||
const result = await ohMonBentoAdapter.list({ cursor: "99" });
|
||||
|
||||
expect(result.items).to.deep.equal([]);
|
||||
expect(result.nextCursor).to.be.null;
|
||||
});
|
||||
|
||||
it("uses the same `/?s=` search URL for an empty query, with no `paged` on page 1", async () => {
|
||||
const { calls } = stubFetchHtmlCapturing(
|
||||
searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
|
||||
);
|
||||
|
||||
await ohMonBentoAdapter.list({});
|
||||
|
||||
expect(calls).to.deep.equal(["https://ohmonbento.com/?s="]);
|
||||
});
|
||||
|
||||
it("passes the query and page through to `/?s={query}&paged={n}`", async () => {
|
||||
const { calls } = stubFetchHtmlCapturing(
|
||||
searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
|
||||
);
|
||||
|
||||
await ohMonBentoAdapter.list({ query: "bento box", cursor: "3" });
|
||||
|
||||
expect(calls).to.deep.equal(["https://ohmonbento.com/?s=bento%20box&paged=3"]);
|
||||
});
|
||||
|
||||
it("throws RecipeSourceFetchError keyed 'ohMonBento' on a non-404 non-2xx response", async () => {
|
||||
stubFetchHtml("", 500);
|
||||
|
||||
try {
|
||||
await ohMonBentoAdapter.list({});
|
||||
expect.fail("expected list to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDetail / parse", () => {
|
||||
it("parses a Yoast @graph-wrapped WPRM Recipe into a ParsedRecipe", async () => {
|
||||
stubFetchHtml(detailHtml());
|
||||
|
||||
const raw = await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/kamameshi/");
|
||||
const parsed = ohMonBentoAdapter.parse(raw);
|
||||
|
||||
expect(parsed.name).to.equal("Kamameshi");
|
||||
// recipeYield: ["2", "2 portions"] -> first element, regex \d+ -> 2.
|
||||
expect(parsed.portions).to.equal(2);
|
||||
expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([
|
||||
"300 g de riz rond japonais (Japonica)",
|
||||
"1 c. à soupe de sauce soja",
|
||||
]);
|
||||
expect(parsed.steps.map((s) => s.description)).to.deep.equal([
|
||||
"Rincez le riz à l'eau claire.",
|
||||
"Faites cuire à couvert 12 minutes.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-keys a delegated RecipeSourceFetchError to 'ohMonBento' when the detail page 404s", async () => {
|
||||
stubFetchHtml("", 404);
|
||||
|
||||
try {
|
||||
await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/gone/");
|
||||
expect.fail("expected fetchDetail to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-keys a delegated RecipeSourceParseError to 'ohMonBento' when a page has no Recipe JSON-LD", () => {
|
||||
try {
|
||||
ohMonBentoAdapter.parse({
|
||||
html: "<!doctype html><html><body>rien</body></html>",
|
||||
url: "https://ohmonbento.com/vide/",
|
||||
});
|
||||
expect.fail("expected parse to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(RecipeSourceParseError);
|
||||
expect((err as RecipeSourceParseError).sourceKey).to.equal("ohMonBento");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
import { expect } from "chai";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
||||
import { quitoqueAdapter } from "../../src/sources/quitoque.js";
|
||||
|
||||
/** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`750g.test.ts`. */
|
||||
function stubFetchHtml(html: string, status = 200) {
|
||||
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
|
||||
}
|
||||
|
||||
/** Like {@link stubFetchHtml} but records the URL each call was made with — for asserting `list()`'s pagination. */
|
||||
function stubFetchHtmlCapturing(html: string): { calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
calls.push(typeof input === "string" ? input : input.toString());
|
||||
return new Response(html, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
return { calls };
|
||||
}
|
||||
|
||||
/** A `/recettes` listing page carrying `count` cards, each a single `<a class="card__content_header">` wrapping an `<img alt data-src>`. */
|
||||
function listingHtml(cards: { slug: string; title: string; image: string }[]): string {
|
||||
const anchors = cards
|
||||
.map(
|
||||
(card) =>
|
||||
`<article><a class="card__content_header" href="/recettes/${card.slug}">` +
|
||||
`<img src="/placeholder.svg" alt="${card.title}" data-src="${card.image}" />` +
|
||||
`</a></article>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<!doctype html><html><body><main>${anchors}</main></body></html>`;
|
||||
}
|
||||
|
||||
/** A quitoque.fr recipe page: a plain schema.org `Recipe` as the sole element of a top-level JSON-LD array. */
|
||||
function detailHtml(): string {
|
||||
const payload = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
name: "Mijoté de lentilles au lait de coco",
|
||||
description: "Un plat végétarien réconfortant.",
|
||||
image: "https://www.quitoque.fr/img/mijote.jpg",
|
||||
recipeYield: "2",
|
||||
recipeIngredient: ["150 g lentilles vertes (Bio)", "1 gousse d'ail", "0.5 citron jaune"],
|
||||
recipeInstructions: [
|
||||
{ "@type": "HowToStep", name: "1. Les légumes", text: "Émincez l'ail." },
|
||||
{ "@type": "HowToStep", name: "2. La cuisson", text: "Faites mijoter 25 minutes." },
|
||||
],
|
||||
},
|
||||
];
|
||||
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
||||
payload,
|
||||
)}</script></head><body></body></html>`;
|
||||
}
|
||||
|
||||
describe("quitoqueAdapter", () => {
|
||||
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(quitoqueAdapter.key).to.equal("quitoque");
|
||||
expect(quitoqueAdapter.name).to.equal("Quitoque");
|
||||
expect(quitoqueAdapter.official).to.equal(false);
|
||||
expect(quitoqueAdapter.iconUrl).to.be.a("string");
|
||||
expect(quitoqueAdapter.locale).to.equal("fr");
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("scrapes each card into a RecipeSourceListItem (real image from data-src, absolute url) and offers a next page", async () => {
|
||||
stubFetchHtml(
|
||||
listingHtml([
|
||||
{
|
||||
slug: "mijote-lentilles",
|
||||
title: "Mijoté de lentilles",
|
||||
image: "https://img.test/1.jpg",
|
||||
},
|
||||
{ slug: "curry-legumes", title: "Curry de légumes", image: "https://img.test/2.jpg" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await quitoqueAdapter.list({});
|
||||
|
||||
expect(result.items).to.deep.equal([
|
||||
{
|
||||
externalId: "https://www.quitoque.fr/recettes/mijote-lentilles",
|
||||
title: "Mijoté de lentilles",
|
||||
picture: "https://img.test/1.jpg",
|
||||
url: "https://www.quitoque.fr/recettes/mijote-lentilles",
|
||||
},
|
||||
{
|
||||
externalId: "https://www.quitoque.fr/recettes/curry-legumes",
|
||||
title: "Curry de légumes",
|
||||
picture: "https://img.test/2.jpg",
|
||||
url: "https://www.quitoque.fr/recettes/curry-legumes",
|
||||
},
|
||||
]);
|
||||
expect(result.nextCursor).to.equal("2");
|
||||
});
|
||||
|
||||
it("treats a page with no cards as the end of the list", async () => {
|
||||
stubFetchHtml("<!doctype html><html><body><main></main></body></html>");
|
||||
|
||||
const result = await quitoqueAdapter.list({ cursor: "9" });
|
||||
|
||||
expect(result.items).to.deep.equal([]);
|
||||
expect(result.nextCursor).to.be.null;
|
||||
});
|
||||
|
||||
it("follows the cursor to `/recettes?page=N`", async () => {
|
||||
const { calls } = stubFetchHtmlCapturing(
|
||||
listingHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
|
||||
);
|
||||
|
||||
await quitoqueAdapter.list({ cursor: "3" });
|
||||
|
||||
expect(calls).to.deep.equal(["https://www.quitoque.fr/recettes?page=3"]);
|
||||
});
|
||||
|
||||
it("best-effort filters a page's cards by a query substring, but still advances if the raw page had cards", async () => {
|
||||
stubFetchHtml(
|
||||
listingHtml([
|
||||
{ slug: "poulet-curry", title: "Poulet au curry", image: "https://img.test/p.jpg" },
|
||||
{ slug: "boeuf-carottes", title: "Bœuf carottes", image: "https://img.test/b.jpg" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await quitoqueAdapter.list({ query: "poulet" });
|
||||
|
||||
expect(result.items.map((item) => item.title)).to.deep.equal(["Poulet au curry"]);
|
||||
// The page did have cards, so the next page might match too.
|
||||
expect(result.nextCursor).to.equal("2");
|
||||
});
|
||||
|
||||
it("throws RecipeSourceFetchError keyed 'quitoque' on a non-2xx listing response", async () => {
|
||||
stubFetchHtml("", 503);
|
||||
|
||||
try {
|
||||
await quitoqueAdapter.list({});
|
||||
expect.fail("expected list to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDetail / parse", () => {
|
||||
it("parses a quitoque.fr recipe page's top-level-array JSON-LD into a ParsedRecipe", async () => {
|
||||
stubFetchHtml(detailHtml());
|
||||
|
||||
const raw = await quitoqueAdapter.fetchDetail(
|
||||
"https://www.quitoque.fr/recettes/mijote-lentilles",
|
||||
);
|
||||
const parsed = quitoqueAdapter.parse(raw);
|
||||
|
||||
expect(parsed.name).to.equal("Mijoté de lentilles au lait de coco");
|
||||
expect(parsed.portions).to.equal(2);
|
||||
expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([
|
||||
"150 g lentilles vertes (Bio)",
|
||||
"1 gousse d'ail",
|
||||
"0.5 citron jaune",
|
||||
]);
|
||||
expect(parsed.steps.map((s) => s.description)).to.deep.equal([
|
||||
"Émincez l'ail.",
|
||||
"Faites mijoter 25 minutes.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-keys a delegated RecipeSourceFetchError to 'quitoque' when the detail page 404s", async () => {
|
||||
stubFetchHtml("", 404);
|
||||
|
||||
try {
|
||||
await quitoqueAdapter.fetchDetail("https://www.quitoque.fr/recettes/gone");
|
||||
expect.fail("expected fetchDetail to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-keys a delegated RecipeSourceParseError to 'quitoque' when a page has no Recipe JSON-LD", () => {
|
||||
try {
|
||||
quitoqueAdapter.parse({
|
||||
html: "<!doctype html><html><body>rien ici</body></html>",
|
||||
url: "https://www.quitoque.fr/recettes/vide",
|
||||
});
|
||||
expect.fail("expected parse to throw");
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(RecipeSourceParseError);
|
||||
expect((err as RecipeSourceParseError).sourceKey).to.equal("quitoque");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -16,28 +16,6 @@ function stubFetch(body: unknown, status = 200) {
|
|||
globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-aware variant of {@link stubFetch} for the empty-query category
|
||||
* walk, which fires several different requests (`/list.php?c=list`, then a
|
||||
* `/filter.php?c=…`) within a single `list()` call. Matches by URL
|
||||
* substring; an unstubbed path throws rather than silently returning junk.
|
||||
* Returns the list of requested URLs, so a test can assert *which*
|
||||
* endpoint was hit (e.g. category walk vs. `/search.php`).
|
||||
*/
|
||||
function stubFetchByUrl(routes: Record<string, unknown>, status = 200): string[] {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
calls.push(url);
|
||||
const route = Object.keys(routes).find((fragment) => url.includes(fragment));
|
||||
if (route === undefined) {
|
||||
throw new Error(`stubFetchByUrl: no route matches ${url}`);
|
||||
}
|
||||
return new Response(JSON.stringify(routes[route]), { status });
|
||||
}) as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
const baseMeal: TheMealDbMeal = {
|
||||
idMeal: "52795",
|
||||
strMeal: "Chicken Handi",
|
||||
|
|
@ -131,113 +109,6 @@ describe("theMealDbAdapter", () => {
|
|||
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
// Empty/omitted query = "browse everything". The free tier has no
|
||||
// catalog-wide endpoint, so the adapter walks categories one per
|
||||
// cursor step (issue #38) rather than settling for `/search.php?s=`'s
|
||||
// fixed ~25-meal sample.
|
||||
describe("empty query — walks categories", () => {
|
||||
const CATEGORIES = {
|
||||
meals: [{ strCategory: "Beef" }, { strCategory: "Chicken" }, { strCategory: "Dessert" }],
|
||||
};
|
||||
|
||||
it("returns the first category's meals, cursor pointing at the next category", async () => {
|
||||
stubFetchByUrl({
|
||||
"/list.php?c=list": CATEGORIES,
|
||||
"/filter.php?c=Beef": {
|
||||
meals: [
|
||||
{ idMeal: "1", strMeal: "Beef Pie", strMealThumb: "https://img.test/1.jpg" },
|
||||
{ idMeal: "2", strMeal: "Beef Stew", strMealThumb: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await theMealDbAdapter.list({});
|
||||
|
||||
expect(result.items).to.deep.equal([
|
||||
{
|
||||
externalId: "1",
|
||||
title: "Beef Pie",
|
||||
picture: "https://img.test/1.jpg",
|
||||
url: "https://www.themealdb.com/meal/1",
|
||||
},
|
||||
{
|
||||
externalId: "2",
|
||||
title: "Beef Stew",
|
||||
picture: null,
|
||||
url: "https://www.themealdb.com/meal/2",
|
||||
},
|
||||
]);
|
||||
expect(result.nextCursor).to.equal("1");
|
||||
});
|
||||
|
||||
it("follows the cursor into the next category", async () => {
|
||||
const calls = stubFetchByUrl({
|
||||
"/list.php?c=list": CATEGORIES,
|
||||
"/filter.php?c=Chicken": {
|
||||
meals: [{ idMeal: "3", strMeal: "Chicken Handi", strMealThumb: null }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await theMealDbAdapter.list({ cursor: "1" });
|
||||
|
||||
expect(calls.some((u) => u.includes("/filter.php?c=Chicken"))).to.equal(true);
|
||||
expect(result.items.map((i) => i.externalId)).to.deep.equal(["3"]);
|
||||
expect(result.nextCursor).to.equal("2");
|
||||
});
|
||||
|
||||
it("stops (null cursor) on the last category", async () => {
|
||||
stubFetchByUrl({
|
||||
"/list.php?c=list": CATEGORIES,
|
||||
"/filter.php?c=Dessert": {
|
||||
meals: [{ idMeal: "9", strMeal: "Flan", strMealThumb: null }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await theMealDbAdapter.list({ cursor: "2" });
|
||||
|
||||
expect(result.nextCursor).to.be.null;
|
||||
});
|
||||
|
||||
it("returns an empty final page once the cursor is past the last category", async () => {
|
||||
stubFetchByUrl({ "/list.php?c=list": CATEGORIES });
|
||||
|
||||
const result = await theMealDbAdapter.list({ cursor: "3" });
|
||||
|
||||
expect(result.items).to.deep.equal([]);
|
||||
expect(result.nextCursor).to.be.null;
|
||||
});
|
||||
|
||||
it("treats a blank query the same as no query — category walk, never /search.php", async () => {
|
||||
const calls = stubFetchByUrl({
|
||||
"/list.php?c=list": CATEGORIES,
|
||||
"/filter.php?c=Beef": {
|
||||
meals: [{ idMeal: "1", strMeal: "Beef Pie", strMealThumb: null }],
|
||||
},
|
||||
});
|
||||
|
||||
await theMealDbAdapter.list({ query: " " });
|
||||
|
||||
expect(calls.some((u) => u.includes("/search.php"))).to.equal(false);
|
||||
expect(calls.some((u) => u.includes("/list.php?c=list"))).to.equal(true);
|
||||
});
|
||||
|
||||
it("skips a titleless meal in a category page, like search does", async () => {
|
||||
stubFetchByUrl({
|
||||
"/list.php?c=list": CATEGORIES,
|
||||
"/filter.php?c=Beef": {
|
||||
meals: [
|
||||
{ idMeal: "1", strMeal: null, strMealThumb: null },
|
||||
{ idMeal: "2", strMeal: "Beef Stew", strMealThumb: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await theMealDbAdapter.list({});
|
||||
|
||||
expect(result.items.map((i) => i.externalId)).to.deep.equal(["2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDetail", () => {
|
||||
|
|
|
|||
|
|
@ -312,16 +312,20 @@ describe("Recipes", () => {
|
|||
});
|
||||
|
||||
describe("POST /recipes", () => {
|
||||
it("creates a recipe with its ingredients and ordered steps, and computes its regimes from the ingredients", async () => {
|
||||
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const oeuf = await ingredientId("egg");
|
||||
const piece = await unitId("piece");
|
||||
const vegetarien = await prisma.diet.findFirstOrThrow({
|
||||
where: { key: "vegetarian" },
|
||||
});
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Omelette provençale",
|
||||
description: "Rapide et savoureuse",
|
||||
portions: 2,
|
||||
dietIds: [vegetarien.id],
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||
{ ingredientId: oeuf, quantity: 3, unitId: piece },
|
||||
|
|
@ -339,84 +343,7 @@ describe("Recipes", () => {
|
|||
).to.deep.equal([0, 1]);
|
||||
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
|
||||
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
|
||||
// Regimes computed (issue #131), not tagged: tomato is vegetarian/
|
||||
// vegan/pescatarian, egg is vegetarian/pescatarian (not vegan) — the
|
||||
// intersection is vegetarian + pescatarian; neither carries gluten, so
|
||||
// glutenFree too.
|
||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
|
||||
"vegetarian",
|
||||
"pescatarian",
|
||||
"glutenFree",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a regime the moment one ingredient is incompatible, keeps glutenFree if nothing carries gluten (issue #131)", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const poulet = await ingredientId("chicken");
|
||||
const piece = await unitId("piece");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Poulet aux tomates",
|
||||
portions: 2,
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||
{ ingredientId: poulet, quantity: 1, unitId: piece },
|
||||
],
|
||||
steps: [{ description: "Cuire le poulet" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
// Chicken is compatible with none of vegetarian/vegan/pescatarian, so
|
||||
// the recipe qualifies for none of them — but it has no gluten.
|
||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["glutenFree"]);
|
||||
});
|
||||
|
||||
it("is not glutenFree when an ingredient carries the gluten allergen (issue #131)", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const farine = await ingredientId("wheatFlour");
|
||||
const piece = await unitId("piece");
|
||||
const gram = await unitId("gram");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Galette de tomates",
|
||||
portions: 2,
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||
{ ingredientId: farine, quantity: 100, unitId: gram },
|
||||
],
|
||||
steps: [{ description: "Mélanger" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
// Both ingredients are vegetarian/vegan/pescatarian, but wheat flour
|
||||
// carries `gluten` — no glutenFree.
|
||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
|
||||
"vegetarian",
|
||||
"vegan",
|
||||
"pescatarian",
|
||||
]);
|
||||
});
|
||||
|
||||
it("has no regimes at all when a recipe contains a placeholder ingredient (issue #131)", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Recette incomplète",
|
||||
portions: 2,
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||
{ placeholderName: "Épice mystère", quantity: 1, unitId: piece },
|
||||
],
|
||||
steps: [{ description: "Assembler" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
// An off-catalog ingredient can't certify anything.
|
||||
expect(res.body.diets).to.deep.equal([]);
|
||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
|
||||
});
|
||||
|
||||
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
|
||||
|
|
@ -428,6 +355,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
|
@ -439,6 +367,7 @@ describe("Recipes", () => {
|
|||
name: "Foyer",
|
||||
visibility: "HOUSE",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
|
@ -457,6 +386,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Ragoût",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description }],
|
||||
});
|
||||
|
|
@ -469,12 +399,7 @@ describe("Recipes", () => {
|
|||
// response itself carries exactly what to highlight, not just the id.
|
||||
const resStep = res.body.steps[0];
|
||||
expect(resStep.techSteps).to.have.length(1);
|
||||
expect(resStep.techSteps[0].techStep).to.deep.equal({
|
||||
id: simmer,
|
||||
key: "simmer",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
|
||||
const { start, end } = resStep.techSteps[0];
|
||||
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
|
||||
});
|
||||
|
|
@ -488,6 +413,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Gratin",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description }],
|
||||
});
|
||||
|
|
@ -522,6 +448,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Servir immédiatement" }],
|
||||
});
|
||||
|
|
@ -540,6 +467,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Ragoût",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [
|
||||
{ description: "Hacher les oignons" },
|
||||
|
|
@ -569,6 +497,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Gratin",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
|
||||
steps: [{ description: "Cuire au four pendant 30 minutes" }],
|
||||
|
|
@ -590,6 +519,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Poêlée",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
// The case that motivated the sequence model: one instruction, two techniques.
|
||||
steps: [{ description }],
|
||||
|
|
@ -620,6 +550,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
|
@ -635,6 +566,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
|
@ -643,12 +575,30 @@ describe("Recipes", () => {
|
|||
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [999_999],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
|
@ -671,6 +621,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 1, unitId: piece },
|
||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||
|
|
@ -688,6 +639,7 @@ describe("Recipes", () => {
|
|||
const piece = await unitId("piece");
|
||||
const basePayload = {
|
||||
name: "Test",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Étape" }],
|
||||
};
|
||||
|
|
@ -700,55 +652,6 @@ describe("Recipes", () => {
|
|||
expect(zero.status).to.equal(400);
|
||||
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("creates a « selon le goût » seasoning line with no quantity or unit (issue #128)", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const sel = await ingredientId("salt");
|
||||
const gram = await unitId("gram");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Tomates au sel",
|
||||
portions: 2,
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 200, unitId: gram },
|
||||
{ ingredientId: sel }, // selon le goût — no quantity/unit
|
||||
],
|
||||
steps: [{ description: "Saler les tomates" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
const seasoning = res.body.ingredients.find(
|
||||
(line: { ingredient: { key: string } }) => line.ingredient.key === "salt",
|
||||
);
|
||||
expect(seasoning.quantity).to.equal(null);
|
||||
expect(seasoning.unit).to.equal(null);
|
||||
});
|
||||
|
||||
it("rejects an ingredient line with a quantity but no unit (or vice versa) with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const gram = await unitId("gram");
|
||||
const base = {
|
||||
name: "Test",
|
||||
portions: 2,
|
||||
steps: [{ description: "Étape" }],
|
||||
};
|
||||
|
||||
const quantityOnly = await agent.post("/recipes").send({
|
||||
...base,
|
||||
ingredients: [{ ingredientId: tomate, quantity: 100 }],
|
||||
});
|
||||
const unitOnly = await agent.post("/recipes").send({
|
||||
...base,
|
||||
ingredients: [{ ingredientId: tomate, unitId: gram }],
|
||||
});
|
||||
|
||||
expect(quantityOnly.status).to.equal(400);
|
||||
expect(quantityOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
expect(unitOnly.status).to.equal(400);
|
||||
expect(unitOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /recipes/:id", () => {
|
||||
|
|
@ -768,6 +671,7 @@ describe("Recipes", () => {
|
|||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
|
@ -853,6 +757,7 @@ describe("Recipes", () => {
|
|||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
|
@ -861,6 +766,7 @@ describe("Recipes", () => {
|
|||
name: "Salade composée",
|
||||
portions: 6,
|
||||
visibility: "PUBLIC",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
|
||||
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
||||
});
|
||||
|
|
@ -875,36 +781,6 @@ describe("Recipes", () => {
|
|||
expect(res.body.steps).to.have.length(2);
|
||||
});
|
||||
|
||||
it("carries a « selon le goût » seasoning line through an edit (issue #128)", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const poivre = await ingredientId("blackPepper");
|
||||
const gram = await unitId("gram");
|
||||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Salade poivrée",
|
||||
portions: 4,
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 100, unitId: gram },
|
||||
{ ingredientId: poivre }, // selon le goût
|
||||
],
|
||||
steps: [{ description: "Couper" }, { description: "Poivrer" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
const seasoning = res.body.ingredients.find(
|
||||
(line: { ingredient: { key: string } }) => line.ingredient.key === "blackPepper",
|
||||
);
|
||||
expect(seasoning.quantity).to.equal(null);
|
||||
expect(seasoning.unit).to.equal(null);
|
||||
});
|
||||
|
||||
it("recomputes each replaced step's technique sequence", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
|
|
@ -913,6 +789,7 @@ describe("Recipes", () => {
|
|||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Servir immédiatement" }],
|
||||
});
|
||||
|
|
@ -920,6 +797,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Émincer les tomates" }],
|
||||
});
|
||||
|
|
@ -942,6 +820,7 @@ describe("Recipes", () => {
|
|||
const created = await agent.post("/recipes").send({
|
||||
name: "Ragoût",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [
|
||||
{ description: "Hacher les oignons" }, // chop
|
||||
|
|
@ -970,6 +849,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Ragoût",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: editedDescription }, { description: addedDescription }],
|
||||
});
|
||||
|
|
@ -1003,55 +883,6 @@ describe("Recipes", () => {
|
|||
expect(orphanedTechSteps).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("carries a replaced step's clause temperatures through the edit, not just its technique span", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
const created = await agent.post("/recipes").send({
|
||||
name: "Gratin",
|
||||
portions: 4,
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Servir immédiatement" }],
|
||||
});
|
||||
|
||||
// The pre-fix bug: `updateRecipe` persisted only each detected
|
||||
// technique's span on a resave and silently dropped every
|
||||
// temperature / utensil / ingredient the classifier attributed to the
|
||||
// clause (only the *create* path wrote them). A recipe edited even
|
||||
// once therefore had temperature-less `preheat`/`bake` steps, so
|
||||
// `cooking-optimizer.ts` could never flag an oven-temperature
|
||||
// conflict between two of them. Same assertion as the POST test
|
||||
// "extracts a step's temperature mentions...", but reached via PATCH.
|
||||
const editedDescription = "Enfourner à 180°C puis finir à feu doux";
|
||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Gratin",
|
||||
portions: 4,
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: editedDescription }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.steps[0].techSteps.length).to.be.greaterThan(0);
|
||||
const temperatures = res.body.steps[0].techSteps.flatMap(
|
||||
(techStep: { temperatures: unknown[] }) => techStep.temperatures,
|
||||
);
|
||||
const celsius = temperatures.find(
|
||||
(temperature: { celsius: number | null }) => temperature.celsius !== null,
|
||||
);
|
||||
expect(celsius).to.include({
|
||||
celsius: 180,
|
||||
gasMark: null,
|
||||
qualitative: null,
|
||||
source: "auto",
|
||||
});
|
||||
expect(editedDescription.slice(celsius.start, celsius.end)).to.equal("180°C");
|
||||
expect(
|
||||
temperatures.some(
|
||||
(temperature: { qualitative: string | null }) => temperature.qualitative === "low",
|
||||
),
|
||||
).to.equal(true);
|
||||
});
|
||||
|
||||
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
|
|
@ -1060,6 +891,7 @@ describe("Recipes", () => {
|
|||
const res = await agent.patch("/recipes/999999").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
|
@ -1068,6 +900,30 @@ describe("Recipes", () => {
|
|||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
dietIds: [999_999],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
||||
const { profileId } = await signup();
|
||||
const { agent: otherAgent } = await signup();
|
||||
|
|
@ -1080,6 +936,7 @@ describe("Recipes", () => {
|
|||
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
||||
name: "Hack",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
|
@ -1123,7 +980,6 @@ describe("Recipes", () => {
|
|||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -109,12 +109,7 @@ describe("Recipe tech-step corrections", () => {
|
|||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.correction.previousTechStep).to.equal(null);
|
||||
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
||||
id: simmerId,
|
||||
key: "simmer",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||
expect(res.body.correction.start).to.equal(6);
|
||||
expect(res.body.correction.end).to.equal(13);
|
||||
// The step's real technique sequence reflects the correction right
|
||||
|
|
@ -122,7 +117,7 @@ describe("Recipe tech-step corrections", () => {
|
|||
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
||||
expect(res.body.techSteps).to.deep.equal([
|
||||
{
|
||||
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
||||
techStep: { id: simmerId, key: "simmer" },
|
||||
start: 6,
|
||||
end: 13,
|
||||
source: "manual",
|
||||
|
|
@ -133,56 +128,6 @@ describe("Recipe tech-step corrections", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("proposes a placeholder technique via correctedTechStepName when the catalog is missing one (issue #130)", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
|
||||
const res = await agent
|
||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||
.send({ start: 6, end: 13, correctedTechStepName: "Flamber au chalumeau" });
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
// A placeholder `TechStep` was minted and the correction points at it.
|
||||
expect(res.body.correction.correctedTechStep.isPlaceholder).to.equal(true);
|
||||
expect(res.body.correction.correctedTechStep.displayName).to.equal("Flamber au chalumeau");
|
||||
// ...and the step's live sequence carries it straight away.
|
||||
expect(res.body.techSteps).to.have.length(1);
|
||||
expect(res.body.techSteps[0].techStep.isPlaceholder).to.equal(true);
|
||||
expect(res.body.techSteps[0].techStep.displayName).to.equal("Flamber au chalumeau");
|
||||
expect(res.body.techSteps[0].source).to.equal("manual");
|
||||
|
||||
const row = await prisma.techStep.findUniqueOrThrow({
|
||||
where: { id: res.body.techSteps[0].techStep.id },
|
||||
});
|
||||
expect(row.isPlaceholder).to.equal(true);
|
||||
expect(row.key).to.match(/^placeholder:/);
|
||||
expect(row.createdById).to.equal(profileId);
|
||||
expect(row.createdAt).to.be.an.instanceOf(Date);
|
||||
expect(row.reviewedAt).to.equal(null);
|
||||
// It never leaks into the browsable catalog.
|
||||
const reference = await agent.get("/reference/tech-steps");
|
||||
expect(
|
||||
reference.body.some(
|
||||
(t: { displayName?: string }) => t.displayName === "Flamber au chalumeau",
|
||||
),
|
||||
).to.equal(false);
|
||||
});
|
||||
|
||||
it("rejects sending both correctedTechStepId and correctedTechStepName with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
correctedTechStepId: await techStepId("simmer"),
|
||||
correctedTechStepName: "Autre chose",
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
|
|
@ -200,23 +145,13 @@ describe("Recipe tech-step corrections", () => {
|
|||
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.correction.previousTechStep).to.deep.equal({
|
||||
id: simmerId,
|
||||
key: "simmer",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
||||
id: boilId,
|
||||
key: "boil",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
||||
// Still exactly one entry — the relabel updated the existing row
|
||||
// rather than adding a second one alongside it.
|
||||
expect(res.body.techSteps).to.deep.equal([
|
||||
{
|
||||
techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null },
|
||||
techStep: { id: boilId, key: "boil" },
|
||||
start: 6,
|
||||
end: 13,
|
||||
source: "manual",
|
||||
|
|
@ -364,7 +299,7 @@ describe("Recipe tech-step corrections", () => {
|
|||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps).to.deep.equal([
|
||||
{
|
||||
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
||||
techStep: { id: simmerId, key: "simmer" },
|
||||
start: 6,
|
||||
end: 13,
|
||||
source: "manual",
|
||||
|
|
@ -583,191 +518,6 @@ describe("Recipe tech-step corrections", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("POST /recipes/:id/steps/:stepId/corrections — temperature metadata", () => {
|
||||
it("attaches a manually-selected celsius temperature, deriving raw from the step's own description", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const description = "Préchauffez à 180°C.";
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
||||
const preheatId = await techStepId("preheat");
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 0,
|
||||
end: 11,
|
||||
correctedTechStepId: preheatId,
|
||||
temperatures: [{ celsius: 180, start: 14, end: 19 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps).to.deep.equal([
|
||||
{
|
||||
techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null },
|
||||
start: 0,
|
||||
end: 11,
|
||||
source: "manual",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
temperatures: [
|
||||
{
|
||||
celsius: 180,
|
||||
gasMark: null,
|
||||
qualitative: null,
|
||||
// Not sent by the client — derived server-side from the span
|
||||
// against the step's real description (see
|
||||
// `ManualTemperatureMention`'s doc comment).
|
||||
raw: "180°C",
|
||||
start: 14,
|
||||
end: 19,
|
||||
source: "manual",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches a manually-selected qualitative temperature", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
const simmerId = await techStepId("simmer");
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
correctedTechStepId: simmerId,
|
||||
temperatures: [{ qualitative: "low", start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps[0].temperatures).to.deep.equal([
|
||||
{
|
||||
celsius: null,
|
||||
gasMark: null,
|
||||
qualitative: "low",
|
||||
raw: "Faire",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "manual",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches a manually-selected gas-mark temperature", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
const simmerId = await techStepId("simmer");
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
correctedTechStepId: simmerId,
|
||||
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps[0].temperatures[0]).to.deep.equal({
|
||||
celsius: null,
|
||||
gasMark: 6,
|
||||
qualitative: null,
|
||||
raw: "Faire",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a temperature with more than one of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
correctedTechStepId: await techStepId("simmer"),
|
||||
temperatures: [{ celsius: 180, qualitative: "high", start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a temperature with none of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
correctedTechStepId: await techStepId("simmer"),
|
||||
temperatures: [{ start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("replaces both auto-detected and previously-manual temperatures on the same occurrence — never accumulates", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
const simmerId = await techStepId("simmer");
|
||||
const boilId = await techStepId("boil");
|
||||
|
||||
await agent
|
||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
||||
// Simulate an auto-detected temperature already sitting on the
|
||||
// occurrence, exactly as `toStepTechStepCreateInputs` would have
|
||||
// written one at save time.
|
||||
await prisma.stepTechStepTemperature.create({
|
||||
data: {
|
||||
stepId,
|
||||
techStepOrder: 0,
|
||||
celsius: 100,
|
||||
raw: "100°C",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
previousTechStepId: simmerId,
|
||||
correctedTechStepId: boilId,
|
||||
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps).to.have.length(1);
|
||||
expect(res.body.techSteps[0].temperatures).to.deep.equal([
|
||||
{
|
||||
celsius: null,
|
||||
gasMark: 6,
|
||||
qualitative: null,
|
||||
raw: "Faire",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "manual",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a temperature span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const description = "Court.";
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 0,
|
||||
end: description.length,
|
||||
correctedTechStepId: await techStepId("simmer"),
|
||||
temperatures: [{ celsius: 180, start: 0, end: description.length + 10 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
|
||||
it("returns every correction submitted for the step, most recent first", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
|
|
@ -786,18 +536,8 @@ describe("Recipe tech-step corrections", () => {
|
|||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(2);
|
||||
expect(res.body[0].correctedTechStep).to.deep.equal({
|
||||
id: boilId,
|
||||
key: "boil",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
expect(res.body[1].correctedTechStep).to.deep.equal({
|
||||
id: simmerId,
|
||||
key: "simmer",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
||||
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing has been submitted yet", async () => {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@ 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 {
|
||||
seedReferenceData,
|
||||
TECH_STEPS,
|
||||
TOUR_SCENARIOS,
|
||||
UTENSILS,
|
||||
} from "../src/db/reference-seed-data.js";
|
||||
import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
|
|
@ -148,30 +143,7 @@ describe("Reference data", () => {
|
|||
// number — this catalog has grown since (26 -> 74) and will again.
|
||||
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||
expect(res.body[0]).to.have.keys(["id", "key", "isPlaceholder", "displayName"]);
|
||||
expect(res.body.every((t: { isPlaceholder: boolean }) => t.isPlaceholder === false)).to.equal(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes placeholder techniques users proposed while correcting a step (issue #130)", async () => {
|
||||
await prisma.techStep.create({
|
||||
data: {
|
||||
key: "placeholder:test-1",
|
||||
isPlaceholder: true,
|
||||
displayName: "Flamber au chalumeau",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app).get("/reference/tech-steps");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||
expect(
|
||||
res.body.some(
|
||||
(t: { displayName: string | null }) => t.displayName === "Flamber au chalumeau",
|
||||
),
|
||||
).to.equal(false);
|
||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||
});
|
||||
|
||||
it("orders techniques alphabetically by key", async () => {
|
||||
|
|
@ -262,40 +234,4 @@ describe("Reference data", () => {
|
|||
expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /reference/tours", () => {
|
||||
it("returns every seeded scenario with its steps in order", async () => {
|
||||
const res = await request(app).get("/reference/tours");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.map((t: { key: string }) => t.key)).to.have.members(
|
||||
TOUR_SCENARIOS.map((s) => s.key),
|
||||
);
|
||||
|
||||
const general = res.body.find((t: { key: string }) => t.key === "general");
|
||||
expect(general.name).to.be.a("string").and.not.empty;
|
||||
expect(general.steps).to.have.length(TOUR_SCENARIOS[0].steps.length);
|
||||
expect(general.steps[0]).to.have.keys(["order", "route", "targetKey", "title", "content"]);
|
||||
expect(general.steps.map((s: { order: number }) => s.order)).to.deep.equal(
|
||||
general.steps.map((_: unknown, i: number) => i),
|
||||
);
|
||||
// First step of the general tour navigates to "/" and points at the
|
||||
// planning nav link (see TOUR_SCENARIOS).
|
||||
expect(general.steps[0].route).to.equal("/");
|
||||
expect(general.steps[0].targetKey).to.equal("nav-planning");
|
||||
});
|
||||
|
||||
it("excludes a disabled scenario", async () => {
|
||||
await prisma.tourScenario.update({
|
||||
where: { key: "techStepMetadata" },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
const res = await request(app).get("/reference/tours");
|
||||
|
||||
const keys = res.body.map((t: { key: string }) => t.key);
|
||||
expect(keys).to.include("general");
|
||||
expect(keys).to.not.include("techStepMetadata");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ describe("Shopping list", () => {
|
|||
meal: "dejeuner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
{
|
||||
planningId: planning.id,
|
||||
|
|
@ -158,7 +157,6 @@ describe("Shopping list", () => {
|
|||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -172,118 +170,6 @@ describe("Shopping list", () => {
|
|||
expect(res.body.items[0].quantity).to.equal(400);
|
||||
});
|
||||
|
||||
it("ignores a leftovers item — nothing new to buy for a slot that just reserves an already-cooked batch (issue #34)", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const authorId: number = houseRes.body.adminId;
|
||||
|
||||
const tomatoId = await ingredientId("tomato");
|
||||
const gramId = await unitId("gram");
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
name: "Salade de tomates",
|
||||
authorId,
|
||||
portions: 4,
|
||||
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
||||
},
|
||||
});
|
||||
|
||||
// One real slot at 12 prepared (10 requested), then a leftovers slot
|
||||
// reusing 2 of that batch — the shopping list must still be 300g
|
||||
// (100 × 12/4), not 350g.
|
||||
const source = await agent.post("/planning/items").send({
|
||||
date: today(),
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portionsRequested: 10,
|
||||
});
|
||||
await agent.post("/planning/items/leftover").send({
|
||||
date: today(),
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
sourceItemId: source.body.id,
|
||||
portions: 2,
|
||||
});
|
||||
|
||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.items).to.have.length(1);
|
||||
expect(res.body.items[0].quantity).to.equal(300);
|
||||
});
|
||||
|
||||
it("lists a « selon le goût » seasoning ingredient once, with no quantity, never summed (issue #128)", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const houseId: number = houseRes.body.id;
|
||||
const authorId: number = houseRes.body.adminId;
|
||||
|
||||
const tomatoId = await ingredientId("tomato");
|
||||
const saltId = await ingredientId("salt");
|
||||
const gramId = await unitId("gram");
|
||||
|
||||
// 100g tomato + salt "selon le goût" (quantity/unit null), planned
|
||||
// twice at 4 portions each (recipe yields 2): tomato scales to 400g,
|
||||
// salt stays a single unquantified line.
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
name: "Tomates au sel",
|
||||
authorId,
|
||||
portions: 2,
|
||||
ingredients: {
|
||||
create: [
|
||||
{ ingredientId: tomatoId, quantity: 100, unitId: gramId },
|
||||
{ ingredientId: saltId, quantity: null, unitId: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const planning = await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||
},
|
||||
});
|
||||
await prisma.planningItem.createMany({
|
||||
data: [
|
||||
{
|
||||
planningId: planning.id,
|
||||
weekDay: "lundi",
|
||||
meal: "dejeuner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
{
|
||||
planningId: planning.id,
|
||||
weekDay: "mercredi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
const salt = res.body.items.filter(
|
||||
(item: { ingredient: { key: string } }) => item.ingredient.key === "salt",
|
||||
);
|
||||
expect(salt).to.have.length(1);
|
||||
expect(salt[0].quantity).to.equal(null);
|
||||
expect(salt[0].unit).to.equal(null);
|
||||
const tomato = res.body.items.find(
|
||||
(item: { ingredient: { key: string } }) => item.ingredient.key === "tomato",
|
||||
);
|
||||
expect(tomato.quantity).to.equal(400);
|
||||
});
|
||||
|
||||
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
|
@ -325,7 +211,6 @@ describe("Shopping list", () => {
|
|||
meal: "dejeuner",
|
||||
recipeId: recipeA.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
{
|
||||
planningId: planning.id,
|
||||
|
|
@ -333,7 +218,6 @@ describe("Shopping list", () => {
|
|||
meal: "diner",
|
||||
recipeId: recipeB.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -388,7 +272,6 @@ describe("Shopping list", () => {
|
|||
meal: "dejeuner",
|
||||
recipeId: recipeA.id,
|
||||
portions: 2,
|
||||
portionsRequested: 2,
|
||||
},
|
||||
{
|
||||
planningId: planning.id,
|
||||
|
|
@ -396,7 +279,6 @@ describe("Shopping list", () => {
|
|||
meal: "diner",
|
||||
recipeId: recipeB.id,
|
||||
portions: 2,
|
||||
portionsRequested: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -441,7 +323,6 @@ describe("Shopping list", () => {
|
|||
meal: "dejeuner",
|
||||
recipeId: recipe.id,
|
||||
portions: 2,
|
||||
portionsRequested: 2,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -52,24 +52,6 @@ describe("registerAllRecipeSources", () => {
|
|||
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger");
|
||||
});
|
||||
|
||||
it("registers Quitoque into the shared registry", () => {
|
||||
registerAllRecipeSources();
|
||||
|
||||
const quitoque = getRecipeSource("quitoque");
|
||||
expect(quitoque).to.not.be.undefined;
|
||||
expect(quitoque?.name).to.equal("Quitoque");
|
||||
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("quitoque");
|
||||
});
|
||||
|
||||
it("registers Oh mon Bento into the shared registry", () => {
|
||||
registerAllRecipeSources();
|
||||
|
||||
const ohMonBento = getRecipeSource("ohMonBento");
|
||||
expect(ohMonBento).to.not.be.undefined;
|
||||
expect(ohMonBento?.name).to.equal("Oh mon Bento");
|
||||
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("ohMonBento");
|
||||
});
|
||||
|
||||
it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
|
||||
registerAllRecipeSources();
|
||||
|
||||
|
|
|
|||
|
|
@ -339,12 +339,7 @@ describe("Sources", () => {
|
|||
const [step] = res.body.steps;
|
||||
expect(step.description).to.equal("Chop the onions finely");
|
||||
expect(step.techSteps).to.have.length(1);
|
||||
expect(step.techSteps[0].techStep).to.deep.equal({
|
||||
id: chop.id,
|
||||
key: "chop",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
|
||||
expect(
|
||||
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
|
||||
).to.equal("chop");
|
||||
|
|
@ -387,8 +382,6 @@ describe("Sources", () => {
|
|||
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
|
||||
id: simmer.id,
|
||||
key: "simmer",
|
||||
isPlaceholder: false,
|
||||
displayName: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -433,6 +426,7 @@ describe("Sources", () => {
|
|||
return {
|
||||
name: "Fake recipe 1 (revue)",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
# Template for `apps/web/.env.capacitor` — copy this file to that name and
|
||||
# set the real value. Used only by `pnpm --filter web build:capacitor`
|
||||
# (i.e. `vite build --mode capacitor`), the build that produces the bundle
|
||||
# packaged into the Android app.
|
||||
#
|
||||
# Unlike the normal web build (served same-origin as the API, so a relative
|
||||
# URL works), a packaged Capacitor build runs from the WebView's own local
|
||||
# origin and must talk to the API over an absolute, public HTTPS URL baked
|
||||
# in at build time.
|
||||
VITE_API_URL=https://batch-cooking.example.com
|
||||
103
apps/web/android/.gitignore
vendored
103
apps/web/android/.gitignore
vendored
|
|
@ -1,103 +0,0 @@
|
|||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files — jamais commités : le keystore de release est un secret
|
||||
# généré une fois à la main (voir la doc du README, section "Application
|
||||
# mobile"), décodé depuis le secret CI ANDROID_KEYSTORE_BASE64 à chaque build
|
||||
# de release, jamais versionné ici (issue #64).
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
apps/web/android/app/.gitignore
vendored
2
apps/web/android/app/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/build/*
|
||||
!/build/.npmkeep
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "fr.kyuno.batchcooking"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "fr.kyuno.batchcooking"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
// Surchargeables depuis la CI via `-PandroidVersionCode=... -PandroidVersionName=...`
|
||||
// (release.yml, dérivés du tag `vX.Y.Z` poussé) — la valeur par
|
||||
// défaut ci-dessous ne sert qu'aux builds locaux/CI qui ne passent
|
||||
// pas ces propriétés (Android Studio, le job build-android de
|
||||
// ci.yml sur un simple push main), jamais à une vraie release.
|
||||
versionCode (project.hasProperty("androidVersionCode") ? (project.androidVersionCode as Integer) : 1)
|
||||
versionName (project.hasProperty("androidVersionName") ? project.androidVersionName : "1.0")
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
// Lu depuis l'environnement, jamais codé en dur — même règle que les
|
||||
// secrets de apps/api (JWT_SECRET, etc.) : aucune valeur par défaut
|
||||
// devinable. Le keystore lui-même (ANDROID_KEYSTORE_PATH) est un fichier
|
||||
// décodé à la volée depuis le secret CI ANDROID_KEYSTORE_BASE64, jamais
|
||||
// commité (voir apps/web/android/.gitignore) — généré une fois à la main,
|
||||
// voir la doc du README "Application mobile". N'affecte que
|
||||
// `assembleRelease` : `assembleDebug` continue d'utiliser le keystore de
|
||||
// debug généré automatiquement par Android, sans configuration.
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile System.getenv("ANDROID_KEYSTORE_PATH") ? file(System.getenv("ANDROID_KEYSTORE_PATH")) : null
|
||||
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
// `signingConfig` reste posé même quand les variables d'environnement
|
||||
// ci-dessus sont absentes (dev local) : Gradle accepte une
|
||||
// signingConfig aux champs `null`, la build échoue seulement au
|
||||
// moment de la signature effective (assembleRelease), jamais à la
|
||||
// configuration — un `assembleDebug` local n'en a jamais besoin.
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-preferences')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue