Compare commits

..

No commits in common. "main" and "feat/quitoque-recipe-source" have entirely different histories.

290 changed files with 1377 additions and 16060 deletions

View file

@ -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. # (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000 # 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) -------- # --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
# All optional: an instance that doesn't run the admin app needs none of # 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 # 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_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops # 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 # 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 # plain HTTP (no TLS in front of it). Left unset, the session cookie

View file

@ -39,13 +39,6 @@ env:
# services/tech-step-intent-service/Dockerfile from this checkout. # services/tech-step-intent-service/Dockerfile from this checkout.
INTENT_SERVICE_BASE_URL: "http://localhost:8000" INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+" INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
# in those tests, so no real request ever leaves CI; these values only need
# to parse (a valid URL and an `owner/repo`).
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
FORGEJO_REPO: "ci/batchcooking"
jobs: jobs:
# Five independent jobs, no needs: between them — each starts in parallel # Five independent jobs, no needs: between them — each starts in parallel
@ -68,70 +61,17 @@ jobs:
test: test:
runs-on: ubuntu-latest 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: steps:
- name: Install curl and the docker CLI - name: Install curl and the docker CLI
run: | run: |
apt-get update && apt-get install -y curl docker.io 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:` # Postgres as a plain background container instead of a `services:`
# block — see this workflow's top-level `env.DATABASE_URL` comment for # block — see this workflow's top-level `env.DATABASE_URL` comment for
# why. Reachable as `postgres` from this job (the runner mounts the # why. Reachable as `postgres` from this job (the runner mounts the
# docker socket and runs jobs on a shared user-defined network, so a # docker socket and runs jobs on a shared user-defined network, so a
# `--name postgres` sibling resolves by name); the network is # `--name postgres` sibling resolves by name); the network is
# discovered from the job container itself rather than hardcoded. The # discovered from the job container itself rather than hardcoded.
# `docker rm -f postgres` below also clears a container left behind by
# the previous run (this runner has no reliable post-job cleanup), and
# the serialization step above means no other run is using it now.
- name: Start Postgres in the background - name: Start Postgres in the background
run: | run: |
net="$(docker inspect "$(hostname)" \ net="$(docker inspect "$(hostname)" \
@ -139,15 +79,6 @@ jobs:
| awk '{print $1}')" | awk '{print $1}')"
echo "job network: ${net:?could not determine the job container network}" echo "job network: ${net:?could not determine the job container network}"
docker rm -f postgres >/dev/null 2>&1 || true 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" \ docker run -d --name postgres --network "$net" \
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \ -e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
postgres:16-alpine postgres:16-alpine
@ -272,90 +203,3 @@ jobs:
# `component.devServer`), unlike `e2e` above which needs the real app # `component.devServer`), unlike `e2e` above which needs the real app
# running first. # running first.
- run: pnpm --filter web cy:run:component - 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

View file

@ -1,243 +1,44 @@
name: Release name: Release
# On a `vX.Y.Z` tag: build the three service images and push them to this # No image registry involved: Portainer is wired to this repo's Git remote
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/ # and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag # that the image actually builds at the tagged commit, publishes a GitHub
# and `latest`), build+attach a signed Android APK, publish a Forgejo # Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can # have to wait for its own polling interval.
# pull those prebuilt images (docker-compose.yml references them via
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
# keeps a `build:` fallback next to every `image:`.
on: on:
push: push:
tags: ["v*.*.*"] tags: ["v*.*.*"]
permissions: permissions:
contents: write 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: jobs:
# Build the three service images at the tagged commit and push them to the sanity-build:
# Forgejo container registry. A successful push also *is* the "does it
# still build" check the old `sanity-build` job did (which only covered
# apps/api). One sequential job, not a per-image matrix: three concurrent
# `docker build`s hammer this single persistent runner's Docker daemon
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
# for no real gain on a release, which is rare and usually runs alone.
build-and-push-images:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# This runner's job containers don't come with docker preinstalled — - uses: actions/checkout@v4
# same reason ci.yml's `test` job has to do this before its own
# `docker run` calls.
- name: Install docker
run: apt-get update && apt-get install -y docker.io
- uses: https://github.com/actions/checkout@v4 # 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 github-release:
env:
# The per-run token Forgejo Actions mints — has `packages: write`
# via this workflow's `permissions:` block. `docker login` accepts
# it as the password for the actor who pushed the tag.
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
# registry namespace is the owner, lower-cased (Forgejo package
# names are case-insensitive but the CLI wants a stable form).
registry="${GITHUB_SERVER_URL#http*://}"
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
tag="$GITHUB_REF_NAME"
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
# Every Dockerfile's build context is the repo root (see
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
build_push() {
image="$registry/$owner/$1"
echo "::group::$1"
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
docker push "$image:$tag"
docker push "$image:latest"
echo "::endgroup::"
}
build_push batchcooking-app apps/api/Dockerfile
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
# Builds the same signed APK as ci.yml's build-android job (issue #64),
# with one difference: the version baked into the APK is derived from the
# tag itself rather than left at build.gradle's default. Duplicated here
# rather than factored into a shared `workflow_call` reusable workflow —
# deliberate: reusable-workflow support on this self-hosted Forgejo/act
# runner is unverified, and this ~15-line recipe is proven working as-is
# in ci.yml; not worth risking a new class of runner-compatibility surprise
# (this repo has already hit several — see ci-forgejo-runner-constraints
# in project memory) to save a little duplication. Keep both copies in
# sync if the Android build steps ever change.
build-android-apk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: sanity-build
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: softprops/action-gh-release@v2
- uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 # Auto-generated from merged PRs since the previous tag — no
cache: pnpm # changelog tooling to maintain, consistent with there being no
# versioning tooling elsewhere in the repo yet.
- uses: https://github.com/actions/setup-java@v4 generate_release_notes: true
with:
distribution: temurin
java-version: "21"
- uses: https://github.com/android-actions/setup-android@v3
- run: pnpm install --frozen-lockfile
- name: Build le bundle web pour Capacitor
env:
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
run: pnpm --filter web build:capacitor
- run: pnpm --filter web exec cap sync android
- name: Décode le keystore de release
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
- name: Dérive versionName/versionCode depuis le tag
# GITHUB_REF_NAME = "v1.4.2" pour un tag `v1.4.2` — versionName =
# "1.4.2" (sans le `v`), versionCode = majeur*10000 + mineur*100 +
# correctif (ex. 1.4.2 -> 10402), un entier strictement croissant à
# chaque release comme Android l'exige (jamais réutilisé, jamais
# décroissant) tant que chaque partie reste sous 100 (largement
# suffisant ici). Exposé aux steps suivants via $GITHUB_ENV plutôt
# que recalculé.
run: |
tag="${GITHUB_REF_NAME#v}"
major="$(echo "$tag" | cut -d. -f1)"
minor="$(echo "$tag" | cut -d. -f2)"
patch="$(echo "$tag" | cut -d. -f3)"
# Préfixe 10# : force une lecture en base 10, sinon bash lirait
# un composant à zéro non significatif (ex. "08") comme de
# l'octal invalide et échouerait.
code=$((10#$major * 10000 + 10#$minor * 100 + 10#$patch))
echo "Version dérivée du tag $GITHUB_REF_NAME : versionName=$tag versionCode=$code"
echo "ANDROID_VERSION_NAME=$tag" >> "$GITHUB_ENV"
echo "ANDROID_VERSION_CODE=$code" >> "$GITHUB_ENV"
- name: Build l'APK signé
working-directory: apps/web/android
env:
ANDROID_KEYSTORE_PATH: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew assembleRelease -PandroidVersionName="$ANDROID_VERSION_NAME" -PandroidVersionCode="$ANDROID_VERSION_CODE"
# Nom de fichier stable, jamais versionné : la page d'accueil (#65)
# pointe vers /releases/latest (Forgejo n'a pas l'équivalent du
# raccourci GitHub /releases/latest/download/<fichier>, seul
# /releases/latest lui-même résout) — un nom stable évite au moins que
# l'utilisateur voie un nom de fichier différent à chaque release une
# fois sur la page.
- name: Renomme l'APK sous un nom stable
run: cp apps/web/android/app/build/outputs/apk/release/app-release.apk batchcooking.apk
# v4+ exige l'API "Results" de GitHub, non supportée par ce Forgejo
# self-hosted ("GHESNotSupportedError") — v3 (ancienne API Artifacts)
# est la seule utilisable ici, voir ci.yml's build-android pour la
# même contrainte déjà rencontrée.
- uses: https://github.com/actions/upload-artifact@v3
with:
name: batchcooking.apk
path: batchcooking.apk
forgejo-release:
runs-on: ubuntu-latest
needs: [build-and-push-images, build-android-apk]
steps:
# Ce runner ne fournit ni docker ni curl par défaut dans ses
# conteneurs de job — même constat déjà fait pour docker (voir
# build-and-push-images) et pour curl dans ci.yml's test job.
- name: Install curl
run: apt-get update && apt-get install -y curl
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/download-artifact@v3
with:
name: batchcooking.apk
# softprops/action-gh-release (l'action GitHub habituelle pour ça)
# échoue systématiquement contre ce Forgejo : sa requête de création
# de release répond 405 (vérifié en conditions réelles, tag v0.2.0 —
# pas un souci de ref bare comme les autres actions de ce fichier,
# une vraie incompatibilité d'API entre les deux plateformes qui n'a
# pas de contournement connu côté configuration de l'action). Appelle
# directement l'API native de Forgejo plutôt qu'une action tierce
# dont la compatibilité GitHub-Forgejo n'est pas garantie.
#
# Pas d'équivalent à `generate_release_notes` ici (fonctionnalité
# propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la
# release reste minimal, cohérent avec l'absence d'outillage de
# changelog ailleurs dans le repo.
- name: Crée (ou récupère) la Release Forgejo
id: create-release
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"
body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")"
# POST pour créer la release, code HTTP capturé à part. Si le tag est
# déjà connu de Forgejo (tag poussé à la main avant ce workflow, ou
# ré-exécution du job), l'API répond 409 : on réutilise alors la
# release existante au lieu d'échouer — idempotence, cf. l'incident
# du tag v0.2.2 poussé en avance qui a fait tomber ce job sur un 409.
code="$(curl -sS -o /tmp/release.json -w '%{http_code}' -X POST "$api" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d "$body")"
if [ "$code" = "409" ]; then
echo "Release $GITHUB_REF_NAME déjà existante (HTTP 409) — récupération de son id."
curl -fsS "$api/tags/$GITHUB_REF_NAME" \
-H "Authorization: token $FORGEJO_TOKEN" -o /tmp/release.json
elif [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
echo "Échec de création de la Release (HTTP $code) : $(cat /tmp/release.json)" >&2
exit 1
fi
id="$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)"
if [ -z "$id" ]; then
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
exit 1
fi
echo "Release $GITHUB_REF_NAME prête (id=$id)"
echo "release-id=$id" >> "$GITHUB_OUTPUT"
- name: Attache l'APK à la Release
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/${{ steps.create-release.outputs.release-id }}/assets"
curl -fsS -X POST "$api?name=batchcooking.apk" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@batchcooking.apk"
notify-portainer: notify-portainer:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: forgejo-release needs: github-release
steps: steps:
- name: Trigger Portainer redeploy - name: Trigger Portainer redeploy
# Best-effort: does nothing (and doesn't fail the workflow) until # Best-effort: does nothing (and doesn't fail the workflow) until

6
.gitignore vendored
View file

@ -70,7 +70,6 @@ web_modules/
.env.* .env.*
!.env.example !.env.example
!.env.test.example !.env.test.example
!.env.capacitor.example
# Python virtualenvs/caches for services/tech-step-intent-service (this repo # Python virtualenvs/caches for services/tech-step-intent-service (this repo
# is otherwise all-Node — see that service's own .gitignore for the rest; # 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 # Stores VSCode versions used for testing VSCode extensions
.vscode-test .vscode-test
# JetBrains IDEs (Android Studio opens this repo as a project once the
# Capacitor Android app exists under apps/web/android — see that folder's own
# .gitignore for the generated native project's build artefacts).
/.idea/
# pnpm # pnpm
.pnpm-store .pnpm-store

View file

@ -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 (`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle. au host — voir leurs propres README pour leur rôle.
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les **Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
trois images de service — `batchcooking-app`, `batchcooking-intent-service`, production est reliée directement au dépôt Git et reconstruit elle-même
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo** `docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`) donc aucune image nulle part.
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
images pré-construites, `docker compose up`/`build` retombe sur un build local si
elles sont absentes. L'instance **Portainer** de production peut donc désormais
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
deux modes fonctionnent avec le même `docker-compose.yml`.
### Release (`.github/workflows/release.yml`) ### Release (`.github/workflows/release.yml`)
@ -229,80 +221,14 @@ git tag vX.Y.Z
git push --tags git push --tags
``` ```
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
réussi vaut aussi vérification que tout build encore à ce commit), redéployer dessus), `github-release` (crée une Release GitHub avec changelog
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release` auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si est configuré (sinon Portainer se resynchronise simplement à son prochain
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
dépôt `PORTAINER_WEBHOOK_URL`.
## Application mobile (Android)
`apps/web` est empaqueté dans une coquille [Capacitor](https://capacitorjs.com/)
pour Android — le projet natif est versionné dans `apps/web/android/` (issue
\#39). L'authentification passe par un jeton Bearer stocké dans le stockage
sécurisé de Capacitor (le cookie de session `SameSite=Lax` ne part pas depuis
l'origine interne d'une WebView), et `main.tsx` bascule sur `HashRouter` quand
`Capacitor.isNativePlatform()` est vrai.
**Prérequis** : Android Studio (SDK Android + un JDK ; le JBR fourni avec
Android Studio suffit). Aucun serveur Postgres/Docker local n'est nécessaire —
l'appli parle à l'API HTTPS publique.
**Build** — depuis `apps/web/` :
```bash
# 1. URL publique de l'API, gravée dans le bundle empaqueté (le build web
# normal utilise une URL relative « même origine », impossible ici).
cp .env.capacitor.example .env.capacitor # puis renseigner VITE_API_URL
# 2. Build web en mode capacitor + synchronisation dans le projet natif.
pnpm build:capacitor
npx cap sync android
# 3. Ouvrir dans Android Studio (build/run/émulateur depuis l'IDE)…
npx cap open android
# …ou build/run en ligne de commande si un appareil/émulateur est prêt :
npx cap run android
```
Le bundle web copié (`apps/web/android/app/src/main/assets/public/`) est
git-ignoré : il est régénéré par `pnpm build:capacitor && npx cap sync android`.
Les icônes et le splash sont générés depuis `apps/web/assets/logo.svg` (voir
`apps/web/assets/README.md`). iOS est hors périmètre pour l'instant (nécessite
Xcode/macOS).
**CI — build automatique d'un APK signé** (`.github/workflows/ci.yml`, job
`build-android`, issue #64) : à chaque push sur `main`, une fois les 5 autres
jobs verts, un APK release signé est construit et déposé comme artefact de
run téléchargeable. Secrets du dépôt Forgejo requis (Réglages du dépôt →
Secrets) :
| Secret | Contenu |
|---|---|
| `MOBILE_API_URL` | URL HTTPS publique de l'API — même valeur que `VITE_API_URL` dans `.env.capacitor` |
| `ANDROID_KEYSTORE_BASE64` | Le keystore de release (`.jks`) encodé en base64 |
| `ANDROID_KEYSTORE_PASSWORD` | Mot de passe du keystore |
| `ANDROID_KEY_ALIAS` | Alias de la clé dans le keystore |
| `ANDROID_KEY_PASSWORD` | Mot de passe de la clé |
Le keystore doit être généré **une seule fois**, à la main, et rester stable
pour toujours : Android refuse d'installer la mise à jour d'une appli signée
par une clé différente de celle déjà installée.
```bash
keytool -genkeypair -v -keystore batchcooking-release.jks \
-alias batchcooking -keyalg RSA -keysize 2048 -validity 10000
base64 -w0 batchcooking-release.jks # → coller comme secret ANDROID_KEYSTORE_BASE64
```
Conserver le fichier `.jks` et ses mots de passe en lieu sûr en dehors du
dépôt (jamais commité — voir `apps/web/android/.gitignore`) : le perdre
empêche de publier toute future mise à jour de l'appli.
## Auth (apps/api) ## Auth (apps/api)

View file

@ -11,7 +11,7 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — defaults shown (see src/config/env.ts) # Optional — defaults shown (see src/config/env.ts)
# JWT_EXPIRES_IN=7d # JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session # AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173,https://localhost # CORS_ORIGIN=http://localhost:5173
# INTENT_SERVICE_BASE_URL=http://localhost:8000 # INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see # 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_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops # 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -49,6 +49,7 @@ model Diet {
key String @unique key String @unique
users UserProfile[] users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[] ingredients IngredientDiet[]
@@map("diet") @@map("diet")
@ -124,9 +125,6 @@ model UserProfile {
/// list regardless of that real-world cardinality. /// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin") administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference? preferences UserPreference?
/// 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 /// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author — /// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`). /// see `StepTechStepCorrection.correctorId`).
@ -134,11 +132,6 @@ model UserProfile {
/// Placeholder `Ingredient` rows this profile created by typing a free-text /// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`. /// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator") 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") @@map("user_profiles")
} }
@ -377,6 +370,7 @@ model Recipe {
steps Step[] steps Step[]
planningItems PlanningItem[] planningItems PlanningItem[]
favoritedBy RecipeFavorite[] favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId]) @@unique([sourceId, externalId])
@@map("recipe") @@map("recipe")
@ -397,6 +391,21 @@ model RecipeFavorite {
@@map("recipe_favorite") @@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 /// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay /// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A /// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
@ -686,17 +695,12 @@ model Unit {
model RecipeIngredient { model RecipeIngredient {
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id") ingredientId Int @map("ingredient_id")
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line — quantity Decimal @db.Decimal(10, 2)
/// salt, pepper… added to taste, with nothing to scale by portions or to unitId Int @map("unit_id")
/// 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")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id]) unit Unit @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId]) @@id([recipeId, ingredientId])
@@map("recipe_ingredient") @@map("recipe_ingredient")
@ -721,33 +725,6 @@ model TechStep {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique 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[] steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong) /// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`. /// match — see `StepTechStepCorrection.previousTechStepId`.
@ -758,10 +735,7 @@ model TechStep {
/// Training-corpus suggestions targeting this technique — see /// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`. /// `TechStepTrainingSuggestion`.
trainingSuggestions 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") @@map("tech_step")
} }
@ -1017,7 +991,7 @@ model StepTechStepCorrection {
/// ///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays /// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped /// (`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 /// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against. /// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion { model TechStepTrainingSuggestion {
@ -1133,126 +1107,3 @@ model BackfillState {
@@map("backfill_state") @@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")
}

View file

@ -7,11 +7,9 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js"; import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js"; import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { contactRouter } from "./modules/contact/contact.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js";
@ -36,11 +34,10 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never // pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route). // makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger); server.addMiddleware(requestLogger);
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in // One allowed origin: the app (`CORS_ORIGIN`). The admin surface
// config/env.ts for why the packaged Android app's origin must always be // (`/admin/*`) is served by this same API and consumed by `apps/web`'s
// one of them). The admin surface (`/admin/*`) is served by this same API // own `/admin/*` routes — same origin as the rest of the app, so no
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the // extra CORS entry.
// rest of the app, so no extra CORS entry.
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => { server.addRoute("get", "/health", (_req: Request, res: Response) => {
@ -61,20 +58,12 @@ export function createServer(): ExpressServer {
// admin page path can ever collide with an admin API path again. // admin page path can ever collide with an admin API path again.
server.mountRouter("/admin/api", adminRouter); server.mountRouter("/admin/api", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// In-app "signaler un bug / suggestion" form (issue #80) — session
// required, one POST. Admins read these rows via `/admin/api/contact`,
// never this router.
server.mountRouter("/contact", contactRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by // Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own // `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers // doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single // 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. // recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter); server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
// mobile.service.ts's doc comment for why this can't just be called
// directly from the browser.
server.mountRouter("/mobile", mobileRouter);
server.mountRouter("/planning", planningRouter); server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);

View file

@ -13,26 +13,12 @@ import { z } from "zod";
// separate test database this now requires. // separate test database this now requires.
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
/**
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
* is `.dockerignore`d, so that passthrough is the container's only source
* of these. Fields that must *fail closed* on a blank value rather than run
* their own format check against `""` (and crash `parse()` at boot) wrap
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
* folds the same `"" -> undefined` step into its own `.transform`.
*/
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
/** /**
* Schema for every environment variable the API reads. Parsing (below) * Schema for every environment variable the API reads. Parsing (below)
* fails fast at startup if something required is missing/invalid, instead * fails fast at startup if something required is missing/invalid, instead
* of surfacing as a confusing runtime error later. Exported so the parsing * of surfacing as a confusing runtime error later.
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
* without the process-wide `process.env` the singleton below parses.
*/ */
export const envSchema = z.object({ const envSchema = z.object({
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */ /** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
/** Port the HTTP server listens on. */ /** Port the HTTP server listens on. */
@ -48,42 +34,8 @@ export const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default("7d"), JWT_EXPIRES_IN: z.string().default("7d"),
/** Name of the httpOnly cookie carrying the session JWT. */ /** Name of the httpOnly cookie carrying the session JWT. */
AUTH_COOKIE_NAME: z.string().default("session"), AUTH_COOKIE_NAME: z.string().default("session"),
/** /** Origin allowed by CORS — must match wherever apps/web is served from. */
* Origin(s) allowed by CORS, comma-separated — parsed below into a CORS_ORIGIN: z.string().default("http://localhost:5173"),
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
* package) and reused as-is for the Socket.IO handshake's own CORS config
* (`server.ts`, which needs its own since the WS upgrade never runs
* through `setupCore`'s middleware).
*
* Must always include `https://localhost`: the packaged Android app's
* WebView is served from exactly that fixed origin (Capacitor's default
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
* doc comment), and every request it makes to this API — `/auth/login`
* and `/auth/signup` included — is genuinely cross-origin, unlike
* apps/web's *browser* build, which is served from the very same origin
* as this API in production (see docker-compose.yml's `app` service) and
* so never needs an entry here at all. Missing this origin doesn't fail
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
* for an origin it's about to reject (it only *omits*
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
* looks identical to a normal one right up until the browser silently
* drops the real `POST` — surfacing client-side as a generic "unexpected
* error" with no 401/403 anywhere to point at the actual cause. Default
* covers both the Vite dev server and the packaged app out of the box; a
* real deployment's own web origin (e.g. a production domain) never needs
* adding here for the reason above, but the env var still allows
* extending/overriding this list (comma-separated) if that ever changes.
*/
CORS_ORIGIN: z
.string()
.default("http://localhost:5173,https://localhost")
.transform((value) =>
value
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
),
/** /**
* Absolute path to the built frontend (`apps/web/dist`), to serve * Absolute path to the built frontend (`apps/web/dist`), to serve
* alongside the API. Optional, no default — only set inside the * 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_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(), ADMIN_INITIAL_PASSWORD: z.string().optional(),
ADMIN_INITIAL_NAME: z.string().optional(), ADMIN_INITIAL_NAME: z.string().optional(),
/**
* Forgejo instance + credentials for "create an issue from a contact
* message" (`/admin/messages`, issue #81). All three `.optional()` and
* checked together by `ForgejoService.isConfigured()` — the feature
* **fails closed** when any is missing: the endpoint answers `503` and
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
* rest of the app never talks to Forgejo.
*/
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()),
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()),
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
FORGEJO_REPO: z.preprocess(
blankAsUnset,
z
.string()
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
.optional(),
),
}); });
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -191,114 +191,6 @@ export const UTENSILS: string[] = [
"mandoline", "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 // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than // businesses to declare — a standard, defensible reference list rather than
// an invented one. Split into ALLERGY (classic IgE-mediated immune // 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) { if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true }); 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,
})),
},
},
});
}
} }

