Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

171 changed files with 1440 additions and 10179 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.
# APP_PORT=3000
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
# building locally instead (`docker compose build`, or `up` with no image
# present) — see docker-compose.yml's `image:`/`build:` comment.
# IMAGE_TAG=latest
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
# All optional: an instance that doesn't run the admin app needs none of
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
@ -40,17 +32,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
#
# Optional — lets an admin open a Forgejo ticket from a contact message
# (apps/web's /admin/messages, issue #81). All three needed together: the
# button stays hidden and the endpoint answers 503 unless all are set to a
# non-empty value. docker-compose.yml forwards these into the "app"
# container, so setting them here is enough (a blank value counts as
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
# scope — generate it in Forgejo, never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo
# Optional — only set this to false if THIS deployment is served over
# plain HTTP (no TLS in front of it). Left unset, the session cookie

View file

@ -39,13 +39,6 @@ env:
# services/tech-step-intent-service/Dockerfile from this checkout.
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
# in those tests, so no real request ever leaves CI; these values only need
# to parse (a valid URL and an `owner/repo`).
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
FORGEJO_REPO: "ci/batchcooking"
jobs:
# Five independent jobs, no needs: between them — each starts in parallel
@ -68,70 +61,17 @@ jobs:
test:
runs-on: ubuntu-latest
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
# spent queued in "Wait for any earlier test job" below. Also a runaway
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
# leaked handle would otherwise hang the job until the runner's global
# timeout (a `test` job zombied ~1h once). If a hung job hits this
# ceiling the runner removes its container, which unblocks the queue.
timeout-minutes: 45
steps:
- name: Install curl and the docker CLI
run: |
apt-get update && apt-get install -y curl docker.io
# Serialize `test` across every concurrent run. Two `test` jobs must
# never overlap: they share one `--name postgres` container on the
# runner's shared docker network (next step), so their `prisma migrate
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
# pending migrations" when there should be some, `tech_step`/`diet` id
# shifts breaking unrelated assertions; and even past that they starve
# each other's RAM/CPU (each also trains a spaCy model) until
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
# re-trigger once the runner was idle.
#
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
# 1.26 and is silently ignored here. A lock object (network, container,
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
# trailing `if: always()` step after an earlier step fails, so there is
# no reliable in-job release. Instead, key off the one thing the runner
# *does* always clean up — the job container itself (removed on pass,
# fail, and cancel alike): wait while any lower-numbered `test` job's
# container is still running. Lower task id goes first, so two jobs
# that start together can't deadlock; nothing to release.
- name: Wait for any earlier test job
run: |
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
echo "this test job is task ${my_id:-<unknown, not serializing>}"
# ~25 min cap (75 * 20s): comfortably covers one earlier run
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
# wait exceeds this just fails and gets an empty-commit re-run.
for attempt in $(seq 1 75); do
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
if [ -z "$earlier" ]; then
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
exit 0
fi
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
sleep 20
done
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
exit 1
# Postgres as a plain background container instead of a `services:`
# block — see this workflow's top-level `env.DATABASE_URL` comment for
# why. Reachable as `postgres` from this job (the runner mounts the
# docker socket and runs jobs on a shared user-defined network, so a
# `--name postgres` sibling resolves by name); the network is
# discovered from the job container itself rather than hardcoded. The
# `docker rm -f postgres` below also clears a container left behind by
# the previous run (this runner has no reliable post-job cleanup), and
# the serialization step above means no other run is using it now.
# discovered from the job container itself rather than hardcoded.
- name: Start Postgres in the background
run: |
net="$(docker inspect "$(hostname)" \
@ -139,15 +79,6 @@ jobs:
| awk '{print $1}')"
echo "job network: ${net:?could not determine the job container network}"
docker rm -f postgres >/dev/null 2>&1 || true
# Also drop any `postgres-<suffix>` container. A since-reverted
# attempt at per-run Postgres naming on this branch left some
# running on this persistent runner (their `if: always()` teardown
# never fired — this runner skips trailing steps after a failure),
# and nothing else removes them. A second idle Postgres here starves
# the runner's RAM and slows every query ~5x → `resetDatabase()`
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
# more, so this only ever clears that debris.
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
docker run -d --name postgres --network "$net" \
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
postgres:16-alpine

View file

@ -1,34 +1,20 @@
name: Release
# On a `vX.Y.Z` tag: build the three service images and push them to this
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
# and `latest`), build+attach a signed Android APK, publish a Forgejo
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
# pull those prebuilt images (docker-compose.yml references them via
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
# keeps a `build:` fallback next to every `image:`.
# No image registry involved: Portainer is wired to this repo's Git remote
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
# that the image actually builds at the tagged commit, builds+attaches a
# signed Android APK, publishes a Forgejo Release, then (best-effort) pings
# Portainer's deploy webhook so it doesn't have to wait for its own polling
# interval.
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
# (the `build-and-push-images` job below `docker login`s with it). If a
# future instance policy scopes the run token tighter, swap that login's
# password for a `write:package` PAT added as a repo secret.
packages: write
jobs:
# Build the three service images at the tagged commit and push them to the
# Forgejo container registry. A successful push also *is* the "does it
# still build" check the old `sanity-build` job did (which only covered
# apps/api). One sequential job, not a per-image matrix: three concurrent
# `docker build`s hammer this single persistent runner's Docker daemon
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
# for no real gain on a release, which is rare and usually runs alone.
build-and-push-images:
sanity-build:
runs-on: ubuntu-latest
steps:
# This runner's job containers don't come with docker preinstalled —
@ -39,36 +25,10 @@ jobs:
- uses: https://github.com/actions/checkout@v4
- name: Build et push des 3 images vers le registre Forgejo
env:
# The per-run token Forgejo Actions mints — has `packages: write`
# via this workflow's `permissions:` block. `docker login` accepts
# it as the password for the actor who pushed the tag.
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
# registry namespace is the owner, lower-cased (Forgejo package
# names are case-insensitive but the CLI wants a stable form).
registry="${GITHUB_SERVER_URL#http*://}"
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
tag="$GITHUB_REF_NAME"
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
# Every Dockerfile's build context is the repo root (see
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
build_push() {
image="$registry/$owner/$1"
echo "::group::$1"
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
docker push "$image:$tag"
docker push "$image:latest"
echo "::endgroup::"
}
build_push batchcooking-app apps/api/Dockerfile
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
# 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 .
# 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
@ -144,12 +104,10 @@ jobs:
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.
# Nom de fichier stable, jamais versionné : c'est ce qui permet à la
# page d'accueil (#65) de pointer en permanence vers
# /releases/latest/download/batchcooking.apk sans jamais changer de
# lien d'une release à l'autre.
- name: Renomme l'APK sous un nom stable
run: cp apps/web/android/app/build/outputs/apk/release/app-release.apk batchcooking.apk
@ -164,11 +122,11 @@ jobs:
forgejo-release:
runs-on: ubuntu-latest
needs: [build-and-push-images, build-android-apk]
needs: [sanity-build, 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.
# sanity-build) et pour curl dans ci.yml's test job.
- name: Install curl
run: apt-get update && apt-get install -y curl
@ -191,39 +149,23 @@ jobs:
# 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
- name: Crée 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" \
resp="$(curl -fsS -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)"
id="$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)"
if [ -z "$id" ]; then
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
echo "Réponse inattendue de l'API Release, pas d'id trouvé : $resp" >&2
exit 1
fi
echo "Release $GITHUB_REF_NAME prête (id=$id)"
echo "Release $GITHUB_REF_NAME créée (id=$id)"
echo "release-id=$id" >> "$GITHUB_OUTPUT"
- name: Attache l'APK à la Release

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
au host — voir leurs propres README pour leur rôle.
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les
trois images de service — `batchcooking-app`, `batchcooking-intent-service`,
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo**
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`)
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
images pré-construites, `docker compose up`/`build` retombe sur un build local si
elles sont absentes. L'instance **Portainer** de production peut donc désormais
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
deux modes fonctionnent avec le même `docker-compose.yml`.
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
production est reliée directement au dépôt Git et reconstruit elle-même
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
donc aucune image nulle part.
### Release (`.github/workflows/release.yml`)
@ -229,16 +221,14 @@ git tag vX.Y.Z
git push --tags
```
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push
réussi vaut aussi vérification que tout build encore à ce commit),
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release`
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
dépôt `PORTAINER_WEBHOOK_URL`.
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
est configuré (sinon Portainer se resynchronise simplement à son prochain
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
## Application mobile (Android)

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)
# JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173,https://localhost
# CORS_ORIGIN=http://localhost:5173
# INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see
@ -35,12 +35,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
# Only needed to create a Forgejo issue from a contact message
# (apps/web's /admin/messages, issue #81). The button is hidden and the
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
# personal access token with the write:issue scope — generate it in Forgejo,
# never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo

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
users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[]
@@map("diet")
@ -134,11 +135,6 @@ model UserProfile {
/// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
/// Placeholder `TechStep` rows this profile proposed while correcting a step
/// — see `TechStep.isPlaceholder`.
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[]
@@map("user_profiles")
}
@ -377,6 +373,7 @@ model Recipe {
steps Step[]
planningItems PlanningItem[]
favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId])
@@map("recipe")
@ -397,6 +394,21 @@ model RecipeFavorite {
@@map("recipe_favorite")
}
/// Explicit join table for the recipe <-> diet "associated regime" tags
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
/// manual reminder set by whoever creates/edits the recipe, not computed
/// from its ingredients.
model RecipeDiet {
recipeId Int @map("recipe_id")
dietId Int @map("diet_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([recipeId, dietId])
@@map("recipe_diet")
}
/// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
@ -686,17 +698,12 @@ model Unit {
model RecipeIngredient {
recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id")
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
/// salt, pepper… added to taste, with nothing to scale by portions or to
/// aggregate on a shopping list. The "both null or neither" invariant is
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
/// the schema — same posture as this file's other cross-field invariants.
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
quantity Decimal @db.Decimal(10, 2)
unitId Int @map("unit_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id])
unit Unit @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId])
@@map("recipe_ingredient")
@ -721,33 +728,6 @@ model TechStep {
id Int @id @default(autoincrement())
key String @unique
/// `true` = a "placeholder" technique: a free-text technique a user typed
/// while correcting a step, because nothing in the seeded catalog matched
/// (issue #131 for the ingredient precedent this mirrors — see
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
/// training data of its own, and is excluded from
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
/// like any other technique (the correction that created it applies
/// immediately), but a maintainer reviews it at
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// The exact text the user typed — `null` for a real catalog row (whose
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
/// `isPlaceholder`, enforced service-side.
displayName String? @map("display_name")
/// Profile that first proposed this technique — context for the admin
/// review. `onDelete: SetNull` so deleting an account never blocks on the
/// recipes still using its placeholder. `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was proposed. `null` for a real catalog row.
createdAt DateTime? @map("created_at")
/// Stamped once a maintainer has triaged this proposal
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
/// `null` while pending / for a real catalog row.
reviewedAt DateTime? @map("reviewed_at")
steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`.
@ -758,10 +738,7 @@ model TechStep {
/// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[]
/// The profile that proposed this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("tech_step")
}
@ -1017,7 +994,7 @@ model StepTechStepCorrection {
///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion {
@ -1212,47 +1189,3 @@ model TourProgress {
@@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 { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { contactRouter } from "./modules/contact/contact.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
@ -36,11 +34,10 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger);
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
// config/env.ts for why the packaged Android app's origin must always be
// one of them). The admin surface (`/admin/*`) is served by this same API
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
// rest of the app, so no extra CORS entry.
// One allowed origin: the app (`CORS_ORIGIN`). The admin surface
// (`/admin/*`) is served by this same API and consumed by `apps/web`'s
// own `/admin/*` routes — same origin as the rest of the app, so no
// extra CORS entry.
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => {
@ -61,20 +58,12 @@ export function createServer(): ExpressServer {
// admin page path can ever collide with an admin API path again.
server.mountRouter("/admin/api", adminRouter);
server.mountRouter("/house", houseRouter);
// In-app "signaler un bug / suggestion" form (issue #80) — session
// required, one POST. Admins read these rows via `/admin/api/contact`,
// never this router.
server.mountRouter("/contact", contactRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single
// recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
// mobile.service.ts's doc comment for why this can't just be called
// directly from the browser.
server.mountRouter("/mobile", mobileRouter);
server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter);

View file

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

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

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

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,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,9 @@
import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminContactRouter } from "./admin-contact.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { 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
@ -28,5 +26,3 @@ adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

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),
}));
return {
techStep: {
id: techStep.techStep.id,
key: techStep.techStep.key,
isPlaceholder: techStep.techStep.isPlaceholder,
displayName: techStep.techStep.displayName,
},
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
order: techStep.order,
ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),

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,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
ErrorCode,
@ -381,20 +380,10 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
start: correction.start,
end: correction.end,
previousTechStep: correction.previousTechStep
? {
id: correction.previousTechStep.id,
key: correction.previousTechStep.key,
isPlaceholder: correction.previousTechStep.isPlaceholder,
displayName: correction.previousTechStep.displayName,
}
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
: null,
correctedTechStep: correction.correctedTechStep
? {
id: correction.correctedTechStep.id,
key: correction.correctedTechStep.key,
isPlaceholder: correction.correctedTechStep.isPlaceholder,
displayName: correction.correctedTechStep.displayName,
}
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
: null,
createdAt: correction.createdAt.toISOString(),
};
@ -460,32 +449,7 @@ export async function submitTechStepCorrection(
);
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction(
async (tx) => {
// A technique the catalog doesn't have (issue #130) — mint a
// placeholder `TechStep` for it, in the same transaction as the
// correction that references it, then proceed exactly as if its id had
// been sent as `correctedTechStepId`. Same shape as
// `recipe.service.ts`'s `resolveIngredientLines` does for a
// free-text ingredient.
const placeholderTechStepId =
input.correctedTechStepName !== undefined
? (
await tx.techStep.create({
data: {
key: `placeholder:${randomUUID()}`,
isPlaceholder: true,
displayName: input.correctedTechStepName,
createdById: correctorId,
createdAt: new Date(),
},
select: { id: true },
})
).id
: null;
const effectiveCorrectedTechStepId =
placeholderTechStepId ?? input.correctedTechStepId ?? null;
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
const createdCorrection = await tx.stepTechStepCorrection.create({
data: {
stepId: step.id,
@ -493,7 +457,7 @@ export async function submitTechStepCorrection(
start: input.start,
end: input.end,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: effectiveCorrectedTechStepId,
correctedTechStepId: input.correctedTechStepId ?? null,
},
include: correctionInclude,
});
@ -503,7 +467,7 @@ export async function submitTechStepCorrection(
step.id,
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
effectiveCorrectedTechStepId,
input.correctedTechStepId ?? null,
input.ingredients === undefined &&
input.utensils === 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", {
actorId: correctorId,
@ -570,20 +533,9 @@ export async function submitTechStepCorrection(
recipeId,
stepId,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: correction.correctedTechStepId,
correctedTechStepId: input.correctedTechStepId ?? null,
},
});
if (placeholderTechStepId !== null) {
analytics.recordEvent("tech_step.placeholder_proposed", {
actorId: correctorId,
context: {
recipeId,
stepId,
techStepId: placeholderTechStepId,
name: input.correctedTechStepName ?? "",
},
});
}
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) {

View file

@ -3,7 +3,6 @@ import { HttpError } from "@batch-cooking/error-tools";
import {
type AllergyView,
type CreateRecipeInput,
computeRecipeDietViews,
type DietView,
ErrorCode,
type IngredientView,
@ -30,7 +29,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
// is a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr";
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
function recipeInclude(viewerId: number) {
return {
ingredients: {
@ -75,38 +74,18 @@ function recipeInclude(viewerId: number) {
},
},
},
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude;
}
/**
* Every reference `Diet` row (`{ id, key }`), loaded once and memoized —
* `Diet` is seeded reference data that never changes at runtime, so caching
* it for the process's lifetime is safe. Used to attach a real `DietView` to
* the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to
* resolve the planning picker's `dietIds` filter / a household's members'
* regimes for the post-filter in {@link listRecipes}.
*/
let referenceDietsPromise: Promise<DietView[]> | null = null;
function loadReferenceDiets(): Promise<DietView[]> {
if (referenceDietsPromise === null) {
referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } });
}
return referenceDietsPromise;
}
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
async function loadGlutenFreeDiet(): Promise<DietView | undefined> {
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>;
}>;
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>;
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
export function toUnitView(unit: UnitWithDetails): UnitView {
@ -142,6 +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}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
const byId = new Map<number, AllergyView>();
@ -153,13 +136,10 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
return [...byId.values()];
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */
function toRecipeSummaryView(
recipe: RecipeWithDetails,
glutenFreeDiet: DietView | undefined,
): RecipeSummaryView {
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
toIngredientView(recipeIngredient.ingredient),
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
const allergens = aggregateAllergens(
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
);
return {
id: recipe.id,
@ -169,9 +149,8 @@ function toRecipeSummaryView(
portions: recipe.portions,
authorId: recipe.authorId,
visibility: recipe.visibility,
allergens: aggregateAllergens(ingredientViews),
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`.
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
allergens,
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
isFavorite: recipe.favoritedBy.length > 0,
};
}
@ -211,12 +190,7 @@ export function toStepTechStepViews(
} = stepTechStep;
if (start === null || end === null) continue;
views.push({
techStep: {
id: techStep.id,
key: techStep.key,
isPlaceholder: techStep.isPlaceholder,
displayName: techStep.displayName,
},
techStep: { id: techStep.id, key: techStep.key },
start,
end,
// `source` is a plain DB `String`, not a Prisma enum (see
@ -266,16 +240,15 @@ export function toStepTechStepViews(
return views;
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView {
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient),
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
quantity: Number(recipeIngredient.quantity),
unit: toUnitView(recipeIngredient.unit),
}));
return {
...toRecipeSummaryView(recipe, glutenFreeDiet),
...toRecipeSummaryView(recipe),
ingredients,
steps: recipe.steps.map((step) => ({
id: step.id,
@ -328,57 +301,54 @@ function visibleToViewerWhere(
}
/**
* The two halves of the planning recipe picker's "convient à tout le foyer"
* toggle (`suitableForHousehold` on `listRecipes`):
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that
* contains an allergen declared by a member (still expressible in SQL).
* - `requiredDietKeys` — every regime declared by a member (`omnivore`
* dropped: it constrains nothing). A recipe must satisfy *all* of them —
* a recipe suiting a vegetarian member doesn't automatically suit a
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a
* stored column to query (issue #131).
*
* Computed server-side (a small extra query for the members' allergy/regime
* data) rather than exposed to the client as raw per-member data: a
* member's allergies/regime are private the same way visibility already
* keeps a recipe's existence private. Deliberately excludes
* `UserProfileDislikedIngredient` — a taste preference, not a safety
* constraint (see that model's doc comment).
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
* and, for every member with a declared regime, are tagged with that
* regime — the planning recipe picker's "convient à tout le foyer" toggle
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
* extra query to gather the household's members' allergy/regime ids)
* rather than exposed to the client as raw per-member data: a member's
* allergies/regime are private the same way visibility already keeps a
* recipe's existence private (404, never 403) — nothing here should let
* one member infer another's medical/dietary info from the shape of a
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
* the schema already treats disliked ingredients as a taste preference,
* not a safety constraint (see that model's doc comment), so it doesn't
* belong in a filter framed around what's safe/appropriate to serve.
*/
async function suitableForHousehold(
houseId: number,
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
try {
const members = await prisma.userProfile.findMany({
where: { houseId },
select: {
diet: { select: { key: true } },
allergies: { select: { allergyId: true } },
},
select: { dietId: true, allergies: { select: { allergyId: true } } },
});
const requiredDietKeys = [
...new Set(
members
.map((member) => member.diet?.key)
.filter((key): key is string => key !== undefined && key !== "omnivore"),
),
const requiredDietIds = [
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
];
const excludedAllergyIds = [
...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))),
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
];
const allergenWhere: Prisma.RecipeWhereInput =
excludedAllergyIds.length > 0
? {
const conditions: Prisma.RecipeWhereInput[] = [];
if (requiredDietIds.length > 0) {
// Every diet declared by a member must be among this recipe's tags —
// not "at least one", since a recipe suiting a vegetarian member
// doesn't automatically suit a gluten-free one too.
conditions.push({
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: {
none: {
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } },
ingredient: {
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
},
},
},
});
}
: {};
return { allergenWhere, requiredDietKeys };
return { AND: conditions };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
@ -412,7 +382,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
).map((row) => row.sourceId);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
@ -426,11 +396,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
export interface ListRecipesFilters {
/** Case-insensitive name substring. */
search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[];
/** Recipe's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
dietIds?: number[];
}
@ -450,32 +420,31 @@ export async function listRecipes(
filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
try {
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters;
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } });
}
// Regimes are computed from ingredients, not a stored column (issue
// #131), so the household-regime and `dietIds` filters can't be SQL
// anymore — they run as post-filters on the mapped summaries below.
// Everything that still *can* be a `where` stays one.
let requiredDietKeys: string[] = [];
// No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing.
if (suitable && viewerHouseId !== null) {
const household = await suitableForHousehold(viewerHouseId);
conditions.push(household.allergenWhere);
requiredDietKeys = household.requiredDietKeys;
if (suitableForHousehold && viewerHouseId !== null) {
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
}
if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of
// them, not just one.
// them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
conditions.push({
AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } },
})),
});
}
if (dietIds && dietIds.length > 0) {
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
switch (tab) {
case "favoris":
@ -495,38 +464,14 @@ export async function listRecipes(
break;
}
const [recipes, referenceDiets] = await Promise.all([
prisma.recipe.findMany({
const recipes = await prisma.recipe.findMany({
where: { AND: conditions },
include: recipeInclude(viewerId),
orderBy: { name: "asc" },
}),
loadReferenceDiets(),
]);
const glutenFreeDiet = referenceDiets.find((diet) => diet.key === "glutenFree");
let summaries = recipes.map((recipe) => toRecipeSummaryView(recipe, glutenFreeDiet));
// Post-filters on the *computed* `diets` (see the comment where the SQL
// conditions are built). "Every one, not any one" for both.
if (requiredDietKeys.length > 0) {
summaries = summaries.filter((summary) =>
requiredDietKeys.every((key) => summary.diets.some((diet) => diet.key === key)),
);
}
if (dietIds && dietIds.length > 0) {
// `omnivore` in the filter would match nothing (it's never emitted) —
// treat it as "no constraint" rather than silently returning an empty list.
const requiredIds = dietIds.filter(
(id) => referenceDiets.find((diet) => diet.id === id)?.key !== "omnivore",
);
summaries = summaries.filter((summary) =>
requiredIds.every((id) => summary.diets.some((diet) => diet.id === id)),
);
}
return summaries;
});
return recipes.map(toRecipeSummaryView);
} 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)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return toRecipeView(recipe, await loadGlutenFreeDiet());
return toRecipeView(recipe);
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
* Creates a recipe with its ingredients and ordered steps in one go —
* steps' `order` is derived from their position in `input.steps`,
* Creates a recipe with its ingredients, ordered steps and diet tags in one
* go — steps' `order` is derived from their position in `input.steps`,
* ingredients reference existing reference `Ingredient` rows by id (see
* `GET /reference/ingredients`; there's no way to create one here). Its
* regimes aren't stored — they're computed from the ingredients on read
* (issue #131). `authorId`/`authorHouseId` are fixed at creation and never
* change on later edits (see {@link updateRecipe}).
* `GET /reference/ingredients`; there's no way to create one here).
* `authorId`/`authorHouseId` are fixed at creation and never change on
* later edits (see {@link updateRecipe}).
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createRecipe(
input: CreateRecipeInput,
@ -571,7 +516,7 @@ export async function createRecipe(
try {
return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
@ -588,6 +533,7 @@ export async function createRecipe(
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createImportedRecipe(
input: CreateRecipeInput,
@ -598,7 +544,7 @@ export async function createImportedRecipe(
try {
return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
@ -632,11 +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 {
ingredientId: number;
quantity: number | null;
unitId: number | null;
quantity: number;
unitId: number;
}
/**
@ -669,8 +615,8 @@ async function resolveIngredientLines(
if (line.ingredientId !== undefined) {
resolved.push({
ingredientId: line.ingredientId,
quantity: line.quantity ?? null,
unitId: line.unitId ?? null,
quantity: line.quantity,
unitId: line.unitId,
});
continue;
}
@ -686,16 +632,12 @@ async function resolveIngredientLines(
},
select: { id: true },
});
resolved.push({
ingredientId: placeholder.id,
quantity: line.quantity ?? null,
unitId: line.unitId ?? null,
});
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
createdPlaceholders.push({ id: placeholder.id, name });
}
return { resolved, createdPlaceholders };
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
@ -709,9 +651,8 @@ async function createRecipeInternal(
await assertIngredientsExist(
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
);
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
// Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async
// (a trained model, not a pure regex test), so its result has to
@ -761,6 +702,7 @@ async function createRecipeInternal(
techSteps: { create: toStepTechStepCreateInputs(matches) },
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
include: recipeInclude(authorId),
});
@ -778,25 +720,24 @@ async function createRecipeInternal(
});
}
return toRecipeView(created, await loadGlutenFreeDiet());
return toRecipeView(created);
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
* Replaces a recipe's whole content — name/description/picture/visibility
* and the complete ingredient/step lists (not a partial merge: a line
* and the complete ingredient/step/diet lists (not a partial merge: a line
* missing from `input` is removed, same contract as `PATCH
* /profile/allergies`). Regimes aren't part of the payload — they're
* recomputed from the ingredients on the next read (issue #131).
* `authorId`/`authorHouseId` are untouched — editing never transfers
* ownership.
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
* never transfers ownership.
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function updateRecipe(
id: number,
@ -811,9 +752,8 @@ export async function updateRecipe(
.map((i) => i.ingredientId)
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
);
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
// Interactive transaction (not the array form) so any new free-text
@ -824,6 +764,7 @@ export async function updateRecipe(
const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } });
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
viewerId,
@ -858,6 +799,7 @@ export async function updateRecipe(
techSteps: { create: toStepTechStepCreateInputs(matches) },
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
});
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) {
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 } });
} 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 },
});
} 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 },
});
} 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`);
}
} 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;
} 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");
}
} 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) {
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) {
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

@ -77,21 +77,11 @@ export async function getUnits(): Promise<UnitView[]> {
/**
* All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`).
*
* Excludes placeholder rows (`TechStep.isPlaceholder` — the free-text
* techniques users propose when the catalog falls short, issue #130): this
* is the *browsable* catalog, and a placeholder is a per-correction
* stand-in, not a real entry anyone should pick again.
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
*/
export async function getTechSteps(): Promise<TechStepView[]> {
try {
const techSteps = await prisma.techStep.findMany({
where: { isPlaceholder: false },
select: { id: true, key: true },
orderBy: { key: "asc" },
});
return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null }));
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}