View file

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

View file

@ -13,34 +13,16 @@ import { toSafeProfile } from "../lib/safe-profile.js";
* typed, no cast needed. * typed, no cast needed.
*/ */
export interface AuthLocals { export interface AuthLocals {
/** The authenticated profile, resolved from the session cookie's JWT (or an `Authorization: Bearer` one, for cookie-less clients). */ /** The authenticated profile, resolved from the session cookie's JWT. */
userProfile: SafeUserProfile; userProfile: SafeUserProfile;
} }
/**
* Pulls the raw JWT out of an `Authorization: Bearer <jwt>` header, or
* returns `null` if it's absent or malformed. This is the fallback path for
* clients that can't rely on the `session` cookie — a Capacitor WebView,
* whose origin never receives a `SameSite=Lax` cookie (see `AuthResponse`
* in packages/shared). The cookie remains the primary mechanism; this only
* kicks in when the cookie isn't there.
*/
function readBearerToken(header: string | undefined): string | null {
if (!header?.startsWith("Bearer ")) {
return null;
}
const token = header.slice("Bearer ".length).trim();
return token.length > 0 ? token : null;
}
/** /**
* Express middleware guarding routes that require an authenticated * Express middleware guarding routes that require an authenticated
* profile. Reads the session cookie (falling back to an `Authorization: * profile. Reads the session cookie, verifies the JWT, and re-checks
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}), * `tokenVersion` against the database — so a stateless JWT can still be
* verifies the JWT, and re-checks `tokenVersion` against the database — so a * invalidated server-side (e.g. on password change / logout-everywhere,
* stateless JWT can still be invalidated server-side (e.g. on password * once that feature exists) despite carrying no server-side session.
* change / logout-everywhere, once that feature exists) despite carrying no
* server-side session.
* *
* On success, attaches the resolved profile to `res.locals.userProfile` * On success, attaches the resolved profile to `res.locals.userProfile`
* (typed via {@link AuthLocals}) for downstream handlers to use. * (typed via {@link AuthLocals}) for downstream handlers to use.
@ -51,9 +33,9 @@ function readBearerToken(header: string | undefined): string | null {
* project-wide ambient augmentation silently changing every `Request` in * project-wide ambient augmentation silently changing every `Request` in
* the codebase, whether or not it went through this middleware. * the codebase, whether or not it went through this middleware.
* *
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
* and no usable Bearer header, malformed/expired JWT, unknown profile, or * cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
* stale tokenVersion. Never distinguishes the reason to the client. * Never distinguishes the reason to the client.
*/ */
export async function requireAuth( export async function requireAuth(
req: Request, req: Request,
@ -61,11 +43,7 @@ export async function requireAuth(
next: NextFunction, next: NextFunction,
) { ) {
try { try {
const cookieToken = req.cookies?.[env.AUTH_COOKIE_NAME]; const token = req.cookies?.[env.AUTH_COOKIE_NAME];
// Cookie first (the browser's mechanism, unchanged); `Authorization:
// Bearer` as a fallback for cookie-less clients — see readBearerToken.
const token =
typeof cookieToken === "string" ? cookieToken : readBearerToken(req.headers.authorization);
if (typeof token !== "string") { if (typeof token !== "string") {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
} }

View file

@ -1,10 +1,5 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
listPlaceholdersQuerySchema,
listTechStepPlaceholdersQuerySchema,
markPlaceholdersReviewedSchema,
markTechStepPlaceholdersReviewedSchema,
} from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js"; import { requireAdmin } from "../../middlewares/require-admin.js";
import { import {
@ -12,17 +7,12 @@ import {
markPlaceholdersReviewed, markPlaceholdersReviewed,
pruneOrphanPlaceholders, pruneOrphanPlaceholders,
} from "./admin-catalog.service.js"; } 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 * Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users * behind {@link requireAdmin}. Surfaces the off-catalog ingredient
* typed when the seeded catalog fell short — free-text ingredients on a * "placeholders" users typed when the seeded catalog fell short, so a
* recipe line, and free-text techniques while correcting a step (issue #130) * maintainer can see what's missing and mark gaps as handled.
* — so a maintainer can see what's missing and mark gaps as handled.
*/ */
export const adminCatalogRouter = Router(); export const adminCatalogRouter = Router();
@ -52,29 +42,3 @@ adminCatalogRouter.post(
res.status(200).json(await pruneOrphanPlaceholders()); 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),
),
);
}),
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,8 @@
import { Router } from "express"; import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js"; import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js"; import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminContactRouter } from "./admin-contact.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.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 * Aggregator for the admin application's API surface, mounted at
@ -27,6 +24,3 @@ adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter); adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