View file

@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>;
}>;
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. `unit`/`quantity` are both `null` for a "selon le goût" seasoning line: nothing to sum, surfaced once. */
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
interface RunningTotal {
ingredient: IngredientView;
unit: UnitView | null;
quantity: number | null;
unit: UnitView;
quantity: number;
}
/**
@ -70,31 +70,16 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
for (const item of items) {
const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) {
// A "selon le goût" seasoning line (no quantity/unit) — surfaced once
// per ingredient, never scaled or summed. Its key can't collide with a
// real `(ingredient, unit)` line for the same ingredient.
if (recipeIngredient.quantity === null) {
const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`;
if (!totals.has(seasoningKey)) {
totals.set(seasoningKey, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: null,
quantity: null,
});
}
continue;
}
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key);
if (existing && existing.quantity !== null) {
if (existing) {
existing.quantity += addedQuantity;
} else {
totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
unit: toUnitView(recipeIngredient.unit),
quantity: addedQuantity,
});
}

View file

@ -180,20 +180,9 @@ export async function previewSourceItem(
),
loadIngredientCatalog(adapter.locale),
loadUnitCatalog(adapter.locale),
// Real catalog techniques only — a draft preview is never a saved
// recipe, so it can't carry a user-proposed placeholder technique
// (issue #130); `isPlaceholder`/`displayName` are the neutral values.
prisma.techStep.findMany({
where: { isPlaceholder: false },
select: { id: true, key: true },
}),
prisma.techStep.findMany({ select: { id: true, key: true } }),
]);
const techStepById = new Map(
techStepsByKey.map((techStep) => [
techStep.id,
{ ...techStep, isPlaceholder: false, displayName: null },
]),
);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
const translatedIngredients = translateRecipeIngredients(
parsed.ingredients,

View file

@ -18,13 +18,11 @@ const SOURCE_KEY = "mangerBouger";
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
// plain GET carries the exact same Redux state the client hydrates from as
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
// has everything `list()` needs. Verified live for the *search* path
// (`?diet=ALL&page=N&query=<free text>`): `query` really filters
// server-side (not just a client-side URL update over an already-fetched
// page), and `page`/`hasMorePages` behave as real, consistent pagination.
// The *catalogue browse* path (no `query` term) must be the bare
// `?diet=ALL&page=N` — a trailing empty `&query=` made the deployed server
// repeat page 1 for every page (issue #78); `list()` below now omits it.
// has everything `list()` needs. Verified live: `?query=<free text>` really
// filters server-side (not just a client-side URL update over an
// already-fetched page), and `page`/`hasMorePages` behave as real,
// consistent pagination — the best-behaved of this adapter family's three
// sources on that front.
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
@ -44,7 +42,7 @@ interface MangerBougerListPageData {
initialState?: {
recipes?: {
list?: MangerBougerListEntry[];
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live on the `query`-term path: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). The catalogue-browse path (no `query`) only paginates when the request URL omits `&query=` entirely — see `LIST_URL` and issue #78. */
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
hasMorePages?: boolean;
};
};
@ -286,18 +284,8 @@ export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: strin
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const page = params.cursor ? Number(params.cursor) : 1;
// Only append `&query=` when there's an actual search term. A trailing
// empty `&query=` on the "browse everything" path (filter absent) made
// the *deployed* server serve page 1 for every `page=N` — the
// infinite scroll then stopped after 9 recipes (issue #38's
// "0 new externalIds ⇒ end of list" guard cutting in). The bare
// `?diet=ALL&page=N` catalogue request paginates correctly. The
// sibling adapters (marmiton/quitoque/ohmonbento) already make their
// search param conditional; this one didn't.
const query = (params.query ?? "").trim();
const listUrl = `${LIST_URL}?diet=ALL&page=${page}${
query ? `&query=${encodeURIComponent(query)}` : ""
}`;
const query = params.query ?? "";
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
let response: Response;
try {

View file

@ -49,7 +49,6 @@ export async function resetDatabase() {
"recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house",
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
"contact_message",
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
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);
});
});
/**
* `/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,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

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

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

View file

@ -132,59 +132,6 @@ describe("mangerBougerAdapter", () => {
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
});
it("omits the query param entirely on the catalogue-browse path (no filter) — issue #78", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithListNextData({ list: [], hasMorePages: true }), {
status: 200,
});
}) as typeof fetch;
await mangerBougerAdapter.list({});
expect(requestedUrl).to.equal(
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes?diet=ALL&page=1",
);
expect(requestedUrl).not.to.include("query=");
});
it("treats a blank/whitespace query the same as no filter (no query param)", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
status: 200,
});
}) as typeof fetch;
await mangerBougerAdapter.list({ query: " " });
expect(requestedUrl).not.to.include("query=");
});
it("still paginates the catalogue-browse path (nextCursor set when hasMorePages)", async () => {
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
const result = await mangerBougerAdapter.list({});
expect(result.nextCursor).to.equal("2");
});
it("still appends the query param when a real search term is given", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
status: 200,
});
}) as typeof fetch;
await mangerBougerAdapter.list({ query: "salade" });
expect(requestedUrl).to.include("&query=salade");
});
it("skips a list entry missing a slug or a name", async () => {
stubFetchHtml(
htmlWithListNextData({

View file

@ -312,16 +312,20 @@ describe("Recipes", () => {
});
describe("POST /recipes", () => {
it("creates a recipe with its ingredients and ordered steps, and computes its regimes from the ingredients", async () => {
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const oeuf = await ingredientId("egg");
const piece = await unitId("piece");
const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: "vegetarian" },
});
const res = await agent.post("/recipes").send({
name: "Omelette provençale",
description: "Rapide et savoureuse",
portions: 2,
dietIds: [vegetarien.id],
ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: oeuf, quantity: 3, unitId: piece },
@ -339,84 +343,7 @@ describe("Recipes", () => {
).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
// Regimes computed (issue #131), not tagged: tomato is vegetarian/
// vegan/pescatarian, egg is vegetarian/pescatarian (not vegan) — the
// intersection is vegetarian + pescatarian; neither carries gluten, so
// glutenFree too.
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
"vegetarian",
"pescatarian",
"glutenFree",
]);
});
it("drops a regime the moment one ingredient is incompatible, keeps glutenFree if nothing carries gluten (issue #131)", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const poulet = await ingredientId("chicken");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Poulet aux tomates",
portions: 2,
ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: poulet, quantity: 1, unitId: piece },
],
steps: [{ description: "Cuire le poulet" }],
});
expect(res.status).to.equal(201);
// Chicken is compatible with none of vegetarian/vegan/pescatarian, so
// the recipe qualifies for none of them — but it has no gluten.
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["glutenFree"]);
});
it("is not glutenFree when an ingredient carries the gluten allergen (issue #131)", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const farine = await ingredientId("wheatFlour");
const piece = await unitId("piece");
const gram = await unitId("gram");
const res = await agent.post("/recipes").send({
name: "Galette de tomates",
portions: 2,
ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: farine, quantity: 100, unitId: gram },
],
steps: [{ description: "Mélanger" }],
});
expect(res.status).to.equal(201);
// Both ingredients are vegetarian/vegan/pescatarian, but wheat flour
// carries `gluten` — no glutenFree.
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
"vegetarian",
"vegan",
"pescatarian",
]);
});
it("has no regimes at all when a recipe contains a placeholder ingredient (issue #131)", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Recette incomplète",
portions: 2,
ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece },
{ placeholderName: "Épice mystère", quantity: 1, unitId: piece },
],
steps: [{ description: "Assembler" }],
});
expect(res.status).to.equal(201);
// An off-catalog ingredient can't certify anything.
expect(res.body.diets).to.deep.equal([]);
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
});
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
@ -428,6 +355,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -439,6 +367,7 @@ describe("Recipes", () => {
name: "Foyer",
visibility: "HOUSE",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -457,6 +386,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }],
});
@ -469,12 +399,7 @@ describe("Recipes", () => {
// response itself carries exactly what to highlight, not just the id.
const resStep = res.body.steps[0];
expect(resStep.techSteps).to.have.length(1);
expect(resStep.techSteps[0].techStep).to.deep.equal({
id: simmer,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
const { start, end } = resStep.techSteps[0];
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
});
@ -488,6 +413,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }],
});
@ -522,6 +448,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
@ -540,6 +467,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [
{ description: "Hacher les oignons" },
@ -569,6 +497,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
steps: [{ description: "Cuire au four pendant 30 minutes" }],
@ -590,6 +519,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Poêlée",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// The case that motivated the sequence model: one instruction, two techniques.
steps: [{ description }],
@ -620,6 +550,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -635,6 +566,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
steps: [{ description: "Étape" }],
});
@ -643,12 +575,30 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [],
steps: [{ description: "Étape" }],
});
@ -671,6 +621,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomate, quantity: 1, unitId: piece },
{ ingredientId: tomate, quantity: 2, unitId: piece },
@ -688,6 +639,7 @@ describe("Recipes", () => {
const piece = await unitId("piece");
const basePayload = {
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
};
@ -700,55 +652,6 @@ describe("Recipes", () => {
expect(zero.status).to.equal(400);
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("creates a « selon le goût » seasoning line with no quantity or unit (issue #128)", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const sel = await ingredientId("salt");
const gram = await unitId("gram");
const res = await agent.post("/recipes").send({
name: "Tomates au sel",
portions: 2,
ingredients: [
{ ingredientId: tomate, quantity: 200, unitId: gram },
{ ingredientId: sel }, // selon le goût — no quantity/unit
],
steps: [{ description: "Saler les tomates" }],
});
expect(res.status).to.equal(201);
const seasoning = res.body.ingredients.find(
(line: { ingredient: { key: string } }) => line.ingredient.key === "salt",
);
expect(seasoning.quantity).to.equal(null);
expect(seasoning.unit).to.equal(null);
});
it("rejects an ingredient line with a quantity but no unit (or vice versa) with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const gram = await unitId("gram");
const base = {
name: "Test",
portions: 2,
steps: [{ description: "Étape" }],
};
const quantityOnly = await agent.post("/recipes").send({
...base,
ingredients: [{ ingredientId: tomate, quantity: 100 }],
});
const unitOnly = await agent.post("/recipes").send({
...base,
ingredients: [{ ingredientId: tomate, unitId: gram }],
});
expect(quantityOnly.status).to.equal(400);
expect(quantityOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(unitOnly.status).to.equal(400);
expect(unitOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("GET /recipes/:id", () => {
@ -768,6 +671,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
@ -853,6 +757,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
@ -861,6 +766,7 @@ describe("Recipes", () => {
name: "Salade composée",
portions: 6,
visibility: "PUBLIC",
dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
});
@ -875,36 +781,6 @@ describe("Recipes", () => {
expect(res.body.steps).to.have.length(2);
});
it("carries a « selon le goût » seasoning line through an edit (issue #128)", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const poivre = await ingredientId("blackPepper");
const gram = await unitId("gram");
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade poivrée",
portions: 4,
ingredients: [
{ ingredientId: tomate, quantity: 100, unitId: gram },
{ ingredientId: poivre }, // selon le goût
],
steps: [{ description: "Couper" }, { description: "Poivrer" }],
});
expect(res.status).to.equal(200);
const seasoning = res.body.ingredients.find(
(line: { ingredient: { key: string } }) => line.ingredient.key === "blackPepper",
);
expect(seasoning.quantity).to.equal(null);
expect(seasoning.unit).to.equal(null);
});
it("recomputes each replaced step's technique sequence", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
@ -913,6 +789,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
@ -920,6 +797,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Émincer les tomates" }],
});
@ -942,6 +820,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [
{ description: "Hacher les oignons" }, // chop
@ -970,6 +849,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }, { description: addedDescription }],
});
@ -1010,6 +890,7 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
@ -1026,6 +907,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }],
});
@ -1060,6 +942,7 @@ describe("Recipes", () => {
const res = await agent.patch("/recipes/999999").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -1068,6 +951,30 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
portions: 4,
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { profileId } = await signup();
const { agent: otherAgent } = await signup();
@ -1080,6 +987,7 @@ describe("Recipes", () => {
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});

View file

@ -109,12 +109,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.equal(null);
expect(res.body.correction.correctedTechStep).to.deep.equal({
id: simmerId,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.start).to.equal(6);
expect(res.body.correction.end).to.equal(13);
// The step's real technique sequence reflects the correction right
@ -122,7 +117,7 @@ describe("Recipe tech-step corrections", () => {
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
techStep: { id: simmerId, key: "simmer" },
start: 6,
end: 13,
source: "manual",
@ -133,56 +128,6 @@ describe("Recipe tech-step corrections", () => {
]);
});
it("proposes a placeholder technique via correctedTechStepName when the catalog is missing one (issue #130)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepName: "Flamber au chalumeau" });
expect(res.status).to.equal(201);
// A placeholder `TechStep` was minted and the correction points at it.
expect(res.body.correction.correctedTechStep.isPlaceholder).to.equal(true);
expect(res.body.correction.correctedTechStep.displayName).to.equal("Flamber au chalumeau");
// ...and the step's live sequence carries it straight away.
expect(res.body.techSteps).to.have.length(1);
expect(res.body.techSteps[0].techStep.isPlaceholder).to.equal(true);
expect(res.body.techSteps[0].techStep.displayName).to.equal("Flamber au chalumeau");
expect(res.body.techSteps[0].source).to.equal("manual");
const row = await prisma.techStep.findUniqueOrThrow({
where: { id: res.body.techSteps[0].techStep.id },
});
expect(row.isPlaceholder).to.equal(true);
expect(row.key).to.match(/^placeholder:/);
expect(row.createdById).to.equal(profileId);
expect(row.createdAt).to.be.an.instanceOf(Date);
expect(row.reviewedAt).to.equal(null);
// It never leaks into the browsable catalog.
const reference = await agent.get("/reference/tech-steps");
expect(
reference.body.some(
(t: { displayName?: string }) => t.displayName === "Flamber au chalumeau",
),
).to.equal(false);
});
it("rejects sending both correctedTechStepId and correctedTechStepName with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
correctedTechStepName: "Autre chose",
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
@ -200,23 +145,13 @@ describe("Recipe tech-step corrections", () => {
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.deep.equal({
id: simmerId,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
expect(res.body.correction.correctedTechStep).to.deep.equal({
id: boilId,
key: "boil",
isPlaceholder: false,
displayName: null,
});
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
// Still exactly one entry — the relabel updated the existing row
// rather than adding a second one alongside it.
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null },
techStep: { id: boilId, key: "boil" },
start: 6,
end: 13,
source: "manual",
@ -364,7 +299,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
techStep: { id: simmerId, key: "simmer" },
start: 6,
end: 13,
source: "manual",
@ -600,7 +535,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null },
techStep: { id: preheatId, key: "preheat" },
start: 0,
end: 11,
source: "manual",
@ -786,18 +721,8 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body[0].correctedTechStep).to.deep.equal({
id: boilId,
key: "boil",
isPlaceholder: false,
displayName: null,
});
expect(res.body[1].correctedTechStep).to.deep.equal({
id: simmerId,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
});
it("returns an empty list when nothing has been submitted yet", async () => {

View file

@ -148,30 +148,7 @@ describe("Reference data", () => {
// number — this catalog has grown since (26 -> 74) and will again.
expect(res.body).to.have.length(TECH_STEPS.length);
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
expect(res.body[0]).to.have.keys(["id", "key", "isPlaceholder", "displayName"]);
expect(res.body.every((t: { isPlaceholder: boolean }) => t.isPlaceholder === false)).to.equal(
true,
);
});
it("excludes placeholder techniques users proposed while correcting a step (issue #130)", async () => {
await prisma.techStep.create({
data: {
key: "placeholder:test-1",
isPlaceholder: true,
displayName: "Flamber au chalumeau",
},
});
const res = await request(app).get("/reference/tech-steps");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(TECH_STEPS.length);
expect(
res.body.some(
(t: { displayName: string | null }) => t.displayName === "Flamber au chalumeau",
),
).to.equal(false);
expect(res.body[0]).to.have.keys(["id", "key"]);
});
it("orders techniques alphabetically by key", async () => {

View file

@ -214,76 +214,6 @@ describe("Shopping list", () => {
expect(res.body.items[0].quantity).to.equal(300);
});
it("lists a « selon le goût » seasoning ingredient once, with no quantity, never summed (issue #128)", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const saltId = await ingredientId("salt");
const gramId = await unitId("gram");
// 100g tomato + salt "selon le goût" (quantity/unit null), planned
// twice at 4 portions each (recipe yields 2): tomato scales to 400g,
// salt stays a single unquantified line.
const recipe = await prisma.recipe.create({
data: {
name: "Tomates au sel",
authorId,
portions: 2,
ingredients: {
create: [
{ ingredientId: tomatoId, quantity: 100, unitId: gramId },
{ ingredientId: saltId, quantity: null, unitId: null },
],
},
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 4,
portionsRequested: 4,
},
{
planningId: planning.id,
weekDay: "mercredi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
portionsRequested: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
const salt = res.body.items.filter(
(item: { ingredient: { key: string } }) => item.ingredient.key === "salt",
);
expect(salt).to.have.length(1);
expect(salt[0].quantity).to.equal(null);
expect(salt[0].unit).to.equal(null);
const tomato = res.body.items.find(
(item: { ingredient: { key: string } }) => item.ingredient.key === "tomato",
);
expect(tomato.quantity).to.equal(400);
});
it("sums the same ingredient across two different recipes sharing a unit", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());

View file

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

View file

@ -18,17 +18,6 @@
<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

View file

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Dark-mode overrides for the native colour resources in
../values/colors.xml. Without this file, AppTheme.NoActionBar's DayNight
parent still resolved @color/colorPrimaryDark / @color/colorBackground to
their light values under a dark system setting: the status-bar band and
the pre-WebView window (cold-start flash + splash ground) stayed on the
light "porcelaine" palette while the rest of the phone, and the app's own
WebView content, went dark (issue #104).
Keep the hex values in sync with the dark-mode colour tokens in
apps/web/src/styles/_theme.scss (the prefers-color-scheme: dark block).
Note: XML comments here must not contain a double hyphen, so the theme
tokens are named "color-x", not with their CSS custom-property prefix.
-->
<resources>
<!-- token color-primary (dark). -->
<color name="colorPrimary">#5FAE7E</color>
<!-- Status-bar band + pre-WebView window in dark mode: the dark
color-background, so the band is seamless with the dark app below
it rather than a stripe of light green. windowLightStatusBar stays
false (light icons), correct against this near-black just as it is
against the light-mode dark-basil. -->
<color name="colorPrimaryDark">#14181A</color>
<!-- token color-accent (dark). -->
<color name="colorAccent">#EA7A48</color>
<!-- token color-background (dark), backs the splash and the window
while the WebView is still blank. -->
<color name="colorBackground">#14181A</color>
</resources>

View file

@ -1,47 +0,0 @@
import { Dialog } from "../../src/components/ui/Dialog";
import { PHONE_VIEWPORT } from "../support/viewports";
// First component test for Dialog — until now it was only exercised
// indirectly through RecipePickerDialog's own e2e coverage. Added here
// specifically to prove `cy.setViewport()` (mobile/tablet UI chantier,
// ticket #83) works correctly in component-test mode too, not just e2e —
// and along the way, covers the phone full-screen treatment (ticket #91)
// that had no test of its own yet.
describe("Dialog — phone full-screen treatment", () => {
it("is a centered, rounded panel with room around it at a desktop viewport", () => {
// Component testing's own default viewport (unlike e2e's) is already
// phone-sized — an explicit wide viewport here, not just "whatever the
// default is", is what actually makes this the desktop case.
const desktopWidth = 1280;
cy.viewport(desktopWidth, 800);
cy.mount(
<Dialog title="Titre" onClose={() => {}}>
Contenu
</Dialog>,
);
cy.get(".dialog-panel").should(($el) => {
const style = getComputedStyle($el[0]);
expect(style.borderRadius).to.not.equal("0px");
expect($el[0].getBoundingClientRect().width).to.be.lessThan(desktopWidth);
});
});
it("goes edge-to-edge, with no rounding, at a phone viewport", () => {
cy.setViewport("phone");
cy.mount(
<Dialog title="Titre" onClose={() => {}}>
Contenu
</Dialog>,
);
cy.get(".dialog-panel").should(($el) => {
const style = getComputedStyle($el[0]);
expect(style.borderRadius).to.equal("0px");
const rect = $el[0].getBoundingClientRect();
expect(rect.width).to.be.closeTo(PHONE_VIEWPORT.width, 1);
expect(rect.height).to.be.closeTo(PHONE_VIEWPORT.height, 1);
});
});
});

View file

@ -1,105 +0,0 @@
import { useState } from "react";
import "../../src/i18n/i18n";
import { PasswordInput } from "../../src/components/ui/PasswordInput";
// Mounts PasswordInput in isolation (no form, no router, no backend) — the
// same "generic component test" posture as CheckboxOption.cy.tsx. Needs
// `../../src/i18n/i18n` imported for its side effect: unlike Checkbox/Radio,
// this component calls `useTranslation()` for the eye button's aria-label,
// and with no <I18nextProvider> in the tree it falls back to that default
// instance (see TechStepCorrectionPopover.cy.tsx for the same note).
const SHOW_LABEL = "Afficher le mot de passe";
const HIDE_LABEL = "Masquer le mot de passe";
/**
* Controlled wrapper — PasswordInput holds no text state of its own, so a
* real caller (LoginPage, SignupPage) always owns the value. `onSpy` lets a
* test assert on what each change reports.
*/
function Wrapper({ onSpy }: { onSpy?: (value: string) => void }) {
const [value, setValue] = useState("");
return (
<PasswordInput
id="password"
value={value}
onChange={(next) => {
setValue(next);
onSpy?.(next);
}}
autoComplete="current-password"
/>
);
}
describe("PasswordInput", () => {
it("renders a masked password field by default", () => {
cy.mount(<Wrapper />);
cy.get("input#password").should("have.attr", "type", "password");
});
it("forwards id and autoComplete to the native input", () => {
cy.mount(<Wrapper />);
cy.get("input#password").should("have.attr", "autocomplete", "current-password");
});
it("reveals the text when the eye is clicked, then re-masks it on a second click", () => {
cy.mount(<Wrapper />);
cy.get("input#password").type("hunter2").should("have.attr", "type", "password");
cy.get("button.password-input__toggle").click();
cy.get("input#password").should("have.attr", "type", "text").and("have.value", "hunter2");
cy.get("button.password-input__toggle").click();
cy.get("input#password").should("have.attr", "type", "password").and("have.value", "hunter2");
});
it("labels the toggle with the action the next click performs, and exposes state via aria-pressed", () => {
cy.mount(<Wrapper />);
cy.get("button.password-input__toggle")
.should("have.attr", "aria-label", SHOW_LABEL)
.and("have.attr", "aria-pressed", "false");
cy.get("button.password-input__toggle").click();
cy.get("button.password-input__toggle")
.should("have.attr", "aria-label", HIDE_LABEL)
.and("have.attr", "aria-pressed", "true");
});
it("is a controlled input — it shows the parent's value and reports every change", () => {
const onSpy = cy.stub().as("onSpy");
cy.mount(<Wrapper onSpy={onSpy} />);
cy.get("input#password").type("abc");
cy.get("input#password").should("have.value", "abc");
cy.get("@onSpy").should("have.been.calledWith", "abc");
});
it("does not submit a surrounding form when the eye is clicked — the toggle is a plain button", () => {
const onSubmit = cy.stub().as("onSubmit");
cy.mount(
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<Wrapper />
<button type="submit">Go</button>
</form>,
);
cy.get("button.password-input__toggle").click();
cy.get("@onSubmit").should("not.have.been.called");
});
it("starts hidden again on a fresh mount — visibility is never persisted", () => {
cy.mount(<Wrapper />);
cy.get("button.password-input__toggle").click();
cy.get("input#password").should("have.attr", "type", "text");
// A brand-new mount, as if the visitor navigated back to the page later.
cy.mount(<Wrapper />);
cy.get("input#password").should("have.attr", "type", "password");
});
});

View file

@ -194,41 +194,6 @@ describe("TechStepCorrectionPopover", () => {
cy.get("@onSubmitted").should("have.been.calledOnce");
});
it("proposes a technique the catalog is missing via the 'pas dans la liste' escape hatch (issue #130)", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: {
id: 99,
key: "placeholder:x",
isPlaceholder: true,
displayName: "Flamber au chalumeau",
},
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains("button", "Ma technique n'est pas dans la liste").click();
cy.get(".tech-step-correction-popover__missing-technique input").type("Flamber au chalumeau");
cy.contains(".tech-step-correction-popover__missing-technique button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__chosen-technique", "Flamber au chalumeau");
cy.contains("button", "Valider").should("not.be.disabled").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepName: "Flamber au chalumeau",
});
});
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,

View file

@ -1,98 +0,0 @@
import {
APP_OPEN_TIMEOUT_MS,
attemptOpenNativeApp,
isAndroidPhoneBrowser,
STAY_ON_WEB_PREFERENCE_KEY,
} from "../../src/lib/mobile-app-redirect";
// Pure-ish logic (timers/localStorage/document.hidden, no React component to
// mount) — same posture as version-compare.cy.tsx: reuses the component-test
// runner purely for `expect`/`cy.clock()`, no `cy.mount`. `.cy.tsx` (not
// `.cy.ts`) only because that's what cypress.config.ts's component
// `specPattern` looks for.
function setUserAgent(ua: string) {
Object.defineProperty(window.navigator, "userAgent", { value: ua, configurable: true });
}
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", { value: hidden, configurable: true });
}
describe("isAndroidPhoneBrowser", () => {
afterEach(() => {
setUserAgent(window.navigator.userAgent);
});
it("is true for a real Android phone Chrome UA", () => {
setUserAgent(
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36",
);
expect(isAndroidPhoneBrowser()).to.be.true;
});
it("is false for an Android tablet UA (Android without Mobile)", () => {
setUserAgent(
"Mozilla/5.0 (Linux; Android 14; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
);
expect(isAndroidPhoneBrowser()).to.be.false;
});
it("is false for a desktop UA", () => {
setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
);
expect(isAndroidPhoneBrowser()).to.be.false;
});
});
describe("attemptOpenNativeApp", () => {
beforeEach(() => {
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
setDocumentHidden(false);
});
afterEach(() => {
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
setDocumentHidden(false);
});
it("calls onFallback immediately, with no delay, when the visitor opted to stay on web", () => {
localStorage.setItem(STAY_ON_WEB_PREFERENCE_KEY, "true");
const onFallback = cy.stub().as("onFallback");
attemptOpenNativeApp({ onFallback });
cy.get("@onFallback").should("have.been.calledOnce");
});
it("calls onFallback once the delay elapses if the page never got hidden (app not installed)", () => {
cy.clock();
const onFallback = cy.stub().as("onFallback");
// `openAppUrl` stubbed to a no-op — real navigation to the custom
// scheme is only meaningful in a real browser with the app installed
// or not; here only the timer/visibility logic around it is under
// test (see the option's own doc comment in mobile-app-redirect.ts).
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
cy.get("@onFallback").should("not.have.been.called");
cy.tick(APP_OPEN_TIMEOUT_MS);
cy.get("@onFallback").should("have.been.calledOnce");
});
it("never calls onFallback if the page is hidden before the delay elapses (handoff succeeded)", () => {
cy.clock();
const onFallback = cy.stub().as("onFallback");
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
cy.then(() => {
setDocumentHidden(true);
document.dispatchEvent(new Event("visibilitychange"));
});
cy.tick(APP_OPEN_TIMEOUT_MS);
cy.get("@onFallback").should("not.have.been.called");
});
});

View file