View file

@ -1,10 +1,5 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
type AuthResponse,
deleteAccountSchema,
loginSchema,
signupSchema,
} from "@batch-cooking/shared";
import type { CookieOptions, Response } from "express"; import type { CookieOptions, Response } from "express";
import { Router } from "express"; import { Router } from "express";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
@ -48,11 +43,7 @@ authRouter.post(
const input = signupSchema.parse(req.body); const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input); const { profile, token } = await signup(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
// Token is echoed in the body too, not just the cookie — see res.status(201).json(profile);
// `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);
}), }),
); );
@ -63,7 +54,7 @@ authRouter.post(
const input = loginSchema.parse(req.body); const input = loginSchema.parse(req.body);
const { profile, token } = await login(input); const { profile, token } = await login(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(200).json({ ...profile, token } satisfies AuthResponse); res.status(200).json(profile);
}), }),
); );

View file

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

View file

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

View file

@ -89,12 +89,7 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
unit: line.unit === null ? null : toUnitView(line.unit), unit: line.unit === null ? null : toUnitView(line.unit),
})); }));
return { return {
techStep: { techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
id: techStep.techStep.id,
key: techStep.techStep.key,
isPlaceholder: techStep.techStep.isPlaceholder,
displayName: techStep.techStep.displayName,
},
order: techStep.order, order: techStep.order,
ingredients, ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })), utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),