@ -78,45 +78,6 @@ describe("Admin catalog — off-catalog ingredients", () => {
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
});
it("switches to the Techniques kind and lists proposed missing techniques (issue #130)", () => {
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
cy.intercept("GET", "**/admin/api/catalog/tech-step-placeholders*", {
statusCode: 200,
body: [
{
id: 5,
displayName: "Flamber au chalumeau",
createdAt: "2026-09-01T10:00:00.000Z",
reviewedAt: null,
recipeCount: 2,
sampleRecipes: [
{ id: 1, name: "Crème brûlée" },
{ id: 2, name: "Tarte flambée" },
],
},
],
}).as("getTechStepPlaceholders");
cy.intercept("PATCH", "**/admin/api/catalog/tech-step-placeholders/mark-reviewed", {
statusCode: 200,
body: { reviewed: 1 },
}).as("markTechStepReviewed");
cy.visit("/admin/catalogue");
cy.contains(".catalog-kinds button", "Techniques").click();
cy.wait("@getTechStepPlaceholders");
cy.contains(".catalog-card", "Flamber au chalumeau")
.should("contain.text", "2 recette")
.and("contain.text", "Crème brûlée");
cy.contains(".catalog-card", "Flamber au chalumeau")
.contains("button", "Marquer comme traité")
.click();
cy.wait("@markTechStepReviewed")
.its("request.body")
.should("deep.equal", { techStepIds: [5] });
});
it("switches to the reviewed archive tab", () => {
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
// query, and minimatch's `?` is itself a wildcard — a glob can't tell

View file

@ -39,7 +39,7 @@ describe("Admin layout", () => {
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
cy.contains("nav a", "Catalogue").click();
cy.url().should("include", "/admin/catalogue");
cy.contains("h1", "Éléments hors-catalogue").should("be.visible");
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
cy.contains("nav a", "Tableau de bord").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);

View file

@ -1,159 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend (see
// .github/workflows/ci.yml); apps/api's Mocha suite covers
// `GET /admin/api/contact` and the Forgejo routes against a real database.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
// Matches the list endpoint (`/contact`, `/contact?type=…`) but NOT the
// nested `/contact/forgejo/status` or `/contact/:id/forgejo-issue`.
const LIST_URL = /\/admin\/api\/contact(\?|$)/;
function messages() {
return [
{
id: 2,
type: "bug",
message: "Le bouton Envoyer ne réagit pas sur la page planning.",
pageUrl: "/",
userAgent: "Mozilla/5.0 (Linux; Android 14) BatchCookingApp/0.2.1",
appVersion: "0.2.1",
createdAt: "2026-09-07T10:00:00.000Z",
author: { id: 9, firstName: "Alice", lastName: "Martin", email: "alice@example.com" },
forgejoIssueNumber: null,
forgejoIssueUrl: null,
},
{
id: 1,
type: "suggestion",
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
pageUrl: "/liste-de-courses",
userAgent: null,
appVersion: null,
createdAt: "2026-09-06T08:30:00.000Z",
author: null,
forgejoIssueNumber: null,
forgejoIssueUrl: null,
},
];
}
describe("Admin contact messages (/admin/messages)", () => {
beforeEach(() => {
cy.viewport(1400, 1000);
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
// Default: Forgejo integration off — individual tests override.
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
statusCode: 200,
body: { enabled: false },
}).as("forgejoStatus");
});
it("lists the messages read-only: sender or « Compte supprimé », type badge, message", () => {
cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages");
cy.visit("/admin/messages");
cy.wait("@getMessages");
cy.get(".messages-table tbody tr").should("have.length", 2);
cy.contains(".messages-table tr", "page planning").should("contain.text", "alice@example.com");
cy.contains(".messages-table tr", "mode hors-ligne").should("contain.text", "Compte supprimé");
// Integration off ⇒ no create button, and the "not configured" note shows.
cy.get(".messages-table__ticket-button").should("not.exist");
cy.contains(".messages-forgejo-note", "non configurée").should("be.visible");
});
it("filters by type through the query param", () => {
cy.intercept("GET", "**/admin/api/contact", { statusCode: 200, body: messages() }).as("all");
cy.intercept("GET", "**/admin/api/contact?type=suggestion", {
statusCode: 200,
body: [messages()[1]],
}).as("filtered");
cy.visit("/admin/messages");
cy.wait("@all");
cy.get(".messages-filters select").select("Suggestion");
cy.wait("@filtered");
cy.get(".messages-table tbody tr").should("have.length", 1);
cy.contains(".messages-table tr", "mode hors-ligne").should("be.visible");
});
it("creates a Forgejo issue from a message through the pre-filled modal", () => {
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
statusCode: 200,
body: { enabled: true },
}).as("forgejoStatus");
cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages");
cy.intercept("POST", "**/admin/api/contact/2/forgejo-issue", {
statusCode: 200,
body: {
...messages()[0],
forgejoIssueNumber: 101,
forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/101",
},
}).as("createIssue");
cy.visit("/admin/messages");
cy.wait("@getMessages");
cy.contains(".messages-table tr", "page planning")
.find(".messages-table__ticket-button")
.click();
// Modal opens pre-filled from the message.
cy.get("#forgejoIssueTitle").should(
"have.value",
"[Bug] Le bouton Envoyer ne réagit pas sur la page planning.",
);
cy.get("#forgejoIssueBody").should("contain.value", "Signalé par");
cy.contains(".dialog-panel__footer button", "Créer le ticket").click();
cy.wait("@createIssue").its("request.body").should("have.keys", ["title", "body"]);
// Modal closed, the row now shows the link instead of the button.
cy.get("#forgejoIssueTitle").should("not.exist");
cy.contains(".messages-table tr", "page planning")
.find("a")
.should("have.attr", "href", "https://git.example/kyuno/batchCooking/issues/101")
.and("contain.text", "#101");
});
it("shows the existing link and no create button for an already-linked message", () => {
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
statusCode: 200,
body: { enabled: true },
});
const linked = messages().map((m) =>
m.id === 2
? {
...m,
forgejoIssueNumber: 55,
forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/55",
}
: m,
);
cy.intercept("GET", LIST_URL, { statusCode: 200, body: linked }).as("getMessages");
cy.visit("/admin/messages");
cy.wait("@getMessages");
cy.contains(".messages-table tr", "page planning").within(() => {
cy.contains("a", "#55").should(
"have.attr",
"href",
"https://git.example/kyuno/batchCooking/issues/55",
);
cy.get(".messages-table__ticket-button").should("not.exist");
});
// The still-unlinked suggestion row keeps its button.
cy.contains(".messages-table tr", "mode hors-ligne")
.find(".messages-table__ticket-button")
.should("be.visible");
});
});

View file

@ -1,111 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend. Covers the
// `/admin/utilisateurs` page: the account list, the create dialog, the
// one-time temporary-password reveal, and the duplicate-email error.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
function users() {
return [
{
id: 2,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
householdName: "Foyer Martin",
createdAt: "2026-09-09T10:00:00.000Z",
},
{
id: 3,
firstName: "Bob",
lastName: "Durand",
email: "bob@example.com",
householdName: null,
createdAt: "2026-09-08T08:00:00.000Z",
},
];
}
describe("Admin — utilisateurs", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
cy.intercept("GET", "**/admin/api/users", { statusCode: 200, body: users() }).as("list");
});
it("lists every account with its household (or a dash when none)", () => {
cy.visit("/admin/utilisateurs");
cy.wait("@list");
cy.contains(".users-table tr", "alice@example.com").should("contain.text", "Foyer Martin");
cy.contains(".users-table tr", "bob@example.com").should("contain.text", "—");
});
it("creates an account and reveals the one-time temporary password", () => {
cy.intercept("POST", "**/admin/api/users", (req) => {
req.reply({
statusCode: 201,
body: {
user: {
id: 4,
firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email,
householdName: null,
createdAt: "2026-09-10T12:00:00.000Z",
},
temporaryPassword: "Tmp4kR7qWzP2xY9n",
},
});
}).as("create");
cy.visit("/admin/utilisateurs");
cy.wait("@list");
cy.contains("button", "Nouvel utilisateur").click();
cy.get('[data-testid="create-user-form"]').within(() => {
cy.get("#newUserFirstName").type("Chloé");
cy.get("#newUserLastName").type("Petit");
cy.get("#newUserEmail").type("chloe@example.com");
});
cy.contains(".dialog-panel__footer button", "Créer le compte").click();
cy.wait("@create").its("request.body").should("deep.equal", {
firstName: "Chloé",
lastName: "Petit",
email: "chloe@example.com",
});
cy.get('[data-testid="temp-password-reveal"]')
.find("#tempPassword")
.should("have.value", "Tmp4kR7qWzP2xY9n");
});
it("shows the localized error when the email is already in use", () => {
cy.intercept("POST", "**/admin/api/users", {
statusCode: 409,
body: { code: 4001, message: "Email already in use" },
}).as("createDup");
cy.visit("/admin/utilisateurs");
cy.wait("@list");
cy.contains("button", "Nouvel utilisateur").click();
cy.get('[data-testid="create-user-form"]').within(() => {
cy.get("#newUserFirstName").type("Alice");
cy.get("#newUserLastName").type("Martin");
cy.get("#newUserEmail").type("alice@example.com");
});
cy.contains(".dialog-panel__footer button", "Créer le compte").click();
cy.wait("@createDup");
cy.get('[data-testid="create-user-form"]').should("contain.text", "Cet email est déjà utilisé");
// Still on the form — no password reveal.
cy.get('[data-testid="temp-password-reveal"]').should("not.exist");
});
});

View file

@ -1,40 +0,0 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml).
//
// Issue #107 — "Paramètres d'application" is packaged-app only now. Its
// content (installed version + update check) is inert on the web, and the
// one web-relevant control it carried (the "stay on web" redirect toggle)
// moved to `/telecharger` (see download-page.cy.ts). Cypress always runs as
// a plain web browser (`Capacitor.isNativePlatform()` is never true here),
// so the page itself has no seam to reach — same untested-on-purpose
// posture already accepted for `useAppUpdateCheck` / `UpdateBanner`. This
// spec now only covers the web-side gating.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: null,
dietId: null,
};
describe("App settings — hidden on the web (#107)", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
});
it("is absent from the Paramètres menu", () => {
cy.visit("/");
cy.contains("button", "Paramètres").click();
cy.contains("a", "Paramètres d'application").should("not.exist");
});
it("redirects /parametres/application to /parametres/compte instead of an orphaned page", () => {
cy.visit("/parametres/application");
cy.url().should("include", "/parametres/compte");
cy.contains("h1", "Paramètres d'application").should("not.exist");
});
});

View file

@ -1,50 +0,0 @@
// Mocks the API via cy.intercept — this job runs no live backend (see
// .github/workflows/ci.yml). The signup/login *journeys* live in
// auth.feature; this plain layout spec covers only the show/hide password
// toggle (PasswordInput) wired into both auth pages — "a section of an
// existing page" in specs/dev-conventions.md's test taxonomy, so a plain
// `.cy.ts`, not a Gherkin scenario.
describe("Auth forms — password show/hide toggle", () => {
beforeEach(() => {
// Both pages sit behind RedirectIfAuthenticated; a 401 on the session
// check keeps the visitor logged out so the form actually renders.
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
});
for (const { label, path } of [
{ label: "login", path: "/login" },
{ label: "signup", path: "/signup" },
]) {
it(`reveals then re-masks the password field on ${label}`, () => {
cy.visit(path);
// Starts masked, with the toggle offering to reveal it.
cy.get("#password").should("have.attr", "type", "password");
cy.get('[aria-label="Afficher le mot de passe"]')
.should("be.visible")
.and("have.attr", "aria-pressed", "false");
// Reveal.
cy.get('[aria-label="Afficher le mot de passe"]').click();
cy.get("#password").should("have.attr", "type", "text");
cy.get('[aria-label="Masquer le mot de passe"]').should("have.attr", "aria-pressed", "true");
// Re-mask.
cy.get('[aria-label="Masquer le mot de passe"]').click();
cy.get("#password").should("have.attr", "type", "password");
});
}
it("does not submit the login form when the eye is clicked", () => {
cy.intercept("POST", "**/auth/login", cy.spy().as("loginRequest"));
cy.visit("/login");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.get('[aria-label="Afficher le mot de passe"]').click();
cy.get("#password").should("have.attr", "type", "text");
cy.get("@loginRequest").should("not.have.been.called");
});
});

View file

@ -51,7 +51,7 @@ Feature: Signup and login
Then the login request should have been made
And I should see "Email ou mot de passe incorrect"
Scenario: Logging out returns to the landing page
Scenario: Logging out returns to the login page
Given I am signed in as "Alice" "Martin"
And my household id is 1
And the planning request returns nothing
@ -60,4 +60,4 @@ Feature: Signup and login
And I open the account menu
And I click the button "Se déconnecter"
Then the logout request should have been made
And the URL should be the home page
And the URL should include "/login"

View file

@ -1,58 +0,0 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
// `POST /contact` behavior against a real database. The Gherkin journey
// (fill + send + confirmation) lives in contact.feature — this file only
// covers the page's initial render and its guards.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: null,
dietId: null,
};
describe("Contact page (/parametres/contact)", () => {
it("redirects to /login when signed out (RequireAuth)", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.visit("/parametres/contact");
cy.url().should("include", "/login");
});
describe("signed in", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("renders the form: a type select defaulting to Bug, a message box, a submit button", () => {
cy.visit("/parametres/contact");
cy.contains("h1", "Nous contacter").should("be.visible");
cy.get("#contactType option").should("have.length", 3);
cy.get("#contactType").should("have.value", "bug");
cy.get("#contactMessage").should("be.visible");
cy.contains("button", "Envoyer").should("not.be.disabled");
});
it("shows a translated error label, never the raw server message, when the submit fails", () => {
cy.intercept("POST", "**/contact", {
statusCode: 500,
body: { code: 5000, message: "raw internal boom" },
}).as("submitContact");
cy.visit("/parametres/contact");
cy.get("#contactMessage").type(
"Ceci est un message de test suffisamment long pour être valide.",
);
cy.contains("button", "Envoyer").click();
cy.wait("@submitContact");
cy.get(".field-error").should("be.visible");
cy.contains("raw internal boom").should("not.exist");
});
});
});

View file

@ -1,25 +0,0 @@
Feature: Contact form
As a signed-in user
I want to report a bug or send a suggestion from within the app
So that the team hears about it with the technical context attached
Background:
Given I am signed in as "Alice" "Martin"
Scenario: Sending a bug report shows a confirmation and forwards the page context
Given submitting a contact message will succeed
When I visit "/parametres/contact"
And I select "Bug" from the "contactType" field
And I fill in the "contactMessage" field with "Le bouton Envoyer ne réagit pas sur la page planning."
And I click the button "Envoyer"
Then the contact request should have been sent with type "bug"
And the contact request body should carry the current page URL and app version
And I should see "Merci ! Votre message a bien été envoyé."
Scenario: A too-short message is rejected on the client, without a request
Given submitting a contact message will succeed
When I visit "/parametres/contact"
And I fill in the "contactMessage" field with "court"
And I click the button "Envoyer"
Then I should see "Le message doit faire au moins 10 caractères"
And no contact request should have been sent

View file

@ -1,31 +0,0 @@
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor";
// Contact-form-specific steps (issue #80). Signing in and the generic
// select/type/click/see steps come from support/step_definitions/.
// Mocks the API via cy.intercept — this job runs no live backend (see
// .github/workflows/ci.yml); apps/api's Mocha suite covers `POST /contact`
// against a real database.
Given("submitting a contact message will succeed", () => {
// 204, matching the real route — the client's `_request` treats only 204
// as a bodyless success (a 201 with no body makes its `.json()` throw).
cy.intercept("POST", "**/contact", { statusCode: 204 }).as("submitContact");
});
Then("the contact request should have been sent with type {string}", (type: string) => {
cy.wait("@submitContact").its("request.body.type").should("equal", type);
});
Then("the contact request body should carry the current page URL and app version", () => {
cy.get("@submitContact.all").should("have.length", 1);
cy.get("@submitContact")
.its("request.body")
.should((body: Record<string, unknown>) => {
expect(body.pageUrl).to.equal("/parametres/contact");
expect(body.appVersion).to.be.a("string").and.not.equal("");
});
});
Then("no contact request should have been sent", () => {
cy.get("@submitContact.all").should("have.length", 0);
});

View file

@ -3,8 +3,6 @@
// `GET /cooking-session` behavior (including the optimizer) against a real
// database.
import { PHONE_VIEWPORT } from "../support/viewports";
const authenticatedProfile = {
id: 1,
firstName: "Alice",
@ -423,72 +421,3 @@ describe("Cooking session page", () => {
cy.contains("Impossible de charger").should("be.visible");
});
});
// Ticket #89 — this page's first dedicated responsive pass (it previously
// had no `@media` rule at all, see cooking-session-page.scss's own header
// comment). No layout is restructured on phone (the flex-column shell and
// the two horizontally-scrolling rows already degrade safely) — this covers
// the two things that actually needed fixing: comfortable touch targets on
// the screen's own controls, and a real flex overflow bug in the Recettes
// tab (a long recipe name had no upper bound and could crowd its portions
// badge/chevron out of the row).
describe("Cooking session page (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
cy.clock(TODAY, ["Date"]);
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("gives the task cards and stepper nodes a taller, more tappable padding than on desktop", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.get(".cooking-task")
.first()
.should(($el) => {
// --space-md (16px) on phone vs. the shared checkbox-label rule's
// own --space-xs (4px) app-wide default — see cooking-session-page
// .scss's phone block.
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
});
cy.get(".cooking-stepper__node")
.first()
.should(($el) => {
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
});
// The document itself never scrolls sideways, same global rule every
// other page is held to.
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1);
});
it("truncates a long recipe name in the Recettes tab instead of pushing its portions badge out of view", () => {
const longName =
"Un très long nom de recette qui ne devrait surtout pas pousser le badge de portions hors de l'écran";
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 200,
body: {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [{ recipeId: 1, name: longName, portions: 4 }],
phases: [],
},
});
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
cy.get(".cooking-recipes__name").should(($el) => {
const s = getComputedStyle($el[0]);
expect(s.textOverflow).to.equal("ellipsis");
expect(s.whiteSpace).to.equal("nowrap");
// The name's own box is clipped narrower than its full text — proof
// the truncation is actually engaged, not just declared.
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
});
// ...and the badge survives, still on screen, not clipped away by the
// card's own `overflow: hidden`.
cy.contains(".cooking-recipes__portions", "4 portions").should("be.visible");
});
});

View file

@ -1,51 +0,0 @@
// Pure layout spec, no Cucumber — a single static page with no user
// journey of its own (see layout.cy.ts's identical rationale for this
// 3-way test split). No `cy.intercept("**/auth/me", ...)` needed: this
// route renders outside RequireAuth entirely (see App.tsx's own doc
// comment on why), so it never calls that endpoint.
describe("DownloadPage — the mobile-app-redirect fallback", () => {
it("renders the APK download link, a retry button, and a way back home", () => {
cy.visit("/telecharger");
cy.contains("h1", "Installer l'application Android").should("be.visible");
// API_BASE_URL is empty locally (relative-URL, same-origin default —
// see client.ts), so the real target collapses to this relative path.
cy.contains("a", "Télécharger l'APK").should("have.attr", "href", "/mobile/download");
cy.contains("button", "Réessayer d'ouvrir l'application").should("be.visible");
cy.contains("a", "Retour à l'accueil").should("have.attr", "href", "/");
});
it("is reachable without being logged in — no bounce to /login", () => {
cy.visit("/telecharger");
cy.url().should("include", "/telecharger");
});
// Issue #107 — the "toujours rester sur le site web" preference moved
// here from "Paramètres d'application" (packaged-app only now). Written to
// `localStorage` on the public web origin, which is where
// `attemptOpenNativeApp` reads it.
it("defaults 'stay on web' to unchecked, and persists a toggle across a reload", () => {
cy.visit("/telecharger");
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("not.be.checked");
cy.contains("label", "Toujours rester sur le site web").click();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
cy.window().then((win) => {
expect(win.localStorage.getItem("batchcooking:stayOnWeb")).to.equal("true");
});
cy.reload();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
});
});

View file