View file

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

View file

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

View file

@ -1,9 +1,5 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import {
type CompletedToursView,
completeTourSchema,
ErrorCode,
updateAllergiesSchema, updateAllergiesSchema,
updateDietSchema, updateDietSchema,
updateDislikedIngredientsSchema, updateDislikedIngredientsSchema,
@ -12,9 +8,7 @@ import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { import {
getAllergyIds, getAllergyIds,
getCompletedTourKeys,
getDislikedIngredientIds, getDislikedIngredientIds,
markTourSeen,
updateAllergies, updateAllergies,
updateDiet, updateDiet,
updateDislikedIngredients, 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. */ /** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
export const profileRouter = Router(); export const profileRouter = Router();
/** 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). */ /** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */
profileRouter.patch( profileRouter.patch(
"/diet", "/diet",
@ -84,29 +70,3 @@ profileRouter.patch(
res.status(200).json(ids); 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();
}),
);

View file

@ -1,7 +1,6 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { toSafeProfile } from "../../lib/safe-profile.js"; import { toSafeProfile } from "../../lib/safe-profile.js";
/** /**
@ -150,57 +149,3 @@ export async function updateDislikedIngredients(
throw err; // see updateDiet()'s catch comment above 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
}
}

View file

@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
ErrorCode, ErrorCode,
@ -381,20 +380,10 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
start: correction.start, start: correction.start,
end: correction.end, end: correction.end,
previousTechStep: correction.previousTechStep previousTechStep: correction.previousTechStep
? { ? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
id: correction.previousTechStep.id,
key: correction.previousTechStep.key,
isPlaceholder: correction.previousTechStep.isPlaceholder,
displayName: correction.previousTechStep.displayName,
}
: null, : null,
correctedTechStep: correction.correctedTechStep correctedTechStep: correction.correctedTechStep
? { ? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
id: correction.correctedTechStep.id,
key: correction.correctedTechStep.key,
isPlaceholder: correction.correctedTechStep.isPlaceholder,
displayName: correction.correctedTechStep.displayName,
}
: null, : null,
createdAt: correction.createdAt.toISOString(), createdAt: correction.createdAt.toISOString(),
}; };
@ -460,32 +449,7 @@ export async function submitTechStepCorrection(
); );
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId)); await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction( const { correction, techSteps } = await prisma.$transaction(async (tx) => {
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 createdCorrection = await tx.stepTechStepCorrection.create({ const createdCorrection = await tx.stepTechStepCorrection.create({
data: { data: {
stepId: step.id, stepId: step.id,
@ -493,7 +457,7 @@ export async function submitTechStepCorrection(
start: input.start, start: input.start,
end: input.end, end: input.end,
previousTechStepId: input.previousTechStepId ?? null, previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: effectiveCorrectedTechStepId, correctedTechStepId: input.correctedTechStepId ?? null,
}, },
include: correctionInclude, include: correctionInclude,
}); });
@ -503,7 +467,7 @@ export async function submitTechStepCorrection(
step.id, step.id,
{ start: input.start, end: input.end }, { start: input.start, end: input.end },
input.previousTechStepId ?? null, input.previousTechStepId ?? null,
effectiveCorrectedTechStepId, input.correctedTechStepId ?? null,
input.ingredients === undefined && input.ingredients === undefined &&
input.utensils === undefined && input.utensils === undefined &&
input.temperatures === undefined input.temperatures === undefined
@ -560,9 +524,8 @@ export async function submitTechStepCorrection(
}, },
}); });
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId }; return { correction: createdCorrection, techSteps: freshTechSteps };
}, });
);
analytics.recordEvent("tech_step.correction_submitted", { analytics.recordEvent("tech_step.correction_submitted", {
actorId: correctorId, actorId: correctorId,
@ -570,20 +533,9 @@ export async function submitTechStepCorrection(
recipeId, recipeId,
stepId, stepId,
previousTechStepId: input.previousTechStepId ?? null, 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) }; return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) { } catch (err) {

View file

@ -3,7 +3,6 @@ import { HttpError } from "@batch-cooking/error-tools";
import { import {
type AllergyView, type AllergyView,
type CreateRecipeInput, type CreateRecipeInput,
computeRecipeDietViews,
type DietView, type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
@ -30,7 +29,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
// is a parameter rather than baked into that module. // is a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr"; 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) { function recipeInclude(viewerId: number) {
return { return {
ingredients: { ingredients: {
@ -75,38 +74,18 @@ function recipeInclude(viewerId: number) {
}, },
}, },
}, },
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } }, favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude; } 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<{ type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>; 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. */ /** 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"]; 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}. */ /** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>; 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). */ /** 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 { export function toUnitView(unit: UnitWithDetails): UnitView {
@ -142,6 +121,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}. */ /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
const byId = new Map<number, AllergyView>(); const byId = new Map<number, AllergyView>();
@ -153,13 +136,10 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
return [...byId.values()]; 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. */ /** 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( function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
recipe: RecipeWithDetails, const allergens = aggregateAllergens(
glutenFreeDiet: DietView | undefined, recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
): RecipeSummaryView {
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
toIngredientView(recipeIngredient.ingredient),
); );
return { return {
id: recipe.id, id: recipe.id,
@ -169,9 +149,8 @@ function toRecipeSummaryView(
portions: recipe.portions, portions: recipe.portions,
authorId: recipe.authorId, authorId: recipe.authorId,
visibility: recipe.visibility, visibility: recipe.visibility,
allergens: aggregateAllergens(ingredientViews), allergens,
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`. diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
isFavorite: recipe.favoritedBy.length > 0, isFavorite: recipe.favoritedBy.length > 0,
}; };
} }
@ -211,12 +190,7 @@ export function toStepTechStepViews(
} = stepTechStep; } = stepTechStep;
if (start === null || end === null) continue; if (start === null || end === null) continue;
views.push({ views.push({
techStep: { techStep: { id: techStep.id, key: techStep.key },
id: techStep.id,
key: techStep.key,
isPlaceholder: techStep.isPlaceholder,
displayName: techStep.displayName,
},
start, start,
end, end,
// `source` is a plain DB `String`, not a Prisma enum (see // `source` is a plain DB `String`, not a Prisma enum (see
@ -266,16 +240,15 @@ export function toStepTechStepViews(
return views; return views;
} }
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */ /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView { function toRecipeView(recipe: RecipeWithDetails): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({ const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`. quantity: Number(recipeIngredient.quantity),
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity), unit: toUnitView(recipeIngredient.unit),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
})); }));
return { return {
...toRecipeSummaryView(recipe, glutenFreeDiet), ...toRecipeSummaryView(recipe),
ingredients, ingredients,
steps: recipe.steps.map((step) => ({ steps: recipe.steps.map((step) => ({
id: step.id, id: step.id,
@ -328,57 +301,54 @@ function visibleToViewerWhere(
} }
/** /**
* The two halves of the planning recipe picker's "convient à tout le foyer" * `Recipe` rows that avoid every member of `houseId`'s declared allergens
* toggle (`suitableForHousehold` on `listRecipes`): * and, for every member with a declared regime, are tagged with that
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that * regime — the planning recipe picker's "convient à tout le foyer" toggle
* contains an allergen declared by a member (still expressible in SQL). * (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
* - `requiredDietKeys` — every regime declared by a member (`omnivore` * extra query to gather the household's members' allergy/regime ids)
* dropped: it constrains nothing). A recipe must satisfy *all* of them — * rather than exposed to the client as raw per-member data: a member's
* a recipe suiting a vegetarian member doesn't automatically suit a * allergies/regime are private the same way visibility already keeps a
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the * recipe's existence private (404, never 403) — nothing here should let
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a * one member infer another's medical/dietary info from the shape of a
* stored column to query (issue #131). * filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
* * the schema already treats disliked ingredients as a taste preference,
* Computed server-side (a small extra query for the members' allergy/regime * not a safety constraint (see that model's doc comment), so it doesn't
* data) rather than exposed to the client as raw per-member data: a * belong in a filter framed around what's safe/appropriate to serve.
* 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).
*/ */
async function suitableForHousehold( async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
houseId: number,
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
try { try {
const members = await prisma.userProfile.findMany({ const members = await prisma.userProfile.findMany({
where: { houseId }, where: { houseId },
select: { select: { dietId: true, allergies: { select: { allergyId: true } } },
diet: { select: { key: true } },
allergies: { select: { allergyId: true } },
},
}); });
const requiredDietKeys = [ const requiredDietIds = [
...new Set( ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
members
.map((member) => member.diet?.key)
.filter((key): key is string => key !== undefined && key !== "omnivore"),
),
]; ];
const excludedAllergyIds = [ 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 = const conditions: Prisma.RecipeWhereInput[] = [];
excludedAllergyIds.length > 0 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: { ingredients: {
none: { none: {
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } }, ingredient: {
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
}, },
}, },
},
});
} }
: {}; return { AND: conditions };
return { allergenWhere, requiredDietKeys };
} catch (err) { } catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it, // already logs it, see `error-logger.ts`) is what actually handles it,
@ -412,7 +382,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
).map((row) => row.sourceId); ).map((row) => row.sourceId);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] }; return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -426,11 +396,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
export interface ListRecipesFilters { export interface ListRecipesFilters {
/** Case-insensitive name substring. */ /** Case-insensitive name substring. */
search?: string; 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; suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */ /** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[]; 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[]; dietIds?: number[];
} }
@ -450,32 +420,31 @@ export async function listRecipes(
filters: ListRecipesFilters = {}, filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> { ): Promise<RecipeSummaryView[]> {
try { try {
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters; const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)]; const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) { if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } }); 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 // No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing. // the `foyer` tab returning everything it can rather than throwing.
if (suitable && viewerHouseId !== null) { if (suitableForHousehold && viewerHouseId !== null) {
const household = await suitableForHousehold(viewerHouseId); conditions.push(await suitableForHouseholdWhere(viewerHouseId));
conditions.push(household.allergenWhere);
requiredDietKeys = household.requiredDietKeys;
} }
if (ingredientIds && ingredientIds.length > 0) { if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of // 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({ conditions.push({
AND: ingredientIds.map((ingredientId) => ({ AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } }, ingredients: { some: { ingredientId } },
})), })),
}); });
} }
if (dietIds && dietIds.length > 0) {
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
switch (tab) { switch (tab) {
case "favoris": case "favoris":
@ -495,38 +464,14 @@ export async function listRecipes(
break; break;
} }
const [recipes, referenceDiets] = await Promise.all([ const recipes = await prisma.recipe.findMany({
prisma.recipe.findMany({
where: { AND: conditions }, where: { AND: conditions },
include: recipeInclude(viewerId), include: recipeInclude(viewerId),
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}), });
loadReferenceDiets(), return recipes.map(toRecipeSummaryView);
]);
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;
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -545,23 +490,23 @@ export async function getRecipe(
if (!canView(recipe, viewerId, viewerHouseId)) { if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
} }
return toRecipeView(recipe, await loadGlutenFreeDiet()); return toRecipeView(recipe);
} catch (err) { } 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 — * Creates a recipe with its ingredients, ordered steps and diet tags in one
* steps' `order` is derived from their position in `input.steps`, * go — steps' `order` is derived from their position in `input.steps`,
* ingredients reference existing reference `Ingredient` rows by id (see * ingredients reference existing reference `Ingredient` rows by id (see
* `GET /reference/ingredients`; there's no way to create one here). Its * `GET /reference/ingredients`; there's no way to create one here).
* regimes aren't stored — they're computed from the ingredients on read * `authorId`/`authorHouseId` are fixed at creation and never change on
* (issue #131). `authorId`/`authorHouseId` are fixed at creation and never * later edits (see {@link updateRecipe}).
* 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 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 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( export async function createRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
@ -571,7 +516,7 @@ export async function createRecipe(
try { try {
return await createRecipeInternal(input, authorId, authorHouseId, null); return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -588,6 +533,7 @@ export async function createRecipe(
* *
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @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 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( export async function createImportedRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
@ -598,7 +544,7 @@ export async function createImportedRecipe(
try { try {
return await createRecipeInternal(input, authorId, authorHouseId, source); return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -632,11 +578,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 { interface ResolvedIngredientLine {
ingredientId: number; ingredientId: number;
quantity: number | null; quantity: number;
unitId: number | null; unitId: number;
} }
/** /**
@ -669,8 +615,8 @@ async function resolveIngredientLines(
if (line.ingredientId !== undefined) { if (line.ingredientId !== undefined) {
resolved.push({ resolved.push({
ingredientId: line.ingredientId, ingredientId: line.ingredientId,
quantity: line.quantity ?? null, quantity: line.quantity,
unitId: line.unitId ?? null, unitId: line.unitId,
}); });
continue; continue;
} }
@ -686,16 +632,12 @@ async function resolveIngredientLines(
}, },
select: { id: true }, select: { id: true },
}); });
resolved.push({ resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
ingredientId: placeholder.id,
quantity: line.quantity ?? null,
unitId: line.unitId ?? null,
});
createdPlaceholders.push({ id: placeholder.id, name }); createdPlaceholders.push({ id: placeholder.id, name });
} }
return { resolved, createdPlaceholders }; return { resolved, createdPlaceholders };
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -709,9 +651,8 @@ async function createRecipeInternal(
await assertIngredientsExist( await assertIngredientsExist(
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined), input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
); );
await assertUnitsExist( await assertUnitsExist(input.ingredients.map((i) => i.unitId));
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), await assertDietsExist(input.dietIds);
);
// Matched up front (one call per step, in parallel) rather than inline // Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async // inside the `steps.create` map below — `techStepClassifier` is async
// (a trained model, not a pure regex test), so its result has to // (a trained model, not a pure regex test), so its result has to
@ -761,6 +702,7 @@ async function createRecipeInternal(
techSteps: { create: toStepTechStepCreateInputs(matches) }, techSteps: { create: toStepTechStepCreateInputs(matches) },
})), })),
}, },
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
include: recipeInclude(authorId), include: recipeInclude(authorId),
}); });
@ -778,25 +720,24 @@ async function createRecipeInternal(
}); });
} }
return toRecipeView(created, await loadGlutenFreeDiet()); return toRecipeView(created);
} catch (err) { } 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 * 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 * missing from `input` is removed, same contract as `PATCH
* /profile/allergies`). Regimes aren't part of the payload — they're * /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
* recomputed from the ingredients on the next read (issue #131). * never transfers ownership.
* `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} `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} `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 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 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( export async function updateRecipe(
id: number, id: number,
@ -811,9 +752,8 @@ export async function updateRecipe(
.map((i) => i.ingredientId) .map((i) => i.ingredientId)
.filter((ingredientId): ingredientId is number => ingredientId !== undefined), .filter((ingredientId): ingredientId is number => ingredientId !== undefined),
); );
await assertUnitsExist( await assertUnitsExist(input.ingredients.map((i) => i.unitId));
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), await assertDietsExist(input.dietIds);
);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
// Interactive transaction (not the array form) so any new free-text // Interactive transaction (not the array form) so any new free-text
@ -824,6 +764,7 @@ export async function updateRecipe(
const createdPlaceholders = await prisma.$transaction(async (tx) => { const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } }); await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } }); await tx.step.deleteMany({ where: { recipeId: id } });
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines( const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients, input.ingredients,
viewerId, viewerId,
@ -858,6 +799,7 @@ export async function updateRecipe(
techSteps: { create: toStepTechStepCreateInputs(matches) }, techSteps: { create: toStepTechStepCreateInputs(matches) },
})), })),
}, },
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
}); });
return createdPlaceholders; return createdPlaceholders;
@ -870,9 +812,9 @@ export async function updateRecipe(
}); });
} }
return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet()); return toRecipeView(await findRecipeOrThrow(id, viewerId));
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -905,7 +847,7 @@ export async function deleteRecipe(
await prisma.recipe.delete({ where: { id } }); await prisma.recipe.delete({ where: { id } });
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -933,7 +875,7 @@ export async function addFavorite(
create: { userProfileId: viewerId, recipeId: id }, create: { userProfileId: viewerId, recipeId: id },
}); });
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -944,7 +886,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
where: { userProfileId: viewerId, recipeId: id }, where: { userProfileId: viewerId, recipeId: id },
}); });
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -967,7 +909,7 @@ export async function assertRecipeVisible(
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -983,7 +925,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
} }
return recipe; return recipe;
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -1002,7 +944,7 @@ async function assertIsAuthor(
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this"); throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -1024,7 +966,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
); );
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -1046,6 +988,29 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
); );
} }
} catch (err) { } 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
} }
} }