@ -22,17 +22,11 @@ Feature: Public landing page at the root
When I click the link "Créer un compte"
Then the URL should include "/signup"
Scenario: The landing page offers the Android app download, on desktop
Scenario: The landing page offers the Android app download
Given I am not signed in
When I visit "/"
Then I should see the Android download link
Scenario: A mobile visitor is offered to open the app first, not just a download link
Given I am not signed in
When I visit "/" as an Android phone browser
Then I should see the button to open the app, with sign-up demoted to secondary
And the Android download link should not be shown
Scenario: Another guarded route still redirects a logged-out visitor to sign in
Given I am not signed in
When I visit "/recettes"

View file

@ -1,4 +1,4 @@
import { Then, When } from "@badeball/cypress-cucumber-preprocessor";
import { Then } from "@badeball/cypress-cucumber-preprocessor";
// Landing-page-specific steps. Everything else this feature needs
// (signing in / not, visiting a path, URL and heading assertions) comes
@ -15,50 +15,15 @@ Then("I should see the landing call to action", () => {
.and("have.attr", "href", "/login");
});
// Issue #85: desktop only now (see LandingPage.tsx) — routed through
// `/telecharger` (DownloadPage.tsx, issue #84's fallback page) rather than
// linking the Android build's actual `/mobile/download` redirect directly,
// so "get the app" has exactly one canonical destination app-wide.
// The Android build is distributed as a Forgejo release asset with a stable
// filename (issue #64 builds/attaches it). We only assert the link is there
// and points at the right URL — the file itself is external to the suite.
Then("I should see the Android download link", () => {
cy.get(".landing__hero .landing__mobile-link")
.should("be.visible")
.and("have.attr", "href", "/telecharger");
});
// Spoofs a real Android phone Chrome UA via `onBeforeLoad` — the one place
// `isAndroidPhoneBrowser()` (`lib/mobile-app-redirect.ts`) actually reads
// from, so this is the only way to reach the landing page's mobile-only
// branch from a real page visit (Cypress's own Electron browser always
// reports a desktop UA otherwise). Same literal UA string already used by
// `mobile-app-redirect.cy.tsx`'s own `isAndroidPhoneBrowser` unit test.
When("I visit {string} as an Android phone browser", (path: string) => {
cy.visit(path, {
onBeforeLoad(win) {
Object.defineProperty(win.navigator, "userAgent", {
value:
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36",
configurable: true,
});
},
});
});
Then("I should see the button to open the app, with sign-up demoted to secondary", () => {
// Deliberately never clicked here: its `onClick` calls
// `attemptOpenNativeApp()` with the real (production) `openAppUrl`, a
// genuine `window.location.href` to an unrecognized custom scheme — that
// hangs the Cypress/Electron runner indefinitely rather than safely
// no-op'ing (see dev-environment-gotchas.md). The function itself is
// already fully unit-tested with an injected stub in
// mobile-app-redirect.cy.tsx; this only covers LandingPage's own
// conditional rendering.
cy.get(".landing__hero .landing__cta--primary")
.should("be.visible")
.and("contain.text", "Ouvrir dans l'application");
cy.contains(".landing__hero .landing__cta--secondary", "Créer un compte").should("be.visible");
cy.contains(".landing__hero .landing__cta--secondary", "Se connecter").should("be.visible");
});
Then("the Android download link should not be shown", () => {
cy.get(".landing__hero .landing__mobile-link").should("not.exist");
.and(
"have.attr",
"href",
"https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest/download/batchcooking.apk",
);
});

View file

@ -11,8 +11,6 @@
// width (some stuck to the left edge with a lopsided gap, others correctly
// full-bleed).
import { PHONE_VIEWPORT } from "../support/viewports";
const authenticatedProfile = {
id: 1,
firstName: "Alice",
@ -174,86 +172,50 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
}
});
describe("Responsive breakpoint — the sidebar becomes a bottom tab bar under 640px", () => {
// Regression coverage for the mobile/tablet UI chantier's foundation
// ticket: below this point the desktop `<aside>` sidebar isn't reflowed
// by CSS anymore, it's replaced outright by a fixed bottom nav (a
// genuinely different component tree — see AppLayout.tsx's own doc
// comment on why). This block used to test the old CSS-squash treatment
// (sidebar → horizontal top bar); that treatment no longer exists.
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {
beforeEach(() => {
interceptAuth();
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
});
it("renders the bottom nav instead of the sidebar, icon-only, with a working Plus tab", () => {
cy.setViewport("phone");
it("switches to a full-width horizontal bar, hides the collapse toggle and version tag, and keeps every nav link legible", () => {
cy.viewport(375, 812);
cy.visit("/");
// The desktop sidebar isn't just hidden — it's not in the DOM at all.
cy.get(".app-sidebar").should("not.exist");
cy.get(".app-bottom-nav").should(($nav) => {
const rect = $nav[0].getBoundingClientRect();
expect(rect.width).to.be.closeTo(PHONE_VIEWPORT.width, 1);
// A short bar flush against the bottom edge, not a tall rail.
cy.get(".app-sidebar").should(($sidebar) => {
const rect = $sidebar[0].getBoundingClientRect();
expect(rect.width).to.be.closeTo(375, 1);
// A short horizontal bar, not the tall vertical rail — well under the
// desktop rail's own content-driven height.
expect(rect.height).to.be.lessThan(120);
expect(rect.bottom).to.be.closeTo(PHONE_VIEWPORT.height, 1);
expect(getComputedStyle($nav[0]).position).to.equal("fixed");
});
// Issue #105 — icon-only bar: each tab's `span.label` stays in the DOM
// (accessible name for the link) but is visually hidden, only the icon
// shows. Same treatment as the desktop collapsed rail (sidebar.cy.ts).
cy.contains(".app-bottom-nav a", "Planning")
.should("have.attr", "href", "/")
.within(() => {
cy.get("span.label").should("exist").and("not.be.visible");
cy.get("svg").should("be.visible");
// Nothing to collapse into on a bar with no rail to shrink.
cy.get(".app-sidebar__collapse-toggle").should("not.be.visible");
cy.get(".app-sidebar__version").should("not.be.visible");
// The main nav must stay fully legible and tappable — icon *and* label
// — falling back to horizontal scroll instead of ever being crushed
// down to unlabeled slivers (see AppLayout.scss's own comment on this
// exact failure mode).
cy.get(".app-sidebar__nav").should(($nav) => {
expect(getComputedStyle($nav[0]).overflowX).to.equal("auto");
});
cy.contains(".app-bottom-nav a", "Recettes").should("have.attr", "href", "/recettes");
cy.contains(".app-bottom-nav a", "Liste de courses").should(
"have.attr",
"href",
"/liste-de-courses",
);
// The "Plus" tab opens a bottom sheet with Paramètres + Compte, closed
// by default.
cy.get(".bottom-sheet").should("not.exist");
cy.contains(".app-bottom-nav button", "Plus").click();
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Bonjour Alice").should("be.visible");
cy.contains(".bottom-sheet a", "Compte").should("have.attr", "href", "/parametres/compte");
cy.contains(".bottom-sheet a", "Préférences alimentaires").should(
"have.attr",
"href",
"/parametres/preferences",
);
cy.contains(".bottom-sheet button", "Se déconnecter").should("be.visible");
// The close button calls the native `<dialog>`'s own `.close()`, whose
// `close` event `useNativeDialogBehavior` forwards back to
// `MobileBottomNav`'s own state — same wiring Escape/tap-outside use,
// exercised here since it doesn't depend on this Cypress/Electron
// environment faithfully replicating the browser's own native
// Escape-closes-a-modal-dialog behavior (a browser built-in this app
// doesn't implement itself — see Dialog.tsx's own doc comment).
cy.get(".bottom-sheet__close").click();
cy.get(".bottom-sheet").should("not.exist");
cy.contains(".app-sidebar__nav a", "Planning").find("span.label").should("be.visible");
cy.contains(".app-sidebar__nav a", "Planning").should(($link) => {
expect($link[0].getBoundingClientRect().width).to.be.greaterThan(40);
});
it("keeps the desktop sidebar (not the bottom nav) at a tablet viewport", () => {
cy.setViewport("tablet");
cy.visit("/");
cy.get(".app-sidebar").should("exist");
cy.get(".app-bottom-nav").should("not.exist");
// Contrast: the settings/account toggles' labels *do* collapse to
// icon-only here — there's no room for both a full nav row and full
// text labels on every piece of chrome at once.
cy.contains("button", "Paramètres").find("span.label").should("not.be.visible");
});
});
describe("Responsive breakpoint — page content under 640px (issue #39)", () => {
const PHONE: [number, number] = [375, 812];
beforeEach(() => {
interceptAuth();
});
@ -270,7 +232,7 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
expect(s.paddingLeft).to.equal("32px");
});
cy.setViewport("phone");
cy.viewport(...PHONE);
cy.visit("/");
cy.get(".app-content").should(($el) => {
const s = getComputedStyle($el[0]);
@ -280,20 +242,7 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
});
});
// Until issue #88, a phone viewport just let the 7-column grid scroll
// horizontally inside `.planning-grid-wrapper` (a shrunk desktop table,
// not a real mobile layout). #88 replaces it outright with
// `PlanningDayView` below the phone breakpoint — this test now covers
// *that* switch; the day view's own day-by-day content is
// PlanningPage.tsx's own spec (planning-page.cy.ts).
it("shows the one-day mobile view instead of the horizontally-scrolling grid, on a phone", () => {
// `PlanningDayView` opens on *today's* weekday within the shown week
// (`defaultDayIndexForWeek`, derived from `Date`). Freeze "today" to a
// Monday so it opens on "lundi" — where the mocked recipe below is —
// instead of drifting with the real date (this test used to pass only
// when CI happened to run on a Monday, UTC). `["Date"]` only, so
// timers/debounces still run.
cy.clock(new Date("2026-08-17T09:00:00.000Z"), ["Date"]);
it("keeps the 7-day planning grid on its own horizontal scroll rather than overflowing the page", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
@ -315,25 +264,25 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
},
});
cy.setViewport("phone");
cy.viewport(...PHONE);
cy.visit("/");
// The desktop grid is gone entirely, not just visually — the day view
// takes over the whole page.
cy.get(".planning-grid-wrapper").should("not.exist");
cy.get(".planning-day-view").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
// The grid wrapper is the scroll container, and its content is genuinely
// wider than the phone viewport (7 columns never crushed).
cy.get(".planning-grid-wrapper").should(($el) => {
expect(getComputedStyle($el[0]).overflowX).to.be.oneOf(["auto", "scroll"]);
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
});
// The document itself never scrolls sideways (global rule against a
// horizontally-scrolling body) — still true for the day view's own
// stacked-card layout, same as every other page.
// ...and that overflow stays *inside* the wrapper — the document itself
// never scrolls sideways (global rule against a horizontally-scrolling
// body).
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1);
.should("be.lte", 375 + 1);
// The per-recipe remove "✕" is hover-gated on desktop; on touch it must
// be permanently visible or a planned recipe can't be removed at all —
// same shared `.recipe-chip__remove` class either layout renders.
// be permanently visible or a planned recipe can't be removed at all.
cy.get(".recipe-chip__remove")
.first()
.should(($btn) => {

View file

@ -105,23 +105,4 @@ describe("Leftovers basket", () => {
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
cy.get(".leftovers-basket__count").should("not.exist");
});
it("opens inside a bottom sheet on a phone instead of the anchored popover", () => {
// Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) —
// that command doesn't exist on this ticket's own base yet; matches its
// PHONE_VIEWPORT value on purpose, worth switching to the real import
// once both have landed on `main`.
cy.viewport(390, 844);
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers });
cy.visit("/");
cy.get(".leftovers-basket__toggle").click();
cy.get(".leftovers-basket__popover").should("not.exist");
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Restes disponibles").should("be.visible");
cy.contains(".bottom-sheet .leftovers-basket__line", "Lasagnes").should("be.visible");
cy.get(".bottom-sheet__close").click();
cy.get(".bottom-sheet").should("not.exist");
});
});

View file

@ -1,54 +1,41 @@
Feature: Onboarding wizard
As a newly signed-up user
I want to set my preferences (diet + allergens), then my household
I want to set my diet, household, and allergens
So that my profile is ready before I start planning meals
# Order since #129: compte -> régime -> allergènes -> foyer -> sources,
# and the sources step only when the foyer step *created* a household
# (joining one or skipping ends the wizard at the home page).
Background:
Given the planning request returns nothing
Scenario: Walks through every step, creating a household (which surfaces the sources step), and lands on the home page
Scenario: Walks through the wizard, creating a household on the way (which surfaces the sources step), and lands on the home page
Given the diets reference list has options
And selecting the diet will succeed
And the household request returns no household
And creating a household will succeed
And the sources reference list is empty
And the allergies reference list has options
And updating allergies will succeed
And creating a household will succeed
And the sources reference list has options
And the household's enabled sources are empty
And saving the source selection will succeed
And I have signed up
Then the URL should include "/onboarding/regime"
And I should see "Étape 1 sur 3"
When I select "Végétarien" from the "diet" field
And I click the button "Continuer"
Then the diet update request should have been made with diet id 2
And the URL should include "/onboarding/allergenes"
And I should see "Étape 2 sur 3"
And I should see the section "Allergies"
And I should see the section "Intolérances"
When I check the checkbox "Arachides"
And I click the button "Continuer"
Then the allergies update request should have been made with allergy id 1
And the URL should include "/onboarding/foyer"
And I should see "Étape 3 sur 3"
And I should see "Étape 2 sur 3"
When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice"
And the URL should include "/onboarding/sources"
And the URL should include "/onboarding/allergenes"
And I should see "Étape 4 sur 4"
And I should see the section "Sources disponibles"
And I should see "TheMealDB"
When I check the checkbox "TheMealDB"
And I should see the section "Allergies"
And I should see the section "Intolérances"
When I check the checkbox "Arachides"
And I click the button "Terminer"
Then the source selection update request should have been made with source id 1
Then the allergies update request should have been made with allergy id 1
And the URL should be the home page
And I should see the heading "Planning de la semaine"
Scenario: Lets the preferences steps be skipped without changing anything, then skips the household step too
Scenario: Lets the regime and allergens steps be skipped without changing anything
Given the diets reference list is empty
And selecting the diet will succeed
And the household request returns no household
@ -58,32 +45,39 @@ Feature: Onboarding wizard
Then the URL should include "/onboarding/regime"
When I click the button "Continuer"
Then the diet update request should have been made with no diet id
And the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the allergies update request should have been made with no allergy ids
And the URL should include "/onboarding/foyer"
When I click the button "Passer cette étape"
Then the URL should be the home page
Then the URL should include "/onboarding/allergenes"
When I click the button "Terminer"
Then the allergies update request should have been made with no allergy ids
And the URL should be the home page
Scenario: Creating a household with no sources implemented ends the wizard through the silently-skipped sources step
Scenario: Shows and saves the sources step instead of skipping it, when sources are available
Given the diets reference list is empty
And selecting the diet will succeed
And the household request returns no household
And creating a household will succeed
And the sources reference list is empty
And the sources reference list has options
And the household's enabled sources are empty
And saving the source selection will succeed
And the allergies reference list is empty
And updating allergies will succeed
And I have signed up
When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer"
When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice"
And the URL should be the home page
And the URL should include "/onboarding/sources"
And I should see "Étape 3 sur 4"
And I should see the section "Sources disponibles"
And I should see "TheMealDB"
When I check the checkbox "TheMealDB"
And I click the button "Continuer"
Then the source selection update request should have been made with source id 1
And the URL should include "/onboarding/allergenes"
Scenario: Joining an existing household skips the sources step entirely and ends the wizard
Scenario: Lets the household step be completed by joining an existing household instead of creating one
Given the diets reference list is empty
And selecting the diet will succeed
And the household request returns no household
@ -93,10 +87,8 @@ Feature: Onboarding wizard
And updating allergies will succeed
And I have signed up
When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer"
When I fill in the "inviteCode" field with "abcd2345"
And I click the button "Rejoindre"
Then the household join request should have been made with invite code "ABCD2345"
And the URL should be the home page
And the URL should include "/onboarding/allergenes"

View file

@ -182,49 +182,6 @@ describe("Planning grid", () => {
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
});
// Issue #110 — a slot with many recipes used to stretch its `<td>`, and
// with it the whole row and the table, past `.planning-grid-wrapper`
// into a vertical scroll (the other cells of that row just gaining dead
// space). The chip list now scrolls inside its own cell instead.
it("keeps a heavy slot's recipes scrolling inside the cell, not stretching the grid", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: Array.from({ length: 14 }, (_, i) => ({
id: i + 1,
weekDay: "lundi",
meal: "dejeuner",
portions: 2,
recipe: { id: i + 1, name: `Recette ${i + 1}` },
})),
},
});
cy.visit("/");
cy.get(".recipe-chip").should("have.length", 14);
// The busy cell's own recipe list is now the scroller: it's capped and
// scrolls its own overflow instead of stretching the `<td>` (and with
// it the whole row, then the table past `.planning-grid-wrapper` into a
// tall vertical scroll).
cy.get(".meal-cell__recipes").should(($lists) => {
const list = [...$lists].find(
(el) => el.querySelectorAll(".recipe-chip").length === 14,
) as HTMLElement;
expect(list, "the 14-recipe cell's list").to.exist;
expect(getComputedStyle(list).overflowY).to.be.oneOf(["auto", "scroll"]);
expect(list.scrollHeight).to.be.greaterThan(list.clientHeight);
expect(list.clientHeight).to.be.lessThan(200);
});
cy.get(".planning-grid-wrapper").should(($w) => {
expect($w[0].scrollHeight).to.be.lessThan($w[0].clientHeight * 1.25);
});
});
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
@ -240,179 +197,4 @@ describe("Planning grid", () => {
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".calendar-popover").should("not.exist");
});
it("opens the calendar inside a bottom sheet on a phone instead of the anchored popover", () => {
// Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) —
// that command doesn't exist on this ticket's own base yet; matches its
// PHONE_VIEWPORT value on purpose, worth switching to the real import
// once both have landed on `main`.
cy.viewport(390, 844);
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
cy.contains("button", "Semaine du").click();
cy.get(".calendar-popover").should("not.exist");
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Choisir une semaine").should("be.visible");
cy.get(".bottom-sheet .calendar-grid__day").contains(/^25$/).click();
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".bottom-sheet").should("not.exist");
});
});
// Ticket #88 — the 7-column grid gives way entirely to `PlanningDayView` on
// a phone (not the same table, scrolled): a switcher plus one day's meals
// stacked as cards. `.recipe-chip`/`.add-recipe-btn` are the same elements
// either layout renders (see `mealCellBody`, shared by `MealCell` and
// `MealCellRow` in PlanningPage.tsx), so this only covers what's actually
// different here — day selection, its clamped edges, and that the day
// view's own "+" still opens the real recipe picker.
describe("Planning day view (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
freezeToday();
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("defaults to today, shows only that day's recipes, and clamps at Monday", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
{
id: 2,
weekDay: "mardi",
meal: "dejeuner",
portions: 2,
recipe: { id: 2, name: "Curry de lentilles" },
},
],
},
}).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
// The table is gone entirely on phone, not just visually hidden.
cy.get("table.planning-grid").should("not.exist");
cy.get(".planning-day-view").should("be.visible");
// Today (17 août, Lundi) is the default day — only its own recipe shows.
cy.contains(".planning-day-view__label", "Lundi").should("be.visible");
cy.contains(".planning-day-view__label", "17").should("be.visible");
cy.contains(".planning-day-view__label .today-badge", "Aujourd'hui").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("not.exist");
// Clamped, not wrapping: Monday is the week's first day.
cy.get(".planning-day-view__arrow").first().should("be.disabled");
cy.get(".planning-day-view__arrow").last().click();
cy.contains(".planning-day-view__label", "Mardi").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("not.exist");
cy.get(".planning-day-view__arrow").first().should("not.be.disabled");
});
it("clamps at Sunday going forward, and its own '+' opens the recipe picker", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
for (let i = 0; i < 6; i++) {
cy.get(".planning-day-view__arrow").last().click();
}
cy.contains(".planning-day-view__label", "Dimanche").should("be.visible");
cy.get(".planning-day-view__arrow").last().should("be.disabled");
cy.get(".add-recipe-btn").first().click();
cy.get(".recipe-picker-dialog").should("be.visible");
cy.contains(".recipe-picker-dialog", "Choisir une recette").should("be.visible");
});
});
// Issue #111 — on a phone the recipe picker is a full-height flex column;
// a regression in the mobile full-screen-dialog CSS let its tall filter
// block (search + ingredient/diet pickers, rendered only for the four
// non-source tabs) consume the column and collapse the recipe table below
// it into a zero-height scrollport. The table was in the DOM the whole
// time, so Favoris / Perso / Foyer / Publique all just looked empty.
describe("Recipe picker dialog (phone)", () => {
// Full `RecipeSummaryView` shape — `RecipeTable` reads `allergens`/`diets`
// (`.length`/`.map`) on every row, so `{ id, name }` alone crashes it.
function summary(id: number, name: string) {
return {
id,
name,
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL" as const,
allergens: [],
diets: [],
isFavorite: false,
};
}
const recipesByTab: Record<string, ReturnType<typeof summary>[]> = {
favoris: [summary(1, "Ratatouille")],
perso: [summary(2, "Omelette")],
foyer: [summary(3, "Gratin dauphinois")],
publique: [summary(4, "Soupe de potiron")],
};
const tabLabel: Record<string, string> = {
favoris: "Favoris",
perso: "Perso",
foyer: "Foyer",
publique: "Publique",
};
beforeEach(() => {
cy.setViewport("phone");
freezeToday();
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
// The dialog's own reference-data / source fetches — all empty, so the
// filter block is only as tall as its always-present controls, which is
// already enough to trigger the collapse this covers.
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [] });
cy.intercept("GET", /\/recipes\?/, (req) => {
const tab = new URL(req.url).searchParams.get("tab") ?? "favoris";
req.reply({ statusCode: 200, body: recipesByTab[tab] ?? [] });
});
});
it("shows the recipe table on every non-source tab, not a collapsed empty area", () => {
cy.visit("/");
cy.get(".add-recipe-btn").first().click();
cy.get(".recipe-picker-dialog").should("be.visible");
for (const tab of Object.keys(recipesByTab)) {
cy.contains(".recipe-tabs__tab", tabLabel[tab]).click();
// `be.visible` fails on a zero-height / clipped element — the exact
// shape of the regression.
cy.get(".recipe-picker-dialog .recipe-table").should("be.visible");
cy.get(".recipe-picker-dialog .recipe-table tbody tr").should("have.length.greaterThan", 0);
cy.contains(".recipe-picker-dialog .recipe-table__name", recipesByTab[tab][0].name).should(
"be.visible",
);
}
});
});