View file

@ -6,7 +6,6 @@ import {
getIngredients, getIngredients,
getSources, getSources,
getTechSteps, getTechSteps,
getTours,
getUnits, getUnits,
getUtensils, getUtensils,
} from "./reference.service.js"; } from "./reference.service.js";
@ -70,14 +69,3 @@ referenceRouter.get(
res.status(200).json(await getSources()); 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());
}),
);

View file

@ -4,7 +4,6 @@ import type {
IngredientView, IngredientView,
SourceView, SourceView,
TechStepView, TechStepView,
TourScenarioView,
UnitView, UnitView,
UtensilView, UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -77,21 +76,11 @@ export async function getUnits(): Promise<UnitView[]> {
/** /**
* All reference cooking techniques, ordered by key (see {@link getDiets} * All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s * for why) — small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`). * `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
*
* 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.
*/ */
export async function getTechSteps(): Promise<TechStepView[]> { export async function getTechSteps(): Promise<TechStepView[]> {
try { try {
const techSteps = await prisma.techStep.findMany({ return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
where: { isPlaceholder: false },
select: { id: true, key: true },
orderBy: { key: "asc" },
});
return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null }));
} catch (err) { } catch (err) {
throw err; // see getDiets()'s catch comment above 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 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
}
}

View file

@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>; 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 { interface RunningTotal {
ingredient: IngredientView; ingredient: IngredientView;
unit: UnitView | null; unit: UnitView;
quantity: number | null; quantity: number;
} }
/** /**
@ -70,31 +70,16 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
for (const item of items) { for (const item of items) {
const scale = item.portions / item.recipe.portions; const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) { 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 key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale; const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key); const existing = totals.get(key);
if (existing && existing.quantity !== null) { if (existing) {
existing.quantity += addedQuantity; existing.quantity += addedQuantity;
} else { } else {
totals.set(key, { totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit), unit: toUnitView(recipeIngredient.unit),
quantity: addedQuantity, quantity: addedQuantity,
}); });
} }

View file

@ -180,20 +180,9 @@ export async function previewSourceItem(
), ),
loadIngredientCatalog(adapter.locale), loadIngredientCatalog(adapter.locale),
loadUnitCatalog(adapter.locale), loadUnitCatalog(adapter.locale),
// Real catalog techniques only — a draft preview is never a saved prisma.techStep.findMany({ select: { id: true, key: true } }),
// 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 },
}),
]); ]);
const techStepById = new Map( const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
techStepsByKey.map((techStep) => [
techStep.id,
{ ...techStep, isPlaceholder: false, displayName: null },
]),
);
const translatedIngredients = translateRecipeIngredients( const translatedIngredients = translateRecipeIngredients(
parsed.ingredients, parsed.ingredients,

View file

@ -2,7 +2,6 @@ import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-regist
import { sevenFiftyGAdapter } from "./750g.js"; import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js"; import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js"; import { marmitonAdapter } from "./marmiton.js";
import { ohMonBentoAdapter } from "./ohmonbento.js";
import { quitoqueAdapter } from "./quitoque.js"; import { quitoqueAdapter } from "./quitoque.js";
import { theMealDbAdapter } from "./the-meal-db.js"; import { theMealDbAdapter } from "./the-meal-db.js";
@ -10,9 +9,8 @@ import { theMealDbAdapter } from "./the-meal-db.js";
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app * Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry (`recipe-source-registry.ts`) * ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter`, * — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter`,
* `mangerBougerAdapter`, `quitoqueAdapter` and `ohMonBentoAdapter`. Called * `mangerBougerAdapter` and `quitoqueAdapter`. Called once, explicitly, by
* once, explicitly, by the two real entry points that need the registry * the two real entry points that need the registry populated:
* populated:
* *
* - `server.ts` — the running API process, before it starts listening. * - `server.ts` — the running API process, before it starts listening.
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into * - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
@ -43,5 +41,4 @@ export function registerAllRecipeSources(): void {
registerRecipeSource(sevenFiftyGAdapter); registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter); registerRecipeSource(mangerBougerAdapter);
registerRecipeSource(quitoqueAdapter); registerRecipeSource(quitoqueAdapter);
registerRecipeSource(ohMonBentoAdapter);
} }

View file

@ -18,13 +18,11 @@ const SOURCE_KEY = "mangerBouger";
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a // 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 // plain GET carries the exact same Redux state the client hydrates from as
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already // a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
// has everything `list()` needs. Verified live for the *search* path // has everything `list()` needs. Verified live: `?query=<free text>` really
// (`?diet=ALL&page=N&query=<free text>`): `query` really filters // filters server-side (not just a client-side URL update over an
// server-side (not just a client-side URL update over an already-fetched // already-fetched page), and `page`/`hasMorePages` behave as real,
// page), and `page`/`hasMorePages` behave as real, consistent pagination. // consistent pagination — the best-behaved of this adapter family's three
// The *catalogue browse* path (no `query` term) must be the bare // sources on that front.
// `?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.
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes"; 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/"; const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
@ -44,7 +42,7 @@ interface MangerBougerListPageData {
initialState?: { initialState?: {
recipes?: { recipes?: {
list?: MangerBougerListEntry[]; 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; hasMorePages?: boolean;
}; };
}; };
@ -286,18 +284,8 @@ export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: strin
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try { try {
const page = params.cursor ? Number(params.cursor) : 1; const page = params.cursor ? Number(params.cursor) : 1;
// Only append `&query=` when there's an actual search term. A trailing const query = params.query ?? "";
// empty `&query=` on the "browse everything" path (filter absent) made const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
// 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)}` : ""
}`;
let response: Response; let response: Response;
try { try {

View file

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

View file

@ -48,8 +48,6 @@ export async function resetDatabase() {
"recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe", "ingredients", "sources", "unit", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house", "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", "backfill_state"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);

View file

@ -139,118 +139,3 @@ describe("Admin catalog — off-catalog ingredients", () => {
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).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);
});
});

View file

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

View file

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

View file

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

View file

@ -55,17 +55,6 @@ describe("Auth", () => {
expect(res.headers["set-cookie"]?.[0]).to.include("session="); 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 () => { it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
const payload = buildSignupPayload(); const payload = buildSignupPayload();
await request(app).post("/auth/signup").send(payload); await request(app).post("/auth/signup").send(payload);
@ -106,16 +95,6 @@ describe("Auth", () => {
expect(res.body.email).to.equal(payload.email); 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 () => { it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app) const res = await request(app)
.post("/auth/login") .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", () => { describe("DELETE /auth/me", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).delete("/auth/me").send({ password: "whatever" }); const res = await request(app).delete("/auth/me").send({ password: "whatever" });

View file

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

View file

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

View file

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

View file

@ -195,95 +195,4 @@ describe("Profile", () => {
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); 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([]);
});
});
}); });

View file

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

View file

@ -62,6 +62,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Poulet basquaise", name: "Poulet basquaise",
portions: 4, portions: 4,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomato, quantity: 3, unitId: piece }, { ingredientId: tomato, quantity: 3, unitId: piece },
{ placeholderName: "Piment d'Espelette", quantity: 1, 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({ await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 2, portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }], ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
steps: [{ description: "x" }], steps: [{ description: "x" }],
}); });
@ -120,6 +122,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Édition", name: "Édition",
portions: 2, portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }], ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
steps: [{ description: "x" }], steps: [{ description: "x" }],
}); });
@ -129,6 +132,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const edited = await agent.patch(`/recipes/${created.body.id}`).send({ const edited = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition", name: "Édition",
portions: 2, portions: 2,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: placeholderId, quantity: 2, unitId: piece }, { ingredientId: placeholderId, quantity: 2, unitId: piece },
{ ingredientId: tomato, quantity: 1, 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({ await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition", name: "Édition",
portions: 2, portions: 2,
dietIds: [],
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
steps: [{ description: "x" }], steps: [{ description: "x" }],
}); });
@ -158,6 +163,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Invalide", name: "Invalide",
portions: 2, portions: 2,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece }, { 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({ const res = await agent.post("/recipes").send({
name: "Doublons libres", name: "Doublons libres",
portions: 2, portions: 2,
dietIds: [],
ingredients: [ ingredients: [
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece }, { placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece }, { placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },

View file

@ -132,59 +132,6 @@ describe("mangerBougerAdapter", () => {
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e"); 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 () => { it("skips a list entry missing a slug or a name", async () => {
stubFetchHtml( stubFetchHtml(
htmlWithListNextData({ htmlWithListNextData({

View file

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

View file

@ -312,16 +312,20 @@ describe("Recipes", () => {
}); });
describe("POST /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 { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const oeuf = await ingredientId("egg"); const oeuf = await ingredientId("egg");
const piece = await unitId("piece"); const piece = await unitId("piece");
const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: "vegetarian" },
});
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Omelette provençale", name: "Omelette provençale",
description: "Rapide et savoureuse", description: "Rapide et savoureuse",
portions: 2, portions: 2,
dietIds: [vegetarien.id],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece }, { ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: oeuf, quantity: 3, unitId: piece }, { ingredientId: oeuf, quantity: 3, unitId: piece },
@ -339,84 +343,7 @@ describe("Recipes", () => {
).to.deep.equal([0, 1]); ).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs". // Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs"); expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
// Regimes computed (issue #131), not tagged: tomato is vegetarian/ expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["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([]);
}); });
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => { 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({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -439,6 +367,7 @@ describe("Recipes", () => {
name: "Foyer", name: "Foyer",
visibility: "HOUSE", visibility: "HOUSE",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -457,6 +386,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }], steps: [{ description }],
}); });
@ -469,12 +399,7 @@ describe("Recipes", () => {
// response itself carries exactly what to highlight, not just the id. // response itself carries exactly what to highlight, not just the id.
const resStep = res.body.steps[0]; const resStep = res.body.steps[0];
expect(resStep.techSteps).to.have.length(1); expect(resStep.techSteps).to.have.length(1);
expect(resStep.techSteps[0].techStep).to.deep.equal({ expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
id: simmer,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
const { start, end } = resStep.techSteps[0]; const { start, end } = resStep.techSteps[0];
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter"); expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
}); });
@ -488,6 +413,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }], steps: [{ description }],
}); });
@ -522,6 +448,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }], steps: [{ description: "Servir immédiatement" }],
}); });
@ -540,6 +467,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [ steps: [
{ description: "Hacher les oignons" }, { description: "Hacher les oignons" },
@ -569,6 +497,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four"). // Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
steps: [{ description: "Cuire au four pendant 30 minutes" }], steps: [{ description: "Cuire au four pendant 30 minutes" }],
@ -590,6 +519,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Poêlée", name: "Poêlée",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// The case that motivated the sequence model: one instruction, two techniques. // The case that motivated the sequence model: one instruction, two techniques.
steps: [{ description }], steps: [{ description }],
@ -620,6 +550,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -635,6 +566,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -643,12 +575,30 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND); 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 () => { it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup(); const { agent } = await signup();
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [], ingredients: [],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -671,6 +621,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 1, unitId: piece }, { ingredientId: tomate, quantity: 1, unitId: piece },
{ ingredientId: tomate, quantity: 2, unitId: piece }, { ingredientId: tomate, quantity: 2, unitId: piece },
@ -688,6 +639,7 @@ describe("Recipes", () => {
const piece = await unitId("piece"); const piece = await unitId("piece");
const basePayload = { const basePayload = {
name: "Test", name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}; };
@ -700,55 +652,6 @@ describe("Recipes", () => {
expect(zero.status).to.equal(400); expect(zero.status).to.equal(400);
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR); 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", () => { describe("GET /recipes/:id", () => {
@ -768,6 +671,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -853,6 +757,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -861,6 +766,7 @@ describe("Recipes", () => {
name: "Salade composée", name: "Salade composée",
portions: 6, portions: 6,
visibility: "PUBLIC", visibility: "PUBLIC",
dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }], ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }], steps: [{ description: "Émincer" }, { description: "Mélanger" }],
}); });
@ -875,36 +781,6 @@ describe("Recipes", () => {
expect(res.body.steps).to.have.length(2); 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 () => { it("recomputes each replaced step's technique sequence", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
@ -913,6 +789,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }], steps: [{ description: "Servir immédiatement" }],
}); });
@ -920,6 +797,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Émincer les tomates" }], steps: [{ description: "Émincer les tomates" }],
}); });
@ -942,6 +820,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [ steps: [
{ description: "Hacher les oignons" }, // chop { description: "Hacher les oignons" }, // chop
@ -970,6 +849,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }, { description: addedDescription }], steps: [{ description: editedDescription }, { description: addedDescription }],
}); });
@ -1010,6 +890,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }], steps: [{ description: "Servir immédiatement" }],
}); });
@ -1026,6 +907,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }], steps: [{ description: editedDescription }],
}); });
@ -1060,6 +942,7 @@ describe("Recipes", () => {
const res = await agent.patch("/recipes/999999").send({ const res = await agent.patch("/recipes/999999").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -1068,6 +951,30 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); 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 () => { it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { profileId } = await signup(); const { profileId } = await signup();
const { agent: otherAgent } = await signup(); const { agent: otherAgent } = await signup();
@ -1080,6 +987,7 @@ describe("Recipes", () => {
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({ const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack", name: "Hack",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });

View file

@ -109,12 +109,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.equal(null); expect(res.body.correction.previousTechStep).to.equal(null);
expect(res.body.correction.correctedTechStep).to.deep.equal({ expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
id: simmerId,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
expect(res.body.correction.start).to.equal(6); expect(res.body.correction.start).to.equal(6);
expect(res.body.correction.end).to.equal(13); expect(res.body.correction.end).to.equal(13);
// The step's real technique sequence reflects the correction right // 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`). // `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null }, techStep: { id: simmerId, key: "simmer" },
start: 6, start: 6,
end: 13, end: 13,
source: "manual", 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 () => { 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 { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
@ -200,23 +145,13 @@ describe("Recipe tech-step corrections", () => {
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId }); .send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.deep.equal({ expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
id: simmerId, expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
key: "simmer",
isPlaceholder: false,
displayName: null,
});
expect(res.body.correction.correctedTechStep).to.deep.equal({
id: boilId,
key: "boil",
isPlaceholder: false,
displayName: null,
});
// Still exactly one entry — the relabel updated the existing row // Still exactly one entry — the relabel updated the existing row
// rather than adding a second one alongside it. // rather than adding a second one alongside it.
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null }, techStep: { id: boilId, key: "boil" },
start: 6, start: 6,
end: 13, end: 13,
source: "manual", source: "manual",
@ -364,7 +299,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null }, techStep: { id: simmerId, key: "simmer" },
start: 6, start: 6,
end: 13, end: 13,
source: "manual", source: "manual",
@ -600,7 +535,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null }, techStep: { id: preheatId, key: "preheat" },
start: 0, start: 0,
end: 11, end: 11,
source: "manual", source: "manual",
@ -786,18 +721,8 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(2); expect(res.body).to.have.length(2);
expect(res.body[0].correctedTechStep).to.deep.equal({ expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
id: boilId, expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
key: "boil",
isPlaceholder: false,
displayName: null,
});
expect(res.body[1].correctedTechStep).to.deep.equal({
id: simmerId,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
}); });
it("returns an empty list when nothing has been submitted yet", async () => { it("returns an empty list when nothing has been submitted yet", async () => {

View file

@ -3,12 +3,7 @@ import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js";
seedReferenceData,
TECH_STEPS,
TOUR_SCENARIOS,
UTENSILS,
} from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import { import {
clearRecipeSources, clearRecipeSources,
@ -148,30 +143,7 @@ describe("Reference data", () => {
// number — this catalog has grown since (26 -> 74) and will again. // number — this catalog has grown since (26 -> 74) and will again.
expect(res.body).to.have.length(TECH_STEPS.length); expect(res.body).to.have.length(TECH_STEPS.length);
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); 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[0]).to.have.keys(["id", "key"]);
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);
}); });
it("orders techniques alphabetically by key", async () => { 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"]); 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");
});
});
}); });

View file

@ -214,76 +214,6 @@ describe("Shopping list", () => {
expect(res.body.items[0].quantity).to.equal(300); 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 () => { it("sums the same ingredient across two different recipes sharing a unit", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());

View file

@ -61,15 +61,6 @@ describe("registerAllRecipeSources", () => {
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("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", () => { it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
registerAllRecipeSources(); registerAllRecipeSources();

View file

@ -339,12 +339,7 @@ describe("Sources", () => {
const [step] = res.body.steps; const [step] = res.body.steps;
expect(step.description).to.equal("Chop the onions finely"); expect(step.description).to.equal("Chop the onions finely");
expect(step.techSteps).to.have.length(1); expect(step.techSteps).to.have.length(1);
expect(step.techSteps[0].techStep).to.deep.equal({ expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
id: chop.id,
key: "chop",
isPlaceholder: false,
displayName: null,
});
expect( expect(
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(), step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
).to.equal("chop"); ).to.equal("chop");
@ -387,8 +382,6 @@ describe("Sources", () => {
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({ expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
id: simmer.id, id: simmer.id,
key: "simmer", key: "simmer",
isPlaceholder: false,
displayName: null,
}); });
}); });
@ -433,6 +426,7 @@ describe("Sources", () => {
return { return {
name: "Fake recipe 1 (revue)", name: "Fake recipe 1 (revue)",
portions: 4, portions: 4,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") }, { ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
], ],

View file

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

View file

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

View file

@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep

View file

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

View file

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

View file

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View file

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Custom scheme for the web app's "open the installed app"
redirect (see apps/web/src/lib/mobile-app-redirect.ts) —
additive, does not touch the launcher filter above. No
android:host/android:path restriction, so any
batchcooking://... URL matches. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="batchcooking" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View file

@ -1,5 +0,0 @@
package fr.kyuno.batchcooking;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

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