View file

@ -68,47 +68,6 @@ Feature: Recipe form — associating ingredients
Then the ingredient "Carotte" should be visible in the picker
And there should be 0 ingredient rows
Scenario: Marks an ingredient as « selon le goût » and saves it with no quantity
Given creating the recipe will succeed and return id 44
When I visit "/recettes/nouvelle"
And I fill in the "recipe-name" field with "Tomates au sel"
And I select the ingredient "Tomate" from the picker
And I mark the ingredient "Tomate" as seasoning
Then the ingredient "Tomate" should have no quantity field
When I add a step
And I fill in the step description with "Saler."
Then the "Enregistrer" button should not be disabled
When I click the button "Enregistrer"
Then the recipe creation request should have included the seasoning-only ingredient 1
And the URL should include "/recettes/44"
Scenario: The recipe form has no regime picker — regimes are computed from ingredients (issue #131)
When I visit "/recettes/nouvelle"
Then I should not see "Régime(s) associé(s)"
Scenario: The save/cancel bar stays pinned to the foot of a long form
When I visit "/recettes/nouvelle"
And I add a step
And I add a step
And I scroll the recipe form to the top
Then the recipe form action bar is pinned to the bottom of the viewport
And I should see "Enregistrer"
And I should see "Annuler"
Scenario: Cancelling a new recipe goes back to the catalog
When I visit "/recettes/nouvelle"
And I fill in the "recipe-name" field with "Brouillon abandonné"
And I click the button "Annuler"
Then the URL should include "/recettes"
And the URL should not include "/nouvelle"
Scenario: Cancelling an edit goes back to that recipe
Given recipe 7 exists with an egg omelette
When I visit "/recettes/7/modifier"
And I click the button "Annuler"
Then the URL should include "/recettes/7"
And the URL should not include "/modifier"
Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another
Given recipe 7 exists with an egg omelette
And updating recipe 7 will succeed

View file

@ -95,44 +95,6 @@ Then(
},
);
When("I mark the ingredient {string} as seasoning", (name: string) => {
cy.contains(".ingredient-row", name)
.find(".ingredient-row__to-taste input[type=checkbox]")
.check({ force: true });
});
Then("the ingredient {string} should have no quantity field", (name: string) => {
cy.contains(".ingredient-row", name).find(".ingredient-row__quantity").should("not.exist");
});
Then(
"the recipe creation request should have included the seasoning-only ingredient {int}",
(ingredientId: number) => {
cy.wait("@createRecipe")
.its("request.body.ingredients")
.should("deep.equal", [{ ingredientId }]);
},
);
When("I scroll the recipe form to the top", () => {
// `.app-content` is the one scroll container (AppLayout.scss) — the sticky
// action bar pins against it, so it's what has to be scrolled to prove it.
cy.get(".app-content").scrollTo("top");
});
Then("the recipe form action bar is pinned to the bottom of the viewport", () => {
cy.get(".recipe-form__actions--sticky").then(($bar) => {
const rect = $bar[0].getBoundingClientRect();
const viewportHeight = Cypress.config("viewportHeight");
// Scrolled to the top of a form taller than the viewport, a pinned bar
// sits at the container's bottom padding edge (`.app-content` keeps its
// own `--space-xl` padding) rather than far below the fold — allow a
// generous band for that padding + the bar's own.
expect(rect.bottom).to.be.greaterThan(viewportHeight - 120);
expect(rect.bottom).to.be.at.most(viewportHeight + 1);
});
});
Given("recipe 7 exists with an egg omelette", () => {
const existingRecipe = {
id: 7,

View file

@ -80,19 +80,6 @@ Feature: Browsing external recipe sources
When I click the button "Réessayer"
Then I should see the source item "Beef Wellington"
Scenario: Keeps the search term when switching between source tabs
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB and Marmiton
And browsing any enabled source echoes back its query
When I visit "/recettes"
And I click the button "TheMealDB"
And I type "poulet" into the recipe search field
Then "TheMealDB" should have been browsed with the query "poulet"
When I click the button "Marmiton"
Then the recipe search field should contain "poulet"
And "Marmiton" should have been browsed with the query "poulet"
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
Given the recipe catalog contains nothing
And the sources reference list has options

View file

@ -56,60 +56,6 @@ Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
});
Given("the household has enabled TheMealDB and Marmiton", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1, 2] });
});
// Issue #133 — the search field is shared across every source tab. Both
// sources answer `browse` the same way and each request is aliased so a
// scenario can assert which `query` reached which source.
Given("browsing any enabled source echoes back its query", () => {
const reply = (req: { url: string; reply: (r: unknown) => void }) => {
const query = new URL(req.url).searchParams.get("query") ?? "";
req.reply({
statusCode: 200,
body: {
items: [
{
externalId: `${query || "all"}-1`,
title: query ? `Résultat « ${query} »` : "Tout le catalogue",
picture: null,
url: "https://example.test/1",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
};
cy.intercept("GET", "**/sources/theMealDb/browse*", reply).as("browseTheMealDb");
cy.intercept("GET", "**/sources/marmiton/browse*", reply).as("browseMarmiton");
});
When("I type {string} into the recipe search field", (text: string) => {
cy.get(".recipes-page__search").clear().type(text);
});
Then("the recipe search field should contain {string}", (text: string) => {
cy.get(".recipes-page__search").should("have.value", text);
});
Then(
"{string} should have been browsed with the query {string}",
(source: string, query: string) => {
const alias = source === "Marmiton" ? "@browseMarmiton.all" : "@browseTheMealDb.all";
// `.all` + a retried `.should` — an empty-query browse fires first (tab
// opened before anything was typed) and React StrictMode double-invokes
// the fetch effect in the dev server, so assert "one of the requests
// carried the term" rather than waiting on a specific occurrence.
cy.get(alias).should((calls: { request: { url: string } }[]) => {
const hit = calls.some((c) => c.request.url.includes(`query=${encodeURIComponent(query)}`));
expect(hit, `a ${source} browse used query="${query}"`).to.equal(true);
});
},
);
Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200,

View file

@ -5,8 +5,6 @@
// The favorite-toggle and delete-recipe journeys moved to recipes.feature —
// this file now only covers catalog browsing/display.
import { PHONE_VIEWPORT } from "../support/viewports";
const authenticatedProfile = {
id: 1,
firstName: "Alice",
@ -462,38 +460,3 @@ describe("Recipe catalog", () => {
);
});
});
// Ticket #90 — `.recipe-table-wrap` had no horizontal-scroll guard at all
// (unlike the planning grid's own wrapper), so a narrow viewport just let
// the browser compress every column instead of scrolling. Below the
// tablet breakpoint (1024px) the master-detail split already collapses to
// one column (recipes.scss's own `bp.tablet-down` rule) and gives the
// table the full page width — still comfortably under `.recipe-table`'s
// own 36rem floor on a phone, which is what this covers.
describe("Recipe catalog (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
interceptAuth();
});
it("keeps the table on its own horizontal scroll, at full width, instead of squeezing its columns", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
cy.visit("/recettes");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.get(".recipe-table-wrap").should(($el) => {
const s = getComputedStyle($el[0]);
expect(s.overflowX).to.be.oneOf(["auto", "scroll"]);
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
});
// ...and that overflow stays *inside* the wrapper — same global rule
// every other page is held to.
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1);
});
});

View file

@ -1,66 +0,0 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml). apps/api's own Mocha suite covers the real
// 401 behavior (an expired/invalid JWT -> `require-auth` -> 401
// NOT_AUTHENTICATED); this only covers the *web client's* reaction to it.
import { ErrorCode } from "@batch-cooking/shared";
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
// Issue #108 — a session that expired (or was invalidated) server-side
// mid-use only took effect on the next full reload: any other request just
// surfaced a local error while the authenticated UI stayed put. `ApiClient`
// now reacts to a `401 NOT_AUTHENTICATED` on any request other than the
// initial `/auth/me` probe by dropping the session and bouncing to
// `/login`.
describe("Session expiry", () => {
it("bounces to /login when a request 401s mid-session", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
// Logged in, planning loads fine.
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
cy.contains("h1", "Planning de la semaine").should("be.visible");
// The session dies server-side: every endpoint now 401s, including the
// `/auth/me` probe a fresh load would make.
const unauthenticated = {
statusCode: 401,
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" },
};
cy.intercept("GET", "**/auth/me", unauthenticated);
cy.intercept("GET", /\/planning\?/, unauthenticated);
// The next planning fetch (a week-nav click) 401s -> bounced to /login.
cy.get(".week-nav__arrow").last().click();
cy.url().should("include", "/login");
// ...and it's a real logout: reloading a guarded route redirects again
// rather than showing stale authed content.
cy.visit("/recettes");
cy.url().should("include", "/login");
});
it("does not bounce a logged-out visitor — the initial /auth/me 401 is the normal state", () => {
cy.intercept("GET", "**/auth/me", {
statusCode: 401,
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" },
});
cy.visit("/");
// `/` for a logged-out visitor is the public landing page (issue #41),
// not a redirect to /login — the `/auth/me` probe 401 must not trip the
// session-expiry handler.
cy.contains("a", "Se connecter").should("be.visible");
cy.url().should("not.include", "/login");
});
});

View file

@ -1,69 +0,0 @@
// Plain layout-mode spec (not Gherkin) for ShoppingListPage's own
// phone-viewport pass (issue #90) — the actual grouped-list rendering,
// aggregation and week-navigation journey is already covered end-to-end by
// shopping-list.feature; this only exercises the responsive change added
// here. Mocks the API via cy.intercept — this job doesn't run a live
// backend (see .github/workflows/ci.yml).
import { PHONE_VIEWPORT } from "../support/viewports";
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
/** Same minimal-fixture convention as shopping-list.ts's own `buildShoppingListItem` step-def helper — only the fields this page actually reads. */
function shoppingListItem(id: number, ingredientKey: string, icon: string, category: string) {
return {
ingredient: {
id,
key: ingredientKey,
icon,
category,
subcategory: category,
reproducible: false,
allergens: [],
diets: [],
},
quantity: 2,
unit: { id, key: "piece", type: "COUNT", toBaseFactor: 1 },
};
}
describe("Shopping list page (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("gives each row a taller, more legible tap target than on desktop, with no horizontal body scroll", () => {
cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200,
body: {
startDate: "2026-08-17",
finishDate: "2026-08-23",
items: [shoppingListItem(1, "egg", "EGG", "dairyAndCheese")],
},
}).as("getList");
cy.visit("/liste-de-courses");
cy.wait("@getList");
// The interactive row is the `CheckboxOption` label (issue #109), not
// the `<li>` wrapper. --space-md (16px) padding on phone vs. the desktop
// default --space-sm (8px), plus a ≥44px min-height tap target.
cy.get(".shopping-list__item-check").should(($el) => {
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
expect($el[0].getBoundingClientRect().height).to.be.gte(44);
});
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1);
});
});

View file

@ -22,11 +22,6 @@ Feature: Shopping list
And the shopping list should show "Tomate" at quantity "400 g"
And the shopping list should show "Oeuf" at quantity "6 unité"
Scenario: A « selon le goût » seasoning line shows no quantity (issue #128)
Given the shopping list for "2026-08-17" has a « selon le goût » line for "salt"
When I visit "/liste-de-courses"
Then the shopping list should show "Sel" at quantity "selon le goût"
Scenario: Navigating to another week fetches and shows that week's own list
Given the shopping list for "2026-08-17" is empty
And the shopping list for "2026-08-24" contains:
@ -35,15 +30,3 @@ Feature: Shopping list
When I visit "/liste-de-courses"
And I click the next week arrow
Then the shopping list should show "Oignon" at quantity "1 kg"
Scenario: Ticking an item marks it as in the cart, and the tick survives a reload
Given the shopping list for "2026-08-17" contains:
| ingredientKey | icon | category | quantity | unitKey |
| tomato | VEGETABLE | freshProduce | 400 | gram |
| onion | VEGETABLE | freshProduce | 2 | piece |
When I visit "/liste-de-courses"
And I tick the shopping list item "Tomate"
Then the shopping list item "Tomate" should be ticked
And the shopping list item "Oignon" should not be ticked
When I reload the page
Then the shopping list item "Tomate" should be ticked

View file

@ -43,38 +43,6 @@ Given("the shopping list for {string} contains:", (date: string, dataTable: Data
});
});
// A "selon le goût" line — `quantity`/`unit` both null (issue #128). Its own
// step rather than a `buildShoppingListItem` data-table column so that
// helper's `quantity`/`unitKey` contract stays required for every other row.
Given(
"the shopping list for {string} has a « selon le goût » line for {string}",
(date: string, ingredientKey: string) => {
cy.intercept("GET", `**/shopping-list?date=${date}`, {
statusCode: 200,
body: {
startDate: date,
finishDate: date,
items: [
{
ingredient: {
id: 1,
key: ingredientKey,
icon: "JAR",
category: "condimentsAndSpices",
subcategory: "spices",
reproducible: false,
allergens: [],
diets: [],
},
quantity: null,
unit: null,
},
],
},
});
},
);
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
// shared between the two pages) — `planning-page.cy.ts` already exercises
// the prev/next arrows directly by class, same approach here.
@ -102,27 +70,3 @@ Then(
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
},
);
// Issue #109 — a line can be ticked off as "in the cart" (local, per-week,
// `localStorage` only — see `shopping-list-progress.ts`).
When("I tick the shopping list item {string}", (name: string) => {
cy.contains(".shopping-list__item", name).find("label.shopping-list__item-check").click();
});
Then("the shopping list item {string} should be ticked", (name: string) => {
cy.contains(".shopping-list__item", name)
.should("have.class", "shopping-list__item--checked")
.find('input[type="checkbox"]')
.should("be.checked");
});
Then("the shopping list item {string} should not be ticked", (name: string) => {
cy.contains(".shopping-list__item", name)
.should("not.have.class", "shopping-list__item--checked")
.find('input[type="checkbox"]')
.should("not.be.checked");
});
When("I reload the page", () => {
cy.reload();
});

View file

@ -25,7 +25,7 @@ describe("Sidebar — settings menu and account menu", () => {
cy.get(".app-sidebar__nav a").should("not.contain", "Foyer");
});
it("reveals the settings pages behind the Paramètres toggle", () => {
it("reveals the four settings pages behind the Paramètres toggle", () => {
cy.visit("/");
cy.contains("a", "Compte").should("not.exist");
@ -43,9 +43,6 @@ describe("Sidebar — settings menu and account menu", () => {
"href",
"/parametres/preferences-utilisateur",
);
// "Paramètres d'application" is packaged-app only (issue #107) — Cypress
// runs as a plain web browser, so it's filtered out here.
cy.contains("a", "Paramètres d'application").should("not.exist");
});
it("opens the account menu from the greeting and links to Mon compte", () => {

View file

@ -3,8 +3,6 @@ import { mount } from "cypress/react18";
// components (Checkbox, Radio, Dialog…) are styled through it, not their
// own scoped CSS, so mounting one without it would test unstyled markup.
import "../../src/styles/global.scss";
// Registers `cy.setViewport("phone" | "tablet")` — see viewports.ts.
import "./viewports";
declare global {
namespace Cypress {

View file

@ -1,3 +1,3 @@
// Cypress support file — global config and custom commands will go here
// as features (and their e2e specs) get added.
import "./viewports";
export {};

View file

@ -32,10 +32,10 @@ Given("the allergies reference list is empty", () => {
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
});
// An onboarding scenario whose household step *creates* a household then
// reaches `/onboarding/sources` (see `OnboardingHouseholdPage`'s
// `goToNextStep`), which reads this before self-skipping straight to the
// home page if it comes back empty.
// Every onboarding scenario that reaches the household step also reaches
// `/onboarding/sources` right after (when a household got created/joined —
// see `OnboardingHouseholdPage`'s `goToNextStep`), which reads this before
// self-skipping to `/onboarding/allergenes` if it comes back empty.
Given("the sources reference list is empty", () => {
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
});

View file

@ -1,30 +0,0 @@
// Named viewport presets for phone/tablet coverage — the mobile/tablet UI
// chantier's testing convention (ticket #83), shared by both the e2e and
// component support files (see e2e.ts/component.ts, each of which imports
// this once for its side effect of registering `cy.setViewport`).
//
// Sized from real, representative Android devices rather than Cypress's own
// built-in presets (`cy.viewport("iphone-x")` etc.) — this app is
// Android-only (see the root README), so an Apple-named preset would be a
// odd source of truth to borrow pixel values from. Comfortably inside
// `_breakpoints.scss`'s `$phone`/`$tablet` bands (not flush against either
// edge) so a test genuinely exercises "a phone" / "a tablet", not the exact
// boundary pixel.
export const PHONE_VIEWPORT = { width: 390, height: 844 } as const;
export const TABLET_VIEWPORT = { width: 900, height: 1200 } as const;
export type ViewportPreset = "phone" | "tablet";
declare global {
namespace Cypress {
interface Chainable {
/** `cy.viewport(width, height)` for one of the mobile/tablet UI chantier's two named presets — see PHONE_VIEWPORT/TABLET_VIEWPORT above for the exact values. */
setViewport(preset: ViewportPreset): Chainable<void>;
}
}
}
Cypress.Commands.add("setViewport", (preset: ViewportPreset) => {
const { width, height } = preset === "phone" ? PHONE_VIEWPORT : TABLET_VIEWPORT;
cy.viewport(width, height);
});

View file

@ -3,15 +3,6 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--
Tints the mobile browser's own chrome (address bar) to match the app,
per system light/dark — same palette as the native app's status bar
(apps/web/android/.../res/values*/colors.xml, issue #104). Hex kept in
sync with `--color-primary-hover` / `--color-background` (dark) in
src/styles/_theme.scss.
-->
<meta name="theme-color" content="#244f38" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#14181a" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>batchCooking</title>
<meta

View file

@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.2.3",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {

View file

@ -1,8 +1,6 @@
import { Capacitor } from "@capacitor/core";
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
import { AdminAuthProvider } from "./features/admin/AdminAuthContext";
import { RequireAdmin } from "./features/admin/RequireAdmin";
import { UpdateBanner } from "./features/app-update/UpdateBanner";
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
import { RequireAuth } from "./features/auth/RequireAuth";
import { AdminLayout } from "./layouts/AdminLayout";
@ -11,14 +9,11 @@ import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPa
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage";
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
import { UsersPage as AdminUsersPage } from "./pages/admin/users/UsersPage";
import { LoginPage } from "./pages/auth/LoginPage";
import { SignupPage } from "./pages/auth/SignupPage";
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
import { DownloadPage } from "./pages/download/DownloadPage";
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
@ -28,8 +23,6 @@ import { ImportRecipePage } from "./pages/recipes/ImportRecipePage";
import { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
import { RecipesPage } from "./pages/recipes/RecipesPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { AppSettingsPage } from "./pages/settings/AppSettingsPage";
import { ContactPage } from "./pages/settings/ContactPage";
import { CreditsPage } from "./pages/settings/CreditsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
import { PreferencesPage } from "./pages/settings/PreferencesPage";
@ -45,30 +38,20 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* itself redirects to `/login` if needed.
*
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences
* utilisateur/application/crédits) are the settings pages, reachable from
* the sidebar's bottom "Paramètres" menu and the account menu (see
* `AppLayout`) — nested under `AppLayout` like every other authenticated
* section. `/foyer` is the old, pre-split combined page's path; it now
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps
* working.
* utilisateur/crédits) are the settings pages, reachable from the sidebar's
* bottom "Paramètres" menu and the account menu (see `AppLayout`) — nested under
* `AppLayout` like every other authenticated section. `/foyer` is the old,
* pre-split combined page's path; it now just redirects to
* `/parametres/foyer` so an existing bookmark/link keeps working.
*
* `/onboarding/*` (regime/allergenes/foyer/sources, in that order) is also
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
* `RequireAuth`-gated — reached right after signup, once a session already
* exists — but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
* `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources`
* is conditional — only reached when the `foyer` step *created* a household
* (see `OnboardingHouseholdPage`'s `goToNextStep`), every other path ending
* the wizard straight to `/`. Step order and the "sur 3" / "4 sur 4"
* indicators live in `pages/onboarding/onboarding-steps.ts`.
*
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
* `/login`/`/signup`, but deliberately *not* wrapped in
* `RedirectIfAuthenticated`: a signed-in visitor still has a legitimate
* reason to land here (logged in on the web, app just not installed on
* this particular device yet).
* `/onboarding/sources` is conditional — only reached when the `foyer` step
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
* skipped otherwise, straight to `/onboarding/allergenes`.
*
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
* triage, off-catalog ingredient review). It's its own top-level group,
@ -78,17 +61,9 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* never nested under `RequireAuth`. `/admin/login` is the only
* unauthenticated admin route; everything else sits under `RequireAdmin` +
* `AdminLayout`.
*
* {@link UpdateBanner} is mounted here, outside `<Routes>`, rather than in
* `AppLayout` — the native app-update check (issue #66) must run on every
* app start, session or not, and `AppLayout` only mounts once a visitor is
* past `RequireAuth`. It's a `position: fixed` strip and renders nothing on
* the web, so mounting it app-wide costs the routed pages no layout.
*/
export function App() {
return (
<>
<UpdateBanner />
<Routes>
<Route
element={
@ -114,22 +89,6 @@ export function App() {
<Route path="/parametres/preferences" element={<PreferencesPage />} />
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
{/* Native app only (issue #107): on the web its sections are inert
(no "installed version", the update check is a no-op) or have
moved to `/telecharger` (the stay-on-web toggle), and the nav
entry is hidden — so a stray bookmark redirects rather than
landing on an orphaned page (same pattern as `/foyer` below). */}
<Route
path="/parametres/application"
element={
Capacitor.isNativePlatform() ? (
<AppSettingsPage />
) : (
<Navigate to="/parametres/compte" replace />
)
}
/>
<Route path="/parametres/contact" element={<ContactPage />} />
<Route path="/parametres/credits" element={<CreditsPage />} />
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route>
@ -141,14 +100,6 @@ export function App() {
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/foyer"
element={
@ -165,6 +116,14 @@ export function App() {
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route
path="/login"
element={
@ -181,7 +140,6 @@ export function App() {
</RedirectIfAuthenticated>
}
/>
<Route path="/telecharger" element={<DownloadPage />} />
<Route
element={
<AdminAuthProvider>
@ -199,16 +157,13 @@ export function App() {
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="utilisateurs" element={<AdminUsersPage />} />
<Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} />
<Route path="didacticiels" element={<AdminToursPage />} />
<Route path="messages" element={<AdminMessagesPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
}

View file

@ -1,25 +1,17 @@
import {
type AdminCreateUserInput,
type AdminLoginInput,
type AdminTourScenarioView,
type AdminUserCreatedView,
type AdminUserListItemView,
type AdminUserView,
type ApiErrorResponse,
type CatalogPlaceholderGroupView,
type ContactMessageAdminView,
type CorrectionAdminView,
type CreateForgejoIssueInput,
type CreateTourScenarioInput,
ErrorCode,
type ForgejoIntegrationStatus,
type MarkPlaceholdersReviewedInput,
type MarkTechStepPlaceholdersReviewedInput,
type MetricsView,
type PruneOrphansResultView,
type RetrainRequestInput,
type RetrainResultView,
type TechStepPlaceholderView,
type TourTargetView,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
@ -134,20 +126,6 @@ export class AdminApiClient {
return this._request(`/metrics?days=${days}`);
}
/** End-user accounts for the `/admin/utilisateurs` list — newest first, capped server-side at 200. */
public getUsers(): Promise<AdminUserListItemView[]> {
return this._request("/users");
}
/**
* Provisions an end-user account by hand. Returns the created account plus
* the one-time temporary password the server generated — show it once,
* it's never retrievable again. Rejects with `EMAIL_ALREADY_IN_USE`.
*/
public createUser(input: AdminCreateUserInput): Promise<AdminUserCreatedView> {
return this._request("/users", { method: "POST", body: JSON.stringify(input) });
}
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
public getSuggestions(filters: {
status?: string;
@ -214,21 +192,6 @@ export class AdminApiClient {
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
}
/** Free-text techniques users proposed while correcting a step, when the catalog was missing one (issue #130). Same `reviewed` tri-state as {@link getPlaceholders}. */
public getTechStepPlaceholders(reviewed?: "true" | "false"): Promise<TechStepPlaceholderView[]> {
return this._request(`/catalog/tech-step-placeholders${query({ reviewed })}`);
}
/** Marks the given placeholder technique ids as triaged (`reviewedAt`). */
public markTechStepPlaceholdersReviewed(
body: MarkTechStepPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
return this._request("/catalog/tech-step-placeholders/mark-reviewed", {
method: "PATCH",
body: JSON.stringify(body),
});
}
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
return this._request("/tours");
@ -256,37 +219,6 @@ export class AdminApiClient {
public deleteTourScenario(id: number): Promise<void> {
return this._request(`/tours/${id}`, { method: "DELETE" });
}
/**
* Contact-form messages users have sent (issue #80), newest first, capped
* server-side at 200. `type` is an optional single-value filter. Read-only
* — this first version has no triage workflow.
*/
public getContactMessages(filters: { type?: string }): Promise<ContactMessageAdminView[]> {
return this._request(`/contact${query(filters)}`);
}
/** Whether the "create a Forgejo issue from a message" feature is configured (issue #81) — drives whether the button is shown. */
public getForgejoStatus(): Promise<ForgejoIntegrationStatus> {
return this._request("/contact/forgejo/status");
}
/**
* Creates a Forgejo issue from contact message `id` with the admin's
* edited `title`/`body` (issue #81), and returns the message with its new
* `forgejoIssueNumber` / `forgejoIssueUrl`. Rejects with
* `FORGEJO_NOT_CONFIGURED` / `CONTACT_MESSAGE_ALREADY_LINKED` /
* `FORGEJO_REQUEST_FAILED`.
*/
public createForgejoIssue(
id: number,
input: CreateForgejoIssueInput,
): Promise<ContactMessageAdminView> {
return this._request(`/contact/${id}/forgejo-issue`, {
method: "POST",
body: JSON.stringify(input),
});
}
}
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */

View file

@ -24,7 +24,6 @@ import {
type SignupInput,
type SourceView,
type StepTechStepCorrectionView,
type SubmitContactMessageInput,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
type TechStepView,
@ -45,7 +44,7 @@ import { noopTokenStore, type TokenStore } from "./token-store";
* in `apps/web/.env`, since the Vite dev server (5173) and the API (3000)
* are on different origins there.
*/
export const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
/**
* The token store {@link ApiClient} consults on every request. Defaults to
@ -66,23 +65,6 @@ export function setTokenStore(store: TokenStore): void {
activeTokenStore = store;
}
/**
* Called by {@link ApiClient._request} the moment any request *other than*
* the initial `GET /auth/me` probe comes back `401 NOT_AUTHENTICATED` — i.e.
* a session that *was* valid has expired (7-day JWT) or been invalidated
* server-side mid-use. The token store is already cleared by the time this
* fires; the app-level handler (registered by `AuthProvider`) resets the
* in-memory auth state and sends the user to `/login`. `null` until
* registered — on the server, in tests, and before `AuthProvider` mounts,
* nobody reacts and the `ApiError` just propagates as before (issue #108).
*/
let onSessionExpired: (() => void) | null = null;
/** Registers (or, with `null`, clears) the {@link onSessionExpired} handler — `AuthProvider` does both, on mount and unmount. */
export function setSessionExpiredHandler(handler: (() => void) | null): void {
onSessionExpired = handler;
}
/**
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
* status. Carries the same {@link ErrorCode} the API returned, so callers
@ -142,19 +124,6 @@ export class ApiClient {
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
// A session that was valid has just died server-side (expired JWT,
// or a future "log out everywhere"): drop the local token and let
// the app react (see `onSessionExpired`). Excludes the initial
// `GET /auth/me` probe — a 401 there is the normal "not logged in
// yet" state `AuthProvider` already handles, not a mid-use expiry.
if (
response.status === 401 &&
body?.code === ErrorCode.NOT_AUTHENTICATED &&
path !== "/auth/me"
) {
await activeTokenStore.clear();
onSessionExpired?.();
}
// Fallback for a response that couldn't even be parsed as JSON — no
// hardcoded string, always the real enum member.
throw new ApiError(
@ -530,30 +499,6 @@ export class ApiClient {
body: JSON.stringify({ skipped }),
});
}
/**
* The latest tagged Android release's version (e.g. `"v1.4.2"`) — used by
* {@link useAppUpdateCheck}. Proxied through this API rather than fetched
* directly from Forgejo: Forgejo's Releases API sends no CORS headers, so
* a browser-side `fetch()` to it fails outright. Public — no session
* required.
*/
public getLatestMobileVersion(): Promise<{ version: string }> {
return this._request("/mobile/latest-release");
}
/**
* Sends a contact-form message (bug report / suggestion / other — issue
* #80). Session required; the message is stored server-side and read by
* admins at `/admin/messages`. Resolves with nothing on success (`204`),
* rejects with `VALIDATION_ERROR` if `message` is too short/long. The
* server reads the user-agent from the request header itself — the client
* only sends `type`, `message`, and the auto-captured `pageUrl` /
* `appVersion` context.
*/
public async submitContactMessage(input: SubmitContactMessageInput): Promise<void> {
await this._request("/contact", { method: "POST", body: JSON.stringify(input) });
}
}
/** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -1,65 +0,0 @@
import type { ReactNode } from "react";
import "./bottom-sheet.scss";
import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
/**
* A native `<dialog>` anchored to the bottom edge instead of centered —
* the mobile-native replacement for a small anchored floating popover
* (a menu, a short list of actions) that a phone-width screen has no good
* place to float. Same open/close mechanics as `Dialog.tsx` (via the
* shared `useNativeDialogBehavior`: `showModal()` on mount, Escape/`close`
* event, tap-outside-to-dismiss) — only the panel's own position/shape
* differs, so both get the browser's focus trap and top-layer stacking for
* free.
*
* First consumer: `AppLayout`'s mobile nav, for the "Plus" tab (Paramètres
* + Compte, previously two separate floating dropdowns that had no
* viewport-edge handling at all — see the mobile/tablet UI chantier's own
* audit). Meant to be reused by the planning popovers (leftovers basket,
* week calendar) in a later ticket of that same chantier — kept generic
* (title + children, no planning-specific assumptions) for that reason.
*
* Not used on desktop: a sheet sliding up from the bottom of a full
* viewport only reads as "anchored to what you tapped" on a narrow screen
* where the tapped control is usually near the bottom already (a bottom
* tab bar, a floating action button). Desktop keeps the existing anchored
* dropdown/popover pattern for the same triggers — callers decide which to
* render (typically via `useIsMobileViewport()`), this component doesn't
* check the viewport itself.
*/
export function BottomSheet({
onClose,
title,
children,
}: {
onClose: () => void;
title?: string;
children: ReactNode;
}) {
const dialogRef = useNativeDialogBehavior(onClose);
return (
<dialog ref={dialogRef} className="bottom-sheet" aria-label={title}>
{/* Purely decorative drag-affordance — this sheet closes via
tap-outside/Escape/the close button, never an actual drag
gesture, but the grabber bar is still the expected visual
convention for "this slid up from the bottom" on both Android and
iOS, so it's worth keeping even without the gesture behind it. */}
<div className="bottom-sheet__grabber" aria-hidden="true" />
{title && (
<div className="bottom-sheet__header">
<h2>{title}</h2>
<button
type="button"
className="bottom-sheet__close"
onClick={() => dialogRef.current?.close()}
aria-label="Fermer"
>
✕
</button>
</div>
)}
<div className="bottom-sheet__body">{children}</div>
</dialog>
);
}

View file

@ -1,6 +1,5 @@
import type { ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
import "./dialog.scss";
import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
/**
* App-wide modal primitive — a native `<dialog>` (`showModal()`), not a
@ -12,14 +11,6 @@ import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
* fit inline, hence this.
*
* On a phone (`_breakpoints.scss`'s `$phone`) the panel goes edge-to-edge
* full screen instead of staying a centered, rounded panel — see
* `dialog.scss`'s own comment on why a shrunk desktop dialog isn't enough
* on a small screen. `BottomSheet` (this same folder) is the sibling
* primitive for a *permanently* bottom-anchored overlay (a menu/popover),
* not a full modal — the two share their open/close mechanics via
* `useNativeDialogBehavior` but stay visually and semantically distinct.
*
* Mounted only while open (see `PlanningPage`'s conditional rendering of
* `RecipePickerDialog`, same convention as its own `CalendarPopover`) —
* `showModal()` fires once on mount rather than toggling on an `isOpen`
@ -40,7 +31,56 @@ export function Dialog({
/** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
footer?: ReactNode;
}) {
const dialogRef = useNativeDialogBehavior(onClose);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// `close` covers every way a native dialog can close — Escape (which
// fires `cancel` first, then `close`) as much as a future
// `<form method="dialog">` — so this is the one listener needed to
// keep the caller's own "is this open" state (e.g. `PlanningPage`'s
// `openSlot`) in sync with it.
dialog.addEventListener("close", onClose);
return () => dialog.removeEventListener("close", onClose);
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// Attached imperatively (not a JSX `onClick`) since the click-to-close
// it implements is already reachable from the keyboard via Escape
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
// trip the "needs a matching keyboard handler" a11y lint for a
// non-interactive element even though one already exists, just not in
// a form that lint rule can see.
function handleClick(e: MouseEvent) {
// Re-read from the ref (not the outer `dialog` const) — TS can't
// carry that early-return narrowing into a nested function, since it
// can't prove the function won't run at some later point where it no
// longer holds (even though here, as an event listener on this same
// element, it trivially still does).
const current = dialogRef.current;
if (!current) return;
// A click lands on the `<dialog>` element itself both for the
// backdrop *and* for its own unfilled padding/margin — comparing
// against its content box (not just `e.target`) is what actually
// distinguishes "outside the panel" from "on it".
const rect = current.getBoundingClientRect();
const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) current.close();
}
dialog.addEventListener("click", handleClick);
return () => dialog.removeEventListener("click", handleClick);
}, []);
return (
<dialog

View file

@ -1,78 +0,0 @@
import { Eye, EyeOff } from "lucide-react";
import { type ChangeEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import "./password-input.scss";
/**
* A password `<input>` paired with a show/hide "eye" toggle — the button
* swaps the field's `type` between `"password"` and `"text"` so a visitor
* can re-read what they typed before submitting. A bare password field
* gives no feedback at all, and a mistyped-but-invisible password is one
* of the most common causes of a failed login; this makes it checkable
* without a password manager.
*
* Lives in `components/ui/` rather than `features/auth/` because nothing
* about it is auth-specific — it is just a password field. Used today by
* {@link LoginPage} and {@link SignupPage}.
*
* Visibility is local state that always starts hidden on mount and is
* never persisted: revealing a password is a deliberate, momentary action,
* not a preference worth remembering across page loads or sessions.
*
* The field's own look (border, padding, background) is inherited from the
* surrounding context — the same arrangement as {@link CheckboxOption},
* which is styled through `global.scss` rather than carrying its own CSS.
* The colocated `password-input.scss` only positions the toggle inside the
* field and reserves horizontal room for it.
*/
export function PasswordInput({
id,
value,
onChange,
autoComplete,
}: {
/** Forwarded to the `<input>` so an external `<label htmlFor>` keeps targeting it. */
id: string;
/** Controlled value — this component holds no text state of its own. */
value: string;
/** Called with the new text on each change (event unwrapped, like {@link CheckboxOption}). */
onChange: (value: string) => void;
/**
* Password-manager hint, forwarded verbatim — callers pass
* `"current-password"` on a login form, `"new-password"` on signup.
*/
autoComplete?: string;
}) {
const { t } = useTranslation();
// Always hidden on mount — see the component doc comment on why this is
// never persisted.
const [visible, setVisible] = useState(false);
// The label always names the action the *next* click performs; the
// pressed state is carried separately by `aria-pressed` below.
const toggleLabel = visible ? t("common.passwordInput.hide") : t("common.passwordInput.show");
return (
<span className="password-input">
<input
id={id}
type={visible ? "text" : "password"}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
autoComplete={autoComplete}
/>
<button
type="button"
className="password-input__toggle"
// `type="button"` (not the implicit `"submit"`) so toggling
// visibility never submits the form the field sits in.
aria-pressed={visible}
aria-label={toggleLabel}
title={toggleLabel}
onClick={() => setVisible((v) => !v)}
>
{visible ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
</button>
</span>
);
}

View file

@ -1,76 +0,0 @@
// Bottom-anchored panel — see BottomSheet.tsx. A native <dialog> opened via
// showModal(), same UA-default reset as dialog.scss, but positioned flush
// against the bottom edge instead of centered.
.bottom-sheet {
display: flex;
flex-direction: column;
width: 100%;
max-width: 32rem; // caps how wide this gets on a tablet-width viewport
max-height: min(80vh, 40rem);
// A native <dialog>'s UA default is `position: fixed; inset: 0; margin:
// auto` (centered) — overriding just the vertical half of that margin
// pins it to the bottom edge while `margin-inline: auto` keeps it
// horizontally centered (relevant once `max-width` is narrower than the
// viewport, e.g. on a tablet).
margin: 0 auto auto;
padding: 0;
border: none;
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-md);
overflow: hidden;
&::backdrop {
background: rgba(0, 0, 0, 0.45);
}
}
.bottom-sheet__grabber {
flex-shrink: 0;
width: 2.5rem;
height: 0.25rem;
margin: var(--space-sm) auto;
border-radius: var(--radius-pill);
background: var(--color-border);
}
.bottom-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: 0 var(--space-lg) var(--space-md);
h2 {
font-size: var(--font-size-lg);
}
}
.bottom-sheet__close {
flex: none;
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--radius-base);
background: transparent;
color: var(--color-text-muted);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
color: var(--color-text);
}
}
.bottom-sheet__body {
flex: 1;
min-height: 0;
overflow-y: auto;
// A phone's bottom safe area (gesture bar / rounded corners) sits right
// below this sheet's own bottom edge — the extra bottom padding keeps the
// last item from ever feeling flush against it.
padding: 0 var(--space-lg) var(--space-lg);
}

View file

@ -5,8 +5,6 @@
// since it's one component's styling, same convention as every feature's
// own .scss file (recipes.scss, planning-page.scss, …).
@use "../../styles/breakpoints" as bp;
.dialog-panel {
display: flex;
flex-direction: column;
@ -26,18 +24,14 @@
background: rgba(0, 0, 0, 0.45);
}
// A shrunk-but-still-centered panel (the old phone treatment here — just
// tighter margins) still wastes a meaningful slice of a ~375px screen on
// rounded corners + drop shadow + surrounding backdrop, and still needs
// its own internal scroll for anything longer than a short form. Full
// screen removes all of that: the panel just *is* the screen, so there's
// nothing left to reflow around.
@include bp.phone {
max-width: 100vw;
max-height: 100vh;
height: 100vh;
border-radius: 0;
box-shadow: none;
// On a phone the `48rem` cap is irrelevant (the viewport is narrower),
// but the `var(--space-2xl)` top/bottom breathing room is a large slice
// of a short screen — tighten it so a form-heavy dialog isn't
// needlessly cramped, and keep a small side gutter so the panel never
// sits edge to edge.
@media (max-width: 640px) {
max-width: calc(100vw - var(--space-md));
max-height: calc(100vh - var(--space-md));
}
}

View file

@ -1,58 +0,0 @@
// =============================================================================
// PasswordInput (components/ui/PasswordInput.tsx) — positions the show/hide
// "eye" toggle inside the password field and keeps typed text clear of it.
//
// The field's own look (border, padding, colours) is inherited from the
// surrounding context — e.g. `.auth-card input` in features/auth/auth-form.scss
// — exactly as CheckboxOption leans on global.scss. This sheet owns only the
// wrapper, the toggle button, and the space the icon needs.
// =============================================================================
// No `@use` of the theme partial: every token below (--color-*, --space-*) is a
// CSS custom property declared on :root in styles/global.scss, available at
// runtime without a compile-time import.
.password-input {
// Positioning context for the absolutely-placed toggle. `display: flex` lets
// the inner <input> fill the row on its own (it replaces a bare <input> that
// used to be a direct flex child of the form column).
position: relative;
display: flex;
input {
flex: 1;
min-width: 0;
// Keep the caret and text from sliding under the icon: field padding on the
// left + the ~20px glyph + matching breathing room on the right.
padding-right: calc(var(--space-sm) + 20px + var(--space-sm));
}
}
// The eye button: sits flush inside the field's right edge, full height so its
// hit area matches the input, no surface of its own so it reads as part of the
// field rather than a second control.
.password-input__toggle {
position: absolute;
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-sm);
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
&:hover {
color: var(--color-text);
}
// lucide-react renders a 24×24 <svg> by default — bring it closer to the
// field's text size.
svg {
width: 20px;
height: 20px;
}
}

View file

@ -1,69 +0,0 @@
import { useEffect, useRef } from "react";
/**
* The mechanics every native `<dialog>`-based overlay in this app needs,
* factored out of `Dialog.tsx` once `BottomSheet.tsx` needed the exact same
* three behaviors with only its CSS differing: open via `showModal()` on
* mount, forward the native `close` event (covers Escape and any future
* `<form method="dialog">`) to the caller's own `onClose`, and treat a
* click that lands outside the panel's own content box as "close" (a click
* on the `<dialog>` element itself, since it fills the viewport, is what a
* backdrop click looks like — there's no separate `::backdrop` element to
* attach a listener to).
*
* Returns just the ref — callers still own their own JSX/className, this
* only owns the imperative wiring. No explicit return type annotation on
* purpose: `useRef<HTMLDialogElement>(null)`'s inferred type is exactly
* what JSX's `ref` prop expects, but spelling that same type out by hand
* (`RefObject<HTMLDialogElement | null>`) is subtly different and doesn't
* satisfy it — let inference carry it through instead of re-typing it.
*/
export function useNativeDialogBehavior(onClose: () => void) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
dialog.addEventListener("close", onClose);
return () => dialog.removeEventListener("close", onClose);
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// Attached imperatively (not a JSX `onClick`) since the click-to-close
// it implements is already reachable from the keyboard via Escape
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
// trip the "needs a matching keyboard handler" a11y lint for a
// non-interactive element even though one already exists, just not in
// a form that lint rule can see.
function handleClick(e: MouseEvent) {
// Re-read from the ref (not the outer `dialog` const) — TS can't
// carry that early-return narrowing into a nested function, since it
// can't prove the function won't run at some later point where it no
// longer holds (even though here, as an event listener on this same
// element, it trivially still does).
const current = dialogRef.current;
if (!current) return;
// A click lands on the `<dialog>` element itself both for the
// backdrop *and* for its own unfilled padding/margin — comparing
// against its content box (not just `e.target`) is what actually
// distinguishes "outside the panel" from "on it".
const rect = current.getBoundingClientRect();
const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) current.close();
}
dialog.addEventListener("click", handleClick);
return () => dialog.removeEventListener("click", handleClick);
}, []);
return dialogRef;
}

View file

@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
import { API_BASE_URL } from "../../api/client";
import "./update-banner.scss";
import { useAppUpdateCheck } from "./useAppUpdateCheck";
@ -11,13 +10,11 @@ import { useAppUpdateCheck } from "./useAppUpdateCheck";
* `<dialog>`: a version check is informative, never something that should
* block the rest of the page.
*
* "Mettre à jour" opens this API's own `/mobile/download` (a plain
* redirect straight to the latest release's actual `.apk`, resolved
* server-side — see `mobile.service.ts`) externally, rather than an in-app
* `PackageInstaller` flow: no `REQUEST_INSTALL_PACKAGES` permission, no
* extra native code, for a marginal UX gain (see issue #66's own
* hors-scope). The system browser follows the redirect and handles the
* download + Android's own install prompt.
* "Mettre à jour" opens the download link externally (the system browser
* handles the download + Android's own install prompt) rather than an
* in-app `PackageInstaller` flow — no `REQUEST_INSTALL_PACKAGES` permission,
* no extra native code, for a marginal UX gain (see issue #66's own
* hors-scope).
*/
export function UpdateBanner() {
const { t } = useTranslation();
@ -33,7 +30,7 @@ export function UpdateBanner() {
<div className="update-banner__actions">
<a
className="update-banner__update"
href={`${API_BASE_URL}/mobile/download`}
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest/download/batchcooking.apk"
target="_blank"
rel="noreferrer"
>

View file

@ -1,28 +1,18 @@
// Styles for UpdateBanner — a discreet, dismissible strip pinned to the top
// of the screen. Mounted app-wide in App.tsx (outside `<Routes>`) so the
// native update check runs session or not, hence `position: fixed` rather
// than the old in-flow placement inside `.app-content`: this way it never
// enters any routed page's layout / height math (`.app-layout` is
// `height: 100vh; overflow: hidden`), it just overlays. Native-only —
// renders nothing on the web (see UpdateBanner.tsx). Uses --color-warning
// the same way source-item-preview__hint does elsewhere: "worth noticing",
// Styles for UpdateBanner — a discreet, dismissible strip above the routed
// page content (see AppLayout.tsx). Uses --color-warning the same way
// source-item-preview__hint does elsewhere in the app: "worth noticing",
// not an error.
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
// Clears the Android status bar when the WebView draws under it.
padding-top: calc(var(--space-sm) + env(safe-area-inset-top, 0px));
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
border-bottom: 1px solid var(--color-warning);
border: 1px solid var(--color-warning);
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
&__text {

View file

@ -1,51 +1,27 @@
import { Capacitor } from "@capacitor/core";
import { useEffect, useState } from "react";
import { apiClient } from "../../api/client";
import { isNewerVersion } from "./version-compare";
/**
* 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). Zero new backend endpoint needed: the
* tag of the latest *tagged* release is exactly "the latest version" this
* check needs, and it's already publicly readable.
*/
const LATEST_RELEASE_URL =
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
/** How often a background check is allowed to actually hit the network — cold start and every resume-from-background otherwise re-checks needlessly. */
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt";
const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion";
/** Outcome of a single version comparison — shared by the silent background check and {@link useAppUpdateCheck}'s manual `checkNow`, so a caller showing immediate feedback (issue #86's "Vérifier les mises à jour" button) can switch on it directly instead of re-deriving it from `availableVersion`. */
export type AppUpdateCheckResult =
| { status: "up-to-date" }
| { status: "update-available"; version: string }
| { status: "error" };
/**
* Fetches the installed version (native `App.getInfo()`) and the latest
* tagged release, and compares them — the one place this comparison
* happens, shared by the background effect below and `checkNow`. No
* throttling and no `Preferences` side effects of its own; callers decide
* what to do with the result (persist a last-checked timestamp, filter
* against a dismissed version, etc.).
*/
async function fetchLatestVersionResult(): Promise<AppUpdateCheckResult> {
try {
const { App } = await import("@capacitor/app");
const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([
App.getInfo(),
apiClient.getLatestMobileVersion(),
]);
if (!latestTag || !isNewerVersion(installedVersion, latestTag)) {
return { status: "up-to-date" };
}
return { status: "update-available", version: latestTag };
} catch {
return { status: "error" };
}
}
/**
* Detects whether a newer version of the Android app has been tagged than
* the one currently installed, and offers a way to dismiss the notice for
* that specific version — plus, since issue #86, a manual `checkNow` for a
* "Vérifier les mises à jour" button (`AppSettingsPage`) that surfaces this
* same check's result immediately instead of waiting on the silent
* background timer.
* that specific version.
*
* **Native only** — on the web, "the app" is just whatever the server is
* currently serving; there is no "installed version" to compare against,
@ -54,37 +30,20 @@ async function fetchLatestVersionResult(): Promise<AppUpdateCheckResult> {
* none of them enter the web bundle (same pattern as `main.tsx`'s own
* `capacitor-token-store` bootstrap) — this hook is safe to call
* unconditionally from `AppLayout`, which renders on both platforms.
* `checkNow` itself still resolves on the web (`{ status: "error" }`,
* nothing to check) rather than rejecting — callers gate the *button* that
* would invoke it behind `Capacitor.isNativePlatform()` themselves
* (`AppSettingsPage` does), so this never actually runs there in practice.
*
* No Play Store integration exists (this app isn't distributed there) —
* Google's In-App Update API has no purchase here at all, so this is a
* from-scratch check, not a wrapper around it.
*
* The background effect fails silently on any network/parsing error — it
* Fails silently on any network/parsing error — a background version check
* must never surface an error banner or block anything, same posture as
* `ThemeContext`'s own preferences fetch. `checkNow` is the opposite: it's
* a direct response to an explicit click, so it returns its outcome
* (including `"error"`) for the caller to actually display.
* `ThemeContext`'s own preferences fetch.
*/
export function useAppUpdateCheck(): {
/** The newer version's tag (e.g. `"v1.4.2"`), or `null` when none is available (or none has been checked/parsed yet). */
availableVersion: string | null;
/** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */
dismiss: () => void;
/**
* Runs the check immediately, bypassing the background check's own
* throttle — an explicit "Vérifier les mises à jour" click should always
* hit the network, not silently no-op because a background check already
* ran recently. Also bypasses the dismissed-version filter: re-asking is
* itself a reason to show the result again, even for a version dismissed
* earlier. Updates `availableVersion` on `"update-available"` so the
* persistent {@link UpdateBanner} picks it up too, not just this call's
* own return value.
*/
checkNow: () => Promise<AppUpdateCheckResult>;
} {
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
@ -93,21 +52,32 @@ export function useAppUpdateCheck(): {
let cancelled = false;
async function backgroundCheck() {
async function checkForUpdate() {
try {
const { Preferences } = await import("@capacitor/preferences");
const [{ App }, { Preferences }] = await Promise.all([
import("@capacitor/app"),
import("@capacitor/preferences"),
]);
const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY });
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return;
const result = await fetchLatestVersionResult();
const [{ version: installedVersion }, response] = await Promise.all([
App.getInfo(),
fetch(LATEST_RELEASE_URL),
]);
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) });
if (result.status !== "update-available") return;
if (!response.ok) return;
const release = (await response.json()) as { tag_name?: string };
const latestTag = release.tag_name;
if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return;
const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY });
if (dismissedVersion === result.version) return;
if (dismissedVersion === latestTag) return;
if (!cancelled) setAvailableVersion(result.version);
if (!cancelled) setAvailableVersion(latestTag);
} catch {
// Silent — see this hook's own doc comment. Nothing to log to
// (apps/web has no client-side LoggerService equivalent yet), and a
@ -115,16 +85,16 @@ export function useAppUpdateCheck(): {
}
}
void backgroundCheck();
void checkForUpdate();
// Re-check whenever the app comes back to the foreground — the listener
// itself is cheap to keep registered, `backgroundCheck`'s own throttle
// itself is cheap to keep registered, `checkForUpdate`'s own throttle
// above is what actually keeps this from hitting the network every time.
let removeListener: (() => void) | undefined;
void import("@capacitor/app").then(({ App }) => {
if (cancelled) return;
App.addListener("appStateChange", ({ isActive }) => {
if (isActive) void backgroundCheck();
if (isActive) void checkForUpdate();
}).then((handle) => {
removeListener = () => void handle.remove();
});
@ -145,27 +115,5 @@ export function useAppUpdateCheck(): {
);
}
async function checkNow(): Promise<AppUpdateCheckResult> {
try {
const result = await fetchLatestVersionResult();
if (result.status === "update-available") setAvailableVersion(result.version);
// Best-effort — a manual check shouldn't make the *next* background
// check fire again immediately after. Not awaited: nothing the
// caller needs to wait on, and native-only anyway (a no-op import on
// the web build would just resolve to nothing useful).
void import("@capacitor/preferences").then(({ Preferences }) =>
Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }),
);
return result;
} catch {
// `fetchLatestVersionResult` already turns every failure it can hit
// into `{ status: "error" }` rather than throwing — this only exists
// to keep `checkNow`'s own contract (a result, never a rejection)
// true in case that ever changes, per the repo's async/try-catch
// convention (see specs/dev-conventions.md).
return { status: "error" };
}
}
return { availableVersion, dismiss, checkNow };
return { availableVersion, dismiss };
}

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