Compare commits
26 commits
feat/recip
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eb7308527 | |||
| 7d1ee934ca | |||
| 5a915a6cee | |||
| 9e847e15a1 | |||
| 0f8a640da2 | |||
| 9123f6358e | |||
| d1430498e0 | |||
| 429aa06ca6 | |||
| 26bd48bf4b | |||
| a02d289c92 | |||
| 09904cffc1 | |||
| 5a6ddfc287 | |||
| 16ceeda031 | |||
| 24bd2239fc | |||
| 0c4e8d089c | |||
| 51b7b8eec3 | |||
| 36d26c2739 | |||
| 2a3fb0434a | |||
| 61079b3a2a | |||
| 380f22d988 | |||
| b49ef5bb40 | |||
| 81b4a149ff | |||
| e8596a55bc | |||
| 019e3fb5e5 | |||
| ba030ec18c | |||
| c3560eccec |
85 changed files with 3840 additions and 815 deletions
19
.env.example
19
.env.example
|
|
@ -15,6 +15,14 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (docker-compose.yml), serving both the API and the built frontend.
|
||||||
# APP_PORT=3000
|
# APP_PORT=3000
|
||||||
|
|
||||||
|
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
|
||||||
|
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
|
||||||
|
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
|
||||||
|
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
|
||||||
|
# building locally instead (`docker compose build`, or `up` with no image
|
||||||
|
# present) — see docker-compose.yml's `image:`/`build:` comment.
|
||||||
|
# IMAGE_TAG=latest
|
||||||
|
|
||||||
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
||||||
# All optional: an instance that doesn't run the admin app needs none of
|
# All optional: an instance that doesn't run the admin app needs none of
|
||||||
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
||||||
|
|
@ -32,6 +40,17 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
# ADMIN_INITIAL_NAME=Ops
|
||||||
|
#
|
||||||
|
# Optional — lets an admin open a Forgejo ticket from a contact message
|
||||||
|
# (apps/web's /admin/messages, issue #81). All three needed together: the
|
||||||
|
# button stays hidden and the endpoint answers 503 unless all are set to a
|
||||||
|
# non-empty value. docker-compose.yml forwards these into the "app"
|
||||||
|
# container, so setting them here is enough (a blank value counts as
|
||||||
|
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
|
||||||
|
# scope — generate it in Forgejo, never commit the real value.
|
||||||
|
# FORGEJO_BASE_URL=https://git.example.com
|
||||||
|
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
||||||
|
# FORGEJO_REPO=owner/repo
|
||||||
|
|
||||||
# Optional — only set this to false if THIS deployment is served over
|
# Optional — only set this to false if THIS deployment is served over
|
||||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||||
|
|
|
||||||
66
.github/workflows/release.yml
vendored
66
.github/workflows/release.yml
vendored
|
|
@ -1,20 +1,34 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# No image registry involved: Portainer is wired to this repo's Git remote
|
# On a `vX.Y.Z` tag: build the three service images and push them to this
|
||||||
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
|
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
|
||||||
# that the image actually builds at the tagged commit, builds+attaches a
|
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
|
||||||
# signed Android APK, publishes a Forgejo Release, then (best-effort) pings
|
# and `latest`), build+attach a signed Android APK, publish a Forgejo
|
||||||
# Portainer's deploy webhook so it doesn't have to wait for its own polling
|
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
|
||||||
# interval.
|
# pull those prebuilt images (docker-compose.yml references them via
|
||||||
|
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
|
||||||
|
# keeps a `build:` fallback next to every `image:`.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
|
||||||
|
# (the `build-and-push-images` job below `docker login`s with it). If a
|
||||||
|
# future instance policy scopes the run token tighter, swap that login's
|
||||||
|
# password for a `write:package` PAT added as a repo secret.
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sanity-build:
|
# 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:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# This runner's job containers don't come with docker preinstalled —
|
# This runner's job containers don't come with docker preinstalled —
|
||||||
|
|
@ -25,10 +39,36 @@ jobs:
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
# Build only, no push, no registry — just confirms Portainer will be
|
- name: Build et push des 3 images vers le registre Forgejo
|
||||||
# able to build this same Dockerfile successfully at this tag before
|
env:
|
||||||
# anyone points it at this ref.
|
# The per-run token Forgejo Actions mints — has `packages: write`
|
||||||
- run: docker build -f apps/api/Dockerfile .
|
# via this workflow's `permissions:` block. `docker login` accepts
|
||||||
|
# it as the password for the actor who pushed the tag.
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
|
||||||
|
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
|
||||||
|
# registry namespace is the owner, lower-cased (Forgejo package
|
||||||
|
# names are case-insensitive but the CLI wants a stable form).
|
||||||
|
registry="${GITHUB_SERVER_URL#http*://}"
|
||||||
|
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
tag="$GITHUB_REF_NAME"
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
|
||||||
|
|
||||||
|
# Every Dockerfile's build context is the repo root (see
|
||||||
|
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
|
||||||
|
build_push() {
|
||||||
|
image="$registry/$owner/$1"
|
||||||
|
echo "::group::$1"
|
||||||
|
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
|
||||||
|
docker push "$image:$tag"
|
||||||
|
docker push "$image:latest"
|
||||||
|
echo "::endgroup::"
|
||||||
|
}
|
||||||
|
build_push batchcooking-app apps/api/Dockerfile
|
||||||
|
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
|
||||||
|
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
|
||||||
|
|
||||||
# Builds the same signed APK as ci.yml's build-android job (issue #64),
|
# 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
|
# with one difference: the version baked into the APK is derived from the
|
||||||
|
|
@ -124,11 +164,11 @@ jobs:
|
||||||
|
|
||||||
forgejo-release:
|
forgejo-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [sanity-build, build-android-apk]
|
needs: [build-and-push-images, build-android-apk]
|
||||||
steps:
|
steps:
|
||||||
# Ce runner ne fournit ni docker ni curl par défaut dans ses
|
# 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
|
# conteneurs de job — même constat déjà fait pour docker (voir
|
||||||
# sanity-build) et pour curl dans ci.yml's test job.
|
# build-and-push-images) et pour curl dans ci.yml's test job.
|
||||||
- name: Install curl
|
- name: Install curl
|
||||||
run: apt-get update && apt-get install -y curl
|
run: apt-get update && apt-get install -y curl
|
||||||
|
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -207,10 +207,18 @@ depuis la même origine. Les deux autres services du fichier
|
||||||
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
||||||
au host — voir leurs propres README pour leur rôle.
|
au host — voir leurs propres README pour leur rôle.
|
||||||
|
|
||||||
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les
|
||||||
production est reliée directement au dépôt Git et reconstruit elle-même
|
trois images de service — `batchcooking-app`, `batchcooking-intent-service`,
|
||||||
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo**
|
||||||
donc aucune image nulle part.
|
(`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`.
|
||||||
|
|
||||||
### Release (`.github/workflows/release.yml`)
|
### Release (`.github/workflows/release.yml`)
|
||||||
|
|
||||||
|
|
@ -221,14 +229,16 @@ git tag vX.Y.Z
|
||||||
git push --tags
|
git push --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
|
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à
|
||||||
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push
|
||||||
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
réussi vaut aussi vérification que tout build encore à ce commit),
|
||||||
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
|
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release`
|
||||||
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
|
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis
|
||||||
est configuré (sinon Portainer se resynchronise simplement à son prochain
|
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si
|
||||||
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se
|
||||||
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du
|
||||||
|
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
|
||||||
|
dépôt `PORTAINER_WEBHOOK_URL`.
|
||||||
|
|
||||||
## Application mobile (Android)
|
## Application mobile (Android)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 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";
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- AlterTable: off-catalog technique "placeholder" rows (issue #130, puce 3
|
||||||
|
-- de #3) — a user names a technique the catalog is missing while correcting a
|
||||||
|
-- step. Every existing row is a real seeded catalog entry, so the flag
|
||||||
|
-- defaults to false and the four new nullable columns stay NULL for them —
|
||||||
|
-- no backfill needed. Mirrors the `ingredients` placeholder migration.
|
||||||
|
ALTER TABLE "tech_step" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "tech_step" ADD COLUMN "display_name" TEXT;
|
||||||
|
ALTER TABLE "tech_step" ADD COLUMN "created_by_id" INTEGER;
|
||||||
|
ALTER TABLE "tech_step" ADD COLUMN "created_at" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "tech_step" ADD COLUMN "reviewed_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tech_step_is_placeholder_idx" ON "tech_step"("is_placeholder");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tech_step" ADD CONSTRAINT "tech_step_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -49,7 +49,6 @@ model Diet {
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
users UserProfile[]
|
users UserProfile[]
|
||||||
recipes RecipeDiet[]
|
|
||||||
ingredients IngredientDiet[]
|
ingredients IngredientDiet[]
|
||||||
|
|
||||||
@@map("diet")
|
@@map("diet")
|
||||||
|
|
@ -135,6 +134,9 @@ model UserProfile {
|
||||||
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
||||||
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
||||||
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
||||||
|
/// Placeholder `TechStep` rows this profile proposed while correcting a step
|
||||||
|
/// — see `TechStep.isPlaceholder`.
|
||||||
|
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
|
||||||
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
||||||
contactMessages ContactMessage[]
|
contactMessages ContactMessage[]
|
||||||
|
|
||||||
|
|
@ -375,7 +377,6 @@ model Recipe {
|
||||||
steps Step[]
|
steps Step[]
|
||||||
planningItems PlanningItem[]
|
planningItems PlanningItem[]
|
||||||
favoritedBy RecipeFavorite[]
|
favoritedBy RecipeFavorite[]
|
||||||
diets RecipeDiet[]
|
|
||||||
|
|
||||||
@@unique([sourceId, externalId])
|
@@unique([sourceId, externalId])
|
||||||
@@map("recipe")
|
@@map("recipe")
|
||||||
|
|
@ -396,21 +397,6 @@ model RecipeFavorite {
|
||||||
@@map("recipe_favorite")
|
@@map("recipe_favorite")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
|
||||||
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
|
||||||
/// manual reminder set by whoever creates/edits the recipe, not computed
|
|
||||||
/// from its ingredients.
|
|
||||||
model RecipeDiet {
|
|
||||||
recipeId Int @map("recipe_id")
|
|
||||||
dietId Int @map("diet_id")
|
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
||||||
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([recipeId, dietId])
|
|
||||||
@@map("recipe_diet")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
||||||
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
||||||
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
||||||
|
|
@ -700,12 +686,17 @@ model Unit {
|
||||||
model RecipeIngredient {
|
model RecipeIngredient {
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
ingredientId Int @map("ingredient_id")
|
ingredientId Int @map("ingredient_id")
|
||||||
quantity Decimal @db.Decimal(10, 2)
|
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
|
||||||
unitId Int @map("unit_id")
|
/// 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")
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
unit Unit @relation(fields: [unitId], references: [id])
|
unit Unit? @relation(fields: [unitId], references: [id])
|
||||||
|
|
||||||
@@id([recipeId, ingredientId])
|
@@id([recipeId, ingredientId])
|
||||||
@@map("recipe_ingredient")
|
@@map("recipe_ingredient")
|
||||||
|
|
@ -730,6 +721,33 @@ model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
|
/// `true` = a "placeholder" technique: a free-text technique a user typed
|
||||||
|
/// while correcting a step, because nothing in the seeded catalog matched
|
||||||
|
/// (issue #131 for the ingredient precedent this mirrors — see
|
||||||
|
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
|
||||||
|
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
|
||||||
|
/// training data of its own, and is excluded from
|
||||||
|
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
|
||||||
|
/// like any other technique (the correction that created it applies
|
||||||
|
/// immediately), but a maintainer reviews it at
|
||||||
|
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
|
||||||
|
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
|
||||||
|
isPlaceholder Boolean @default(false) @map("is_placeholder")
|
||||||
|
/// The exact text the user typed — `null` for a real catalog row (whose
|
||||||
|
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
|
||||||
|
/// `isPlaceholder`, enforced service-side.
|
||||||
|
displayName String? @map("display_name")
|
||||||
|
/// Profile that first proposed this technique — context for the admin
|
||||||
|
/// review. `onDelete: SetNull` so deleting an account never blocks on the
|
||||||
|
/// recipes still using its placeholder. `null` for a real catalog row.
|
||||||
|
createdById Int? @map("created_by_id")
|
||||||
|
/// When this placeholder was proposed. `null` for a real catalog row.
|
||||||
|
createdAt DateTime? @map("created_at")
|
||||||
|
/// Stamped once a maintainer has triaged this proposal
|
||||||
|
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
|
||||||
|
/// `null` while pending / for a real catalog row.
|
||||||
|
reviewedAt DateTime? @map("reviewed_at")
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps StepTechStep[]
|
||||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||||
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||||
|
|
@ -740,7 +758,10 @@ model TechStep {
|
||||||
/// Training-corpus suggestions targeting this technique — see
|
/// Training-corpus suggestions targeting this technique — see
|
||||||
/// `TechStepTrainingSuggestion`.
|
/// `TechStepTrainingSuggestion`.
|
||||||
trainingSuggestions TechStepTrainingSuggestion[]
|
trainingSuggestions TechStepTrainingSuggestion[]
|
||||||
|
/// The profile that proposed this row when it is a placeholder — see `createdById`.
|
||||||
|
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([isPlaceholder])
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -996,7 +1017,7 @@ model StepTechStepCorrection {
|
||||||
///
|
///
|
||||||
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
||||||
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
||||||
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
|
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
|
||||||
/// proposed once for a human to read, not ids referencing another catalog
|
/// proposed once for a human to read, not ids referencing another catalog
|
||||||
/// table, so there's nothing for a join table to normalize against.
|
/// table, so there's nothing for a join table to normalize against.
|
||||||
model TechStepTrainingSuggestion {
|
model TechStepTrainingSuggestion {
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,26 @@ import { z } from "zod";
|
||||||
// separate test database this now requires.
|
// separate test database this now requires.
|
||||||
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
|
||||||
|
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
|
||||||
|
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
|
||||||
|
* is `.dockerignore`d, so that passthrough is the container's only source
|
||||||
|
* of these. Fields that must *fail closed* on a blank value rather than run
|
||||||
|
* their own format check against `""` (and crash `parse()` at boot) wrap
|
||||||
|
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
|
||||||
|
* folds the same `"" -> undefined` step into its own `.transform`.
|
||||||
|
*/
|
||||||
|
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for every environment variable the API reads. Parsing (below)
|
* Schema for every environment variable the API reads. Parsing (below)
|
||||||
* fails fast at startup if something required is missing/invalid, instead
|
* fails fast at startup if something required is missing/invalid, instead
|
||||||
* of surfacing as a confusing runtime error later.
|
* 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.
|
||||||
*/
|
*/
|
||||||
const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
/** Port the HTTP server listens on. */
|
/** Port the HTTP server listens on. */
|
||||||
|
|
@ -152,14 +166,17 @@ const envSchema = z.object({
|
||||||
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
|
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
|
||||||
* rest of the app never talks to Forgejo.
|
* rest of the app never talks to Forgejo.
|
||||||
*/
|
*/
|
||||||
FORGEJO_BASE_URL: z.string().url().optional(),
|
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 personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
|
||||||
FORGEJO_TOKEN: z.string().optional(),
|
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. */
|
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
|
||||||
FORGEJO_REPO: z
|
FORGEJO_REPO: z.preprocess(
|
||||||
|
blankAsUnset,
|
||||||
|
z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
|
import {
|
||||||
|
listPlaceholdersQuerySchema,
|
||||||
|
listTechStepPlaceholdersQuerySchema,
|
||||||
|
markPlaceholdersReviewedSchema,
|
||||||
|
markTechStepPlaceholdersReviewedSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,12 +12,17 @@ import {
|
||||||
markPlaceholdersReviewed,
|
markPlaceholdersReviewed,
|
||||||
pruneOrphanPlaceholders,
|
pruneOrphanPlaceholders,
|
||||||
} from "./admin-catalog.service.js";
|
} from "./admin-catalog.service.js";
|
||||||
|
import {
|
||||||
|
listTechStepPlaceholders,
|
||||||
|
markTechStepPlaceholdersReviewed,
|
||||||
|
} from "./admin-tech-step-catalog.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
||||||
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
|
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
|
||||||
* "placeholders" users typed when the seeded catalog fell short, so a
|
* typed when the seeded catalog fell short — free-text ingredients on a
|
||||||
* maintainer can see what's missing and mark gaps as handled.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export const adminCatalogRouter = Router();
|
export const adminCatalogRouter = Router();
|
||||||
|
|
||||||
|
|
@ -42,3 +52,29 @@ adminCatalogRouter.post(
|
||||||
res.status(200).json(await pruneOrphanPlaceholders());
|
res.status(200).json(await pruneOrphanPlaceholders());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Missing techniques (issue #130) --------------------------------------
|
||||||
|
|
||||||
|
adminCatalogRouter.get(
|
||||||
|
"/tech-step-placeholders",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json(await listTechStepPlaceholders(listTechStepPlaceholdersQuerySchema.parse(req.query)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
adminCatalogRouter.patch(
|
||||||
|
"/tech-step-placeholders/mark-reviewed",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json(
|
||||||
|
await markTechStepPlaceholdersReviewed(
|
||||||
|
markTechStepPlaceholdersReviewedSchema.parse(req.body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/modules/admin/admin-users.routes.ts
Normal file
31
apps/api/src/modules/admin/admin-users.routes.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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));
|
||||||
|
}),
|
||||||
|
);
|
||||||
145
apps/api/src/modules/admin/admin-users.service.ts
Normal file
145
apps/api/src/modules/admin/admin-users.service.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { adminContactRouter } from "./admin-contact.routes.js";
|
||||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||||
import { adminToursRouter } from "./admin-tours.routes.js";
|
import { adminToursRouter } from "./admin-tours.routes.js";
|
||||||
|
import { adminUsersRouter } from "./admin-users.routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregator for the admin application's API surface, mounted at
|
* Aggregator for the admin application's API surface, mounted at
|
||||||
|
|
@ -28,3 +29,4 @@ adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||||
adminRouter.use("/catalog", adminCatalogRouter);
|
adminRouter.use("/catalog", adminCatalogRouter);
|
||||||
adminRouter.use("/tours", adminToursRouter);
|
adminRouter.use("/tours", adminToursRouter);
|
||||||
adminRouter.use("/contact", adminContactRouter);
|
adminRouter.use("/contact", adminContactRouter);
|
||||||
|
adminRouter.use("/users", adminUsersRouter);
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,12 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
||||||
unit: line.unit === null ? null : toUnitView(line.unit),
|
unit: line.unit === null ? null : toUnitView(line.unit),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
techStep: {
|
||||||
|
id: techStep.techStep.id,
|
||||||
|
key: techStep.techStep.key,
|
||||||
|
isPlaceholder: techStep.techStep.isPlaceholder,
|
||||||
|
displayName: techStep.techStep.displayName,
|
||||||
|
},
|
||||||
order: techStep.order,
|
order: techStep.order,
|
||||||
ingredients,
|
ingredients,
|
||||||
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|
@ -380,10 +381,20 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
|
||||||
start: correction.start,
|
start: correction.start,
|
||||||
end: correction.end,
|
end: correction.end,
|
||||||
previousTechStep: correction.previousTechStep
|
previousTechStep: correction.previousTechStep
|
||||||
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
? {
|
||||||
|
id: correction.previousTechStep.id,
|
||||||
|
key: correction.previousTechStep.key,
|
||||||
|
isPlaceholder: correction.previousTechStep.isPlaceholder,
|
||||||
|
displayName: correction.previousTechStep.displayName,
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
correctedTechStep: correction.correctedTechStep
|
correctedTechStep: correction.correctedTechStep
|
||||||
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
? {
|
||||||
|
id: correction.correctedTechStep.id,
|
||||||
|
key: correction.correctedTechStep.key,
|
||||||
|
isPlaceholder: correction.correctedTechStep.isPlaceholder,
|
||||||
|
displayName: correction.correctedTechStep.displayName,
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
createdAt: correction.createdAt.toISOString(),
|
createdAt: correction.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -449,7 +460,32 @@ export async function submitTechStepCorrection(
|
||||||
);
|
);
|
||||||
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
||||||
|
|
||||||
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
|
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 createdCorrection = await tx.stepTechStepCorrection.create({
|
const createdCorrection = await tx.stepTechStepCorrection.create({
|
||||||
data: {
|
data: {
|
||||||
stepId: step.id,
|
stepId: step.id,
|
||||||
|
|
@ -457,7 +493,7 @@ export async function submitTechStepCorrection(
|
||||||
start: input.start,
|
start: input.start,
|
||||||
end: input.end,
|
end: input.end,
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
previousTechStepId: input.previousTechStepId ?? null,
|
||||||
correctedTechStepId: input.correctedTechStepId ?? null,
|
correctedTechStepId: effectiveCorrectedTechStepId,
|
||||||
},
|
},
|
||||||
include: correctionInclude,
|
include: correctionInclude,
|
||||||
});
|
});
|
||||||
|
|
@ -467,7 +503,7 @@ export async function submitTechStepCorrection(
|
||||||
step.id,
|
step.id,
|
||||||
{ start: input.start, end: input.end },
|
{ start: input.start, end: input.end },
|
||||||
input.previousTechStepId ?? null,
|
input.previousTechStepId ?? null,
|
||||||
input.correctedTechStepId ?? null,
|
effectiveCorrectedTechStepId,
|
||||||
input.ingredients === undefined &&
|
input.ingredients === undefined &&
|
||||||
input.utensils === undefined &&
|
input.utensils === undefined &&
|
||||||
input.temperatures === undefined
|
input.temperatures === undefined
|
||||||
|
|
@ -524,8 +560,9 @@ export async function submitTechStepCorrection(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { correction: createdCorrection, techSteps: freshTechSteps };
|
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
analytics.recordEvent("tech_step.correction_submitted", {
|
analytics.recordEvent("tech_step.correction_submitted", {
|
||||||
actorId: correctorId,
|
actorId: correctorId,
|
||||||
|
|
@ -533,9 +570,20 @@ export async function submitTechStepCorrection(
|
||||||
recipeId,
|
recipeId,
|
||||||
stepId,
|
stepId,
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
previousTechStepId: input.previousTechStepId ?? null,
|
||||||
correctedTechStepId: input.correctedTechStepId ?? null,
|
correctedTechStepId: correction.correctedTechStepId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (placeholderTechStepId !== null) {
|
||||||
|
analytics.recordEvent("tech_step.placeholder_proposed", {
|
||||||
|
actorId: correctorId,
|
||||||
|
context: {
|
||||||
|
recipeId,
|
||||||
|
stepId,
|
||||||
|
techStepId: placeholderTechStepId,
|
||||||
|
name: input.correctedTechStepName ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
|
computeRecipeDietViews,
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
|
|
@ -29,7 +30,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
|
||||||
// is a parameter rather than baked into that module.
|
// is a parameter rather than baked into that module.
|
||||||
const DEFAULT_TECH_STEP_LOCALE = "fr";
|
const DEFAULT_TECH_STEP_LOCALE = "fr";
|
||||||
|
|
||||||
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
|
/** 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}. */
|
||||||
function recipeInclude(viewerId: number) {
|
function recipeInclude(viewerId: number) {
|
||||||
return {
|
return {
|
||||||
ingredients: {
|
ingredients: {
|
||||||
|
|
@ -74,18 +75,38 @@ function recipeInclude(viewerId: number) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diets: { include: { diet: true } },
|
|
||||||
favoritedBy: { where: { userProfileId: viewerId } },
|
favoritedBy: { where: { userProfileId: viewerId } },
|
||||||
} satisfies Prisma.RecipeInclude;
|
} satisfies Prisma.RecipeInclude;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every reference `Diet` row (`{ id, key }`), loaded once and memoized —
|
||||||
|
* `Diet` is seeded reference data that never changes at runtime, so caching
|
||||||
|
* it for the process's lifetime is safe. Used to attach a real `DietView` to
|
||||||
|
* the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to
|
||||||
|
* resolve the planning picker's `dietIds` filter / a household's members'
|
||||||
|
* regimes for the post-filter in {@link listRecipes}.
|
||||||
|
*/
|
||||||
|
let referenceDietsPromise: Promise<DietView[]> | null = null;
|
||||||
|
function loadReferenceDiets(): Promise<DietView[]> {
|
||||||
|
if (referenceDietsPromise === null) {
|
||||||
|
referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } });
|
||||||
|
}
|
||||||
|
return referenceDietsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
|
||||||
|
async function loadGlutenFreeDiet(): Promise<DietView | undefined> {
|
||||||
|
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
|
||||||
|
}
|
||||||
|
|
||||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||||
include: ReturnType<typeof recipeInclude>;
|
include: ReturnType<typeof recipeInclude>;
|
||||||
}>;
|
}>;
|
||||||
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
||||||
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||||
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
|
/** 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 = RecipeWithDetails["ingredients"][number]["unit"];
|
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>;
|
||||||
|
|
||||||
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
||||||
export function toUnitView(unit: UnitWithDetails): UnitView {
|
export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
|
|
@ -121,10 +142,6 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDietView(diet: { id: number; key: string }): DietView {
|
|
||||||
return { id: diet.id, key: diet.key };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
||||||
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||||
const byId = new Map<number, AllergyView>();
|
const byId = new Map<number, AllergyView>();
|
||||||
|
|
@ -136,10 +153,13 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||||
return [...byId.values()];
|
return [...byId.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
|
/** 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): RecipeSummaryView {
|
function toRecipeSummaryView(
|
||||||
const allergens = aggregateAllergens(
|
recipe: RecipeWithDetails,
|
||||||
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
|
glutenFreeDiet: DietView | undefined,
|
||||||
|
): RecipeSummaryView {
|
||||||
|
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
|
||||||
|
toIngredientView(recipeIngredient.ingredient),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: recipe.id,
|
id: recipe.id,
|
||||||
|
|
@ -149,8 +169,9 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||||
portions: recipe.portions,
|
portions: recipe.portions,
|
||||||
authorId: recipe.authorId,
|
authorId: recipe.authorId,
|
||||||
visibility: recipe.visibility,
|
visibility: recipe.visibility,
|
||||||
allergens,
|
allergens: aggregateAllergens(ingredientViews),
|
||||||
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
|
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`.
|
||||||
|
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
|
||||||
isFavorite: recipe.favoritedBy.length > 0,
|
isFavorite: recipe.favoritedBy.length > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +211,12 @@ export function toStepTechStepViews(
|
||||||
} = stepTechStep;
|
} = stepTechStep;
|
||||||
if (start === null || end === null) continue;
|
if (start === null || end === null) continue;
|
||||||
views.push({
|
views.push({
|
||||||
techStep: { id: techStep.id, key: techStep.key },
|
techStep: {
|
||||||
|
id: techStep.id,
|
||||||
|
key: techStep.key,
|
||||||
|
isPlaceholder: techStep.isPlaceholder,
|
||||||
|
displayName: techStep.displayName,
|
||||||
|
},
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
// `source` is a plain DB `String`, not a Prisma enum (see
|
// `source` is a plain DB `String`, not a Prisma enum (see
|
||||||
|
|
@ -240,15 +266,16 @@ export function toStepTechStepViews(
|
||||||
return views;
|
return views;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
|
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
|
||||||
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView {
|
||||||
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
||||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
quantity: Number(recipeIngredient.quantity),
|
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
|
||||||
unit: toUnitView(recipeIngredient.unit),
|
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity),
|
||||||
|
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
...toRecipeSummaryView(recipe),
|
...toRecipeSummaryView(recipe, glutenFreeDiet),
|
||||||
ingredients,
|
ingredients,
|
||||||
steps: recipe.steps.map((step) => ({
|
steps: recipe.steps.map((step) => ({
|
||||||
id: step.id,
|
id: step.id,
|
||||||
|
|
@ -301,54 +328,57 @@ function visibleToViewerWhere(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
|
* The two halves of the planning recipe picker's "convient à tout le foyer"
|
||||||
* and, for every member with a declared regime, are tagged with that
|
* toggle (`suitableForHousehold` on `listRecipes`):
|
||||||
* regime — the planning recipe picker's "convient à tout le foyer" toggle
|
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that
|
||||||
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
|
* contains an allergen declared by a member (still expressible in SQL).
|
||||||
* extra query to gather the household's members' allergy/regime ids)
|
* - `requiredDietKeys` — every regime declared by a member (`omnivore`
|
||||||
* rather than exposed to the client as raw per-member data: a member's
|
* dropped: it constrains nothing). A recipe must satisfy *all* of them —
|
||||||
* allergies/regime are private the same way visibility already keeps a
|
* a recipe suiting a vegetarian member doesn't automatically suit a
|
||||||
* recipe's existence private (404, never 403) — nothing here should let
|
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the
|
||||||
* one member infer another's medical/dietary info from the shape of a
|
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a
|
||||||
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
|
* stored column to query (issue #131).
|
||||||
* the schema already treats disliked ingredients as a taste preference,
|
*
|
||||||
* not a safety constraint (see that model's doc comment), so it doesn't
|
* Computed server-side (a small extra query for the members' allergy/regime
|
||||||
* belong in a filter framed around what's safe/appropriate to serve.
|
* 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).
|
||||||
*/
|
*/
|
||||||
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
|
async function suitableForHousehold(
|
||||||
|
houseId: number,
|
||||||
|
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
|
||||||
try {
|
try {
|
||||||
const members = await prisma.userProfile.findMany({
|
const members = await prisma.userProfile.findMany({
|
||||||
where: { houseId },
|
where: { houseId },
|
||||||
select: { dietId: true, allergies: { select: { allergyId: true } } },
|
select: {
|
||||||
|
diet: { select: { key: true } },
|
||||||
|
allergies: { select: { allergyId: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const requiredDietIds = [
|
const requiredDietKeys = [
|
||||||
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
|
...new Set(
|
||||||
|
members
|
||||||
|
.map((member) => member.diet?.key)
|
||||||
|
.filter((key): key is string => key !== undefined && key !== "omnivore"),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
const excludedAllergyIds = [
|
const excludedAllergyIds = [
|
||||||
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
|
...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))),
|
||||||
];
|
];
|
||||||
|
|
||||||
const conditions: Prisma.RecipeWhereInput[] = [];
|
const allergenWhere: Prisma.RecipeWhereInput =
|
||||||
if (requiredDietIds.length > 0) {
|
excludedAllergyIds.length > 0
|
||||||
// Every diet declared by a member must be among this recipe's tags —
|
? {
|
||||||
// not "at least one", since a recipe suiting a vegetarian member
|
|
||||||
// doesn't automatically suit a gluten-free one too.
|
|
||||||
conditions.push({
|
|
||||||
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (excludedAllergyIds.length > 0) {
|
|
||||||
conditions.push({
|
|
||||||
ingredients: {
|
ingredients: {
|
||||||
none: {
|
none: {
|
||||||
ingredient: {
|
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } },
|
||||||
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return { AND: conditions };
|
: {};
|
||||||
|
return { allergenWhere, requiredDietKeys };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||||
|
|
@ -382,7 +412,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
|
||||||
).map((row) => row.sourceId);
|
).map((row) => row.sourceId);
|
||||||
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,11 +426,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
|
||||||
export interface ListRecipesFilters {
|
export interface ListRecipesFilters {
|
||||||
/** Case-insensitive name substring. */
|
/** Case-insensitive name substring. */
|
||||||
search?: string;
|
search?: string;
|
||||||
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
|
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
|
||||||
suitableForHousehold?: boolean;
|
suitableForHousehold?: boolean;
|
||||||
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
|
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
|
||||||
ingredientIds?: number[];
|
ingredientIds?: number[];
|
||||||
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
|
/** 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). */
|
||||||
dietIds?: number[];
|
dietIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,31 +450,32 @@ export async function listRecipes(
|
||||||
filters: ListRecipesFilters = {},
|
filters: ListRecipesFilters = {},
|
||||||
): Promise<RecipeSummaryView[]> {
|
): Promise<RecipeSummaryView[]> {
|
||||||
try {
|
try {
|
||||||
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters;
|
||||||
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||||
}
|
}
|
||||||
|
// Regimes are computed from ingredients, not a stored column (issue
|
||||||
|
// #131), so the household-regime and `dietIds` filters can't be SQL
|
||||||
|
// anymore — they run as post-filters on the mapped summaries below.
|
||||||
|
// Everything that still *can* be a `where` stays one.
|
||||||
|
let requiredDietKeys: string[] = [];
|
||||||
// No-op without a household — nothing to filter against, same posture as
|
// No-op without a household — nothing to filter against, same posture as
|
||||||
// the `foyer` tab returning everything it can rather than throwing.
|
// the `foyer` tab returning everything it can rather than throwing.
|
||||||
if (suitableForHousehold && viewerHouseId !== null) {
|
if (suitable && viewerHouseId !== null) {
|
||||||
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
|
const household = await suitableForHousehold(viewerHouseId);
|
||||||
|
conditions.push(household.allergenWhere);
|
||||||
|
requiredDietKeys = household.requiredDietKeys;
|
||||||
}
|
}
|
||||||
if (ingredientIds && ingredientIds.length > 0) {
|
if (ingredientIds && ingredientIds.length > 0) {
|
||||||
// One condition per required id (AND) — a recipe must carry all of
|
// One condition per required id (AND) — a recipe must carry all of
|
||||||
// them, not just one, same "every one, not any one" posture as
|
// them, not just one.
|
||||||
// suitableForHouseholdWhere's requiredDietIds.
|
|
||||||
conditions.push({
|
conditions.push({
|
||||||
AND: ingredientIds.map((ingredientId) => ({
|
AND: ingredientIds.map((ingredientId) => ({
|
||||||
ingredients: { some: { ingredientId } },
|
ingredients: { some: { ingredientId } },
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (dietIds && dietIds.length > 0) {
|
|
||||||
conditions.push({
|
|
||||||
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "favoris":
|
case "favoris":
|
||||||
|
|
@ -464,14 +495,38 @@ export async function listRecipes(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipes = await prisma.recipe.findMany({
|
const [recipes, referenceDiets] = await Promise.all([
|
||||||
|
prisma.recipe.findMany({
|
||||||
where: { AND: conditions },
|
where: { AND: conditions },
|
||||||
include: recipeInclude(viewerId),
|
include: recipeInclude(viewerId),
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
}),
|
||||||
return recipes.map(toRecipeSummaryView);
|
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;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,23 +545,23 @@ export async function getRecipe(
|
||||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
}
|
}
|
||||||
return toRecipeView(recipe);
|
return toRecipeView(recipe, await loadGlutenFreeDiet());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a recipe with its ingredients, ordered steps and diet tags in one
|
* Creates a recipe with its ingredients and ordered steps in one go —
|
||||||
* go — steps' `order` is derived from their position in `input.steps`,
|
* steps' `order` is derived from their position in `input.steps`,
|
||||||
* ingredients reference existing reference `Ingredient` rows by id (see
|
* ingredients reference existing reference `Ingredient` rows by id (see
|
||||||
* `GET /reference/ingredients`; there's no way to create one here).
|
* `GET /reference/ingredients`; there's no way to create one here). Its
|
||||||
* `authorId`/`authorHouseId` are fixed at creation and never change on
|
* regimes aren't stored — they're computed from the ingredients on read
|
||||||
* later edits (see {@link updateRecipe}).
|
* (issue #131). `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 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
|
||||||
*/
|
*/
|
||||||
export async function createRecipe(
|
export async function createRecipe(
|
||||||
input: CreateRecipeInput,
|
input: CreateRecipeInput,
|
||||||
|
|
@ -516,7 +571,7 @@ export async function createRecipe(
|
||||||
try {
|
try {
|
||||||
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -533,7 +588,6 @@ export async function createRecipe(
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
|
||||||
*/
|
*/
|
||||||
export async function createImportedRecipe(
|
export async function createImportedRecipe(
|
||||||
input: CreateRecipeInput,
|
input: CreateRecipeInput,
|
||||||
|
|
@ -544,7 +598,7 @@ export async function createImportedRecipe(
|
||||||
try {
|
try {
|
||||||
return await createRecipeInternal(input, authorId, authorHouseId, source);
|
return await createRecipeInternal(input, authorId, authorHouseId, source);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -578,11 +632,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. */
|
/** 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. */
|
||||||
interface ResolvedIngredientLine {
|
interface ResolvedIngredientLine {
|
||||||
ingredientId: number;
|
ingredientId: number;
|
||||||
quantity: number;
|
quantity: number | null;
|
||||||
unitId: number;
|
unitId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -615,8 +669,8 @@ async function resolveIngredientLines(
|
||||||
if (line.ingredientId !== undefined) {
|
if (line.ingredientId !== undefined) {
|
||||||
resolved.push({
|
resolved.push({
|
||||||
ingredientId: line.ingredientId,
|
ingredientId: line.ingredientId,
|
||||||
quantity: line.quantity,
|
quantity: line.quantity ?? null,
|
||||||
unitId: line.unitId,
|
unitId: line.unitId ?? null,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -632,12 +686,16 @@ async function resolveIngredientLines(
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
|
resolved.push({
|
||||||
|
ingredientId: placeholder.id,
|
||||||
|
quantity: line.quantity ?? null,
|
||||||
|
unitId: line.unitId ?? null,
|
||||||
|
});
|
||||||
createdPlaceholders.push({ id: placeholder.id, name });
|
createdPlaceholders.push({ id: placeholder.id, name });
|
||||||
}
|
}
|
||||||
return { resolved, createdPlaceholders };
|
return { resolved, createdPlaceholders };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -651,8 +709,9 @@ async function createRecipeInternal(
|
||||||
await assertIngredientsExist(
|
await assertIngredientsExist(
|
||||||
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
|
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
|
||||||
);
|
);
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(
|
||||||
await assertDietsExist(input.dietIds);
|
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
||||||
|
);
|
||||||
// Matched up front (one call per step, in parallel) rather than inline
|
// Matched up front (one call per step, in parallel) rather than inline
|
||||||
// inside the `steps.create` map below — `techStepClassifier` is async
|
// inside the `steps.create` map below — `techStepClassifier` is async
|
||||||
// (a trained model, not a pure regex test), so its result has to
|
// (a trained model, not a pure regex test), so its result has to
|
||||||
|
|
@ -702,7 +761,6 @@ async function createRecipeInternal(
|
||||||
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
|
||||||
},
|
},
|
||||||
include: recipeInclude(authorId),
|
include: recipeInclude(authorId),
|
||||||
});
|
});
|
||||||
|
|
@ -720,24 +778,25 @@ async function createRecipeInternal(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return toRecipeView(created);
|
return toRecipeView(created, await loadGlutenFreeDiet());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||||
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
* and the complete ingredient/step lists (not a partial merge: a line
|
||||||
* missing from `input` is removed, same contract as `PATCH
|
* missing from `input` is removed, same contract as `PATCH
|
||||||
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
* /profile/allergies`). Regimes aren't part of the payload — they're
|
||||||
* never transfers ownership.
|
* recomputed from the ingredients on the next read (issue #131).
|
||||||
|
* `authorId`/`authorHouseId` are untouched — editing never transfers
|
||||||
|
* ownership.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
|
||||||
*/
|
*/
|
||||||
export async function updateRecipe(
|
export async function updateRecipe(
|
||||||
id: number,
|
id: number,
|
||||||
|
|
@ -752,8 +811,9 @@ export async function updateRecipe(
|
||||||
.map((i) => i.ingredientId)
|
.map((i) => i.ingredientId)
|
||||||
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
|
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
|
||||||
);
|
);
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(
|
||||||
await assertDietsExist(input.dietIds);
|
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
||||||
|
);
|
||||||
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||||
|
|
||||||
// Interactive transaction (not the array form) so any new free-text
|
// Interactive transaction (not the array form) so any new free-text
|
||||||
|
|
@ -764,7 +824,6 @@ export async function updateRecipe(
|
||||||
const createdPlaceholders = await prisma.$transaction(async (tx) => {
|
const createdPlaceholders = await prisma.$transaction(async (tx) => {
|
||||||
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
||||||
await tx.step.deleteMany({ where: { recipeId: id } });
|
await tx.step.deleteMany({ where: { recipeId: id } });
|
||||||
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
|
|
||||||
const { resolved, createdPlaceholders } = await resolveIngredientLines(
|
const { resolved, createdPlaceholders } = await resolveIngredientLines(
|
||||||
input.ingredients,
|
input.ingredients,
|
||||||
viewerId,
|
viewerId,
|
||||||
|
|
@ -799,7 +858,6 @@ export async function updateRecipe(
|
||||||
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return createdPlaceholders;
|
return createdPlaceholders;
|
||||||
|
|
@ -812,9 +870,9 @@ export async function updateRecipe(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -847,7 +905,7 @@ export async function deleteRecipe(
|
||||||
|
|
||||||
await prisma.recipe.delete({ where: { id } });
|
await prisma.recipe.delete({ where: { id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -875,7 +933,7 @@ export async function addFavorite(
|
||||||
create: { userProfileId: viewerId, recipeId: id },
|
create: { userProfileId: viewerId, recipeId: id },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -886,7 +944,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
|
||||||
where: { userProfileId: viewerId, recipeId: id },
|
where: { userProfileId: viewerId, recipeId: id },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -909,7 +967,7 @@ export async function assertRecipeVisible(
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -925,7 +983,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
|
||||||
}
|
}
|
||||||
return recipe;
|
return recipe;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -944,7 +1002,7 @@ async function assertIsAuthor(
|
||||||
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -966,7 +1024,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -988,29 +1046,6 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHousehold()'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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,21 @@ export async function getUnits(): Promise<UnitView[]> {
|
||||||
/**
|
/**
|
||||||
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
||||||
* for why) — small, static list (see `reference-seed-data.ts`'s
|
* for why) — small, static list (see `reference-seed-data.ts`'s
|
||||||
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
* `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.
|
||||||
*/
|
*/
|
||||||
export async function getTechSteps(): Promise<TechStepView[]> {
|
export async function getTechSteps(): Promise<TechStepView[]> {
|
||||||
try {
|
try {
|
||||||
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
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 }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see getDiets()'s catch comment above
|
throw err; // see getDiets()'s catch comment above
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
|
||||||
include: ReturnType<typeof shoppingListPlanningInclude>;
|
include: ReturnType<typeof shoppingListPlanningInclude>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
|
/** 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. */
|
||||||
interface RunningTotal {
|
interface RunningTotal {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
unit: UnitView;
|
unit: UnitView | null;
|
||||||
quantity: number;
|
quantity: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,16 +70,31 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const scale = item.portions / item.recipe.portions;
|
const scale = item.portions / item.recipe.portions;
|
||||||
for (const recipeIngredient of item.recipe.ingredients) {
|
for (const recipeIngredient of item.recipe.ingredients) {
|
||||||
|
// A "selon le goût" seasoning line (no quantity/unit) — surfaced once
|
||||||
|
// per ingredient, never scaled or summed. Its key can't collide with a
|
||||||
|
// real `(ingredient, unit)` line for the same ingredient.
|
||||||
|
if (recipeIngredient.quantity === null) {
|
||||||
|
const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`;
|
||||||
|
if (!totals.has(seasoningKey)) {
|
||||||
|
totals.set(seasoningKey, {
|
||||||
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
|
unit: null,
|
||||||
|
quantity: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
||||||
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
||||||
|
|
||||||
const existing = totals.get(key);
|
const existing = totals.get(key);
|
||||||
if (existing) {
|
if (existing && existing.quantity !== null) {
|
||||||
existing.quantity += addedQuantity;
|
existing.quantity += addedQuantity;
|
||||||
} else {
|
} else {
|
||||||
totals.set(key, {
|
totals.set(key, {
|
||||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
unit: toUnitView(recipeIngredient.unit),
|
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
||||||
quantity: addedQuantity,
|
quantity: addedQuantity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,20 @@ export async function previewSourceItem(
|
||||||
),
|
),
|
||||||
loadIngredientCatalog(adapter.locale),
|
loadIngredientCatalog(adapter.locale),
|
||||||
loadUnitCatalog(adapter.locale),
|
loadUnitCatalog(adapter.locale),
|
||||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
// 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 },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
const techStepById = new Map(
|
||||||
|
techStepsByKey.map((techStep) => [
|
||||||
|
techStep.id,
|
||||||
|
{ ...techStep, isPlaceholder: false, displayName: null },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const translatedIngredients = translateRecipeIngredients(
|
const translatedIngredients = translateRecipeIngredients(
|
||||||
parsed.ingredients,
|
parsed.ingredients,
|
||||||
|
|
|
||||||
|
|
@ -139,3 +139,118 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/admin/api/catalog/tech-step-placeholders` — the missing-technique review
|
||||||
|
* (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route
|
||||||
|
* behind `requireAdmin`.
|
||||||
|
*/
|
||||||
|
describe("Admin catalog — missing techniques", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function adminAgent() {
|
||||||
|
const { email, password } = await seedAdmin();
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */
|
||||||
|
async function seedPlaceholderTechnique(name: string): Promise<number> {
|
||||||
|
const author = await prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
firstName: "T",
|
||||||
|
lastName: "A",
|
||||||
|
email: `${faker.string.uuid()}@example.test`,
|
||||||
|
passwordHash: "x",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const techStep = await prisma.techStep.create({
|
||||||
|
data: {
|
||||||
|
key: `placeholder:${faker.string.uuid()}`,
|
||||||
|
isPlaceholder: true,
|
||||||
|
displayName: name,
|
||||||
|
createdById: author.id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: faker.lorem.words(3),
|
||||||
|
authorId: author.id,
|
||||||
|
portions: 2,
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
steps: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Une étape.",
|
||||||
|
order: 0,
|
||||||
|
techSteps: {
|
||||||
|
create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return techStep.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects both routes without an admin session", async () => {
|
||||||
|
expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal(
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request(app)
|
||||||
|
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
||||||
|
.send({ techStepIds: [1] })
|
||||||
|
).status,
|
||||||
|
).to.equal(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists proposed techniques with their recipe count, impact-ordered", async () => {
|
||||||
|
await seedPlaceholderTechnique("Flamber au chalumeau");
|
||||||
|
await seedPlaceholderTechnique("Confire à basse température");
|
||||||
|
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const res = await agent.get("/admin/api/catalog/tech-step-placeholders");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(2);
|
||||||
|
expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([
|
||||||
|
"Flamber au chalumeau",
|
||||||
|
"Confire à basse température",
|
||||||
|
]);
|
||||||
|
expect(res.body[0].recipeCount).to.equal(1);
|
||||||
|
expect(res.body[0].reviewedAt).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => {
|
||||||
|
const id = await seedPlaceholderTechnique("Snacker à la plancha");
|
||||||
|
const agent = await adminAgent();
|
||||||
|
|
||||||
|
const patched = await agent
|
||||||
|
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
||||||
|
.send({ techStepIds: [id] });
|
||||||
|
expect(patched.status).to.equal(200);
|
||||||
|
expect(patched.body.reviewed).to.equal(1);
|
||||||
|
expect(
|
||||||
|
(await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
||||||
|
).to.be.an.instanceOf(Date);
|
||||||
|
|
||||||
|
expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0);
|
||||||
|
const reviewed = await agent
|
||||||
|
.get("/admin/api/catalog/tech-step-placeholders")
|
||||||
|
.query({ reviewed: "true" });
|
||||||
|
expect(reviewed.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
160
apps/api/test/admin-users.test.ts
Normal file
160
apps/api/test/admin-users.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
59
apps/api/test/env.test.ts
Normal file
59
apps/api/test/env.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
apps/api/test/recipe-diets.test.ts
Normal file
86
apps/api/test/recipe-diets.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { computeRecipeDietViews, type DietView, type IngredientView } from "@batch-cooking/shared";
|
||||||
|
import { expect } from "chai";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure regime derivation from `@batch-cooking/shared` — no DB, so this suite
|
||||||
|
* skips `resetDatabase`. `recipe.service.ts`'s `toRecipeSummaryView` calls
|
||||||
|
* it for every recipe read (issue #131 dropped the hand-tagged `RecipeDiet`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VEGETARIAN: DietView = { id: 1, key: "vegetarian" };
|
||||||
|
const VEGAN: DietView = { id: 2, key: "vegan" };
|
||||||
|
const PESCATARIAN: DietView = { id: 3, key: "pescatarian" };
|
||||||
|
const GLUTEN_FREE: DietView = { id: 4, key: "glutenFree" };
|
||||||
|
|
||||||
|
/** A minimal ingredient fixture — only the fields `computeRecipeDietViews` reads. */
|
||||||
|
function ingredient(
|
||||||
|
diets: DietView[],
|
||||||
|
opts: { gluten?: boolean; placeholder?: boolean } = {},
|
||||||
|
): Pick<IngredientView, "diets" | "allergens" | "isPlaceholder"> {
|
||||||
|
return {
|
||||||
|
diets,
|
||||||
|
allergens: opts.gluten ? [{ id: 9, key: "gluten", kind: "INTOLERANCE" }] : [],
|
||||||
|
isPlaceholder: opts.placeholder ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeRecipeDietViews", () => {
|
||||||
|
it("keeps only the regimes every ingredient is compatible with", () => {
|
||||||
|
const result = computeRecipeDietViews(
|
||||||
|
[
|
||||||
|
ingredient([VEGETARIAN, VEGAN, PESCATARIAN]),
|
||||||
|
ingredient([VEGETARIAN, PESCATARIAN]), // e.g. egg — not vegan
|
||||||
|
],
|
||||||
|
GLUTEN_FREE,
|
||||||
|
);
|
||||||
|
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "pescatarian", "glutenFree"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops every ingredient-derived regime when one ingredient is compatible with none", () => {
|
||||||
|
const result = computeRecipeDietViews(
|
||||||
|
[ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([])],
|
||||||
|
GLUTEN_FREE,
|
||||||
|
);
|
||||||
|
// Still gluten-free — nothing carries the allergen.
|
||||||
|
expect(result.map((d) => d.key)).to.deep.equal(["glutenFree"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not gluten-free when any ingredient carries the gluten allergen", () => {
|
||||||
|
const result = computeRecipeDietViews(
|
||||||
|
[
|
||||||
|
ingredient([VEGETARIAN, VEGAN, PESCATARIAN]),
|
||||||
|
ingredient([VEGETARIAN, VEGAN, PESCATARIAN], { gluten: true }),
|
||||||
|
],
|
||||||
|
GLUTEN_FREE,
|
||||||
|
);
|
||||||
|
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for a recipe with any placeholder (off-catalog) ingredient", () => {
|
||||||
|
const result = computeRecipeDietViews(
|
||||||
|
[ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([], { placeholder: true })],
|
||||||
|
GLUTEN_FREE,
|
||||||
|
);
|
||||||
|
expect(result).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] when there are no ingredients", () => {
|
||||||
|
expect(computeRecipeDietViews([], GLUTEN_FREE)).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits gluten-free entirely when no glutenFree DietView is supplied", () => {
|
||||||
|
const result = computeRecipeDietViews(
|
||||||
|
[ingredient([VEGAN, VEGETARIAN, PESCATARIAN])],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("borrows each regime's DietView (id + key) from the ingredients themselves", () => {
|
||||||
|
const result = computeRecipeDietViews(
|
||||||
|
[ingredient([VEGETARIAN]), ingredient([VEGETARIAN])],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(result).to.deep.equal([VEGETARIAN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -62,7 +62,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Poulet basquaise",
|
name: "Poulet basquaise",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomato, quantity: 3, unitId: piece },
|
{ ingredientId: tomato, quantity: 3, unitId: piece },
|
||||||
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
|
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
|
||||||
|
|
@ -99,7 +98,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
await agent.post("/recipes").send({
|
await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
|
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "x" }],
|
steps: [{ description: "x" }],
|
||||||
});
|
});
|
||||||
|
|
@ -122,7 +120,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Édition",
|
name: "Édition",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
|
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "x" }],
|
steps: [{ description: "x" }],
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +129,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
|
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Édition",
|
name: "Édition",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
|
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
|
||||||
{ ingredientId: tomato, quantity: 1, unitId: piece },
|
{ ingredientId: tomato, quantity: 1, unitId: piece },
|
||||||
|
|
@ -146,7 +142,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
await agent.patch(`/recipes/${created.body.id}`).send({
|
await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Édition",
|
name: "Édition",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "x" }],
|
steps: [{ description: "x" }],
|
||||||
});
|
});
|
||||||
|
|
@ -163,7 +158,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Invalide",
|
name: "Invalide",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
|
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
|
||||||
],
|
],
|
||||||
|
|
@ -181,7 +175,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Doublons libres",
|
name: "Doublons libres",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
|
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
|
||||||
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
|
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
|
||||||
|
|
|
||||||
|
|
@ -312,20 +312,16 @@ describe("Recipes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /recipes", () => {
|
describe("POST /recipes", () => {
|
||||||
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
|
it("creates a recipe with its ingredients and ordered steps, and computes its regimes from the ingredients", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const oeuf = await ingredientId("egg");
|
const oeuf = await ingredientId("egg");
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
const vegetarien = await prisma.diet.findFirstOrThrow({
|
|
||||||
where: { key: "vegetarian" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Omelette provençale",
|
name: "Omelette provençale",
|
||||||
description: "Rapide et savoureuse",
|
description: "Rapide et savoureuse",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [vegetarien.id],
|
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||||
{ ingredientId: oeuf, quantity: 3, unitId: piece },
|
{ ingredientId: oeuf, quantity: 3, unitId: piece },
|
||||||
|
|
@ -343,7 +339,84 @@ describe("Recipes", () => {
|
||||||
).to.deep.equal([0, 1]);
|
).to.deep.equal([0, 1]);
|
||||||
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
|
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
|
||||||
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
|
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
|
||||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
|
// 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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
|
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
|
||||||
|
|
@ -355,7 +428,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -367,7 +439,6 @@ describe("Recipes", () => {
|
||||||
name: "Foyer",
|
name: "Foyer",
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -386,7 +457,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description }],
|
steps: [{ description }],
|
||||||
});
|
});
|
||||||
|
|
@ -399,7 +469,12 @@ describe("Recipes", () => {
|
||||||
// response itself carries exactly what to highlight, not just the id.
|
// response itself carries exactly what to highlight, not just the id.
|
||||||
const resStep = res.body.steps[0];
|
const resStep = res.body.steps[0];
|
||||||
expect(resStep.techSteps).to.have.length(1);
|
expect(resStep.techSteps).to.have.length(1);
|
||||||
expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
|
expect(resStep.techSteps[0].techStep).to.deep.equal({
|
||||||
|
id: simmer,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
const { start, end } = resStep.techSteps[0];
|
const { start, end } = resStep.techSteps[0];
|
||||||
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
|
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
|
||||||
});
|
});
|
||||||
|
|
@ -413,7 +488,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description }],
|
steps: [{ description }],
|
||||||
});
|
});
|
||||||
|
|
@ -448,7 +522,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Servir immédiatement" }],
|
steps: [{ description: "Servir immédiatement" }],
|
||||||
});
|
});
|
||||||
|
|
@ -467,7 +540,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [
|
steps: [
|
||||||
{ description: "Hacher les oignons" },
|
{ description: "Hacher les oignons" },
|
||||||
|
|
@ -497,7 +569,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
|
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
|
||||||
steps: [{ description: "Cuire au four pendant 30 minutes" }],
|
steps: [{ description: "Cuire au four pendant 30 minutes" }],
|
||||||
|
|
@ -519,7 +590,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Poêlée",
|
name: "Poêlée",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
// The case that motivated the sequence model: one instruction, two techniques.
|
// The case that motivated the sequence model: one instruction, two techniques.
|
||||||
steps: [{ description }],
|
steps: [{ description }],
|
||||||
|
|
@ -550,7 +620,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -566,7 +635,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -575,30 +643,12 @@ describe("Recipes", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Test",
|
|
||||||
portions: 4,
|
|
||||||
dietIds: [999_999],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
||||||
steps: [{ description: "Étape" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -621,7 +671,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomate, quantity: 1, unitId: piece },
|
{ ingredientId: tomate, quantity: 1, unitId: piece },
|
||||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||||
|
|
@ -639,7 +688,6 @@ describe("Recipes", () => {
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
name: "Test",
|
name: "Test",
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
};
|
};
|
||||||
|
|
@ -652,6 +700,55 @@ describe("Recipes", () => {
|
||||||
expect(zero.status).to.equal(400);
|
expect(zero.status).to.equal(400);
|
||||||
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a « selon le goût » seasoning line with no quantity or unit (issue #128)", async () => {
|
||||||
|
const { agent } = await signup();
|
||||||
|
const tomate = await ingredientId("tomato");
|
||||||
|
const sel = await ingredientId("salt");
|
||||||
|
const gram = await unitId("gram");
|
||||||
|
|
||||||
|
const res = await agent.post("/recipes").send({
|
||||||
|
name: "Tomates au sel",
|
||||||
|
portions: 2,
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: tomate, quantity: 200, unitId: gram },
|
||||||
|
{ ingredientId: sel }, // selon le goût — no quantity/unit
|
||||||
|
],
|
||||||
|
steps: [{ description: "Saler les tomates" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
const seasoning = res.body.ingredients.find(
|
||||||
|
(line: { ingredient: { key: string } }) => line.ingredient.key === "salt",
|
||||||
|
);
|
||||||
|
expect(seasoning.quantity).to.equal(null);
|
||||||
|
expect(seasoning.unit).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an ingredient line with a quantity but no unit (or vice versa) with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const { agent } = await signup();
|
||||||
|
const tomate = await ingredientId("tomato");
|
||||||
|
const gram = await unitId("gram");
|
||||||
|
const base = {
|
||||||
|
name: "Test",
|
||||||
|
portions: 2,
|
||||||
|
steps: [{ description: "Étape" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const quantityOnly = await agent.post("/recipes").send({
|
||||||
|
...base,
|
||||||
|
ingredients: [{ ingredientId: tomate, quantity: 100 }],
|
||||||
|
});
|
||||||
|
const unitOnly = await agent.post("/recipes").send({
|
||||||
|
...base,
|
||||||
|
ingredients: [{ ingredientId: tomate, unitId: gram }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(quantityOnly.status).to.equal(400);
|
||||||
|
expect(quantityOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
expect(unitOnly.status).to.equal(400);
|
||||||
|
expect(unitOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /recipes/:id", () => {
|
describe("GET /recipes/:id", () => {
|
||||||
|
|
@ -671,7 +768,6 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
@ -757,7 +853,6 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
@ -766,7 +861,6 @@ describe("Recipes", () => {
|
||||||
name: "Salade composée",
|
name: "Salade composée",
|
||||||
portions: 6,
|
portions: 6,
|
||||||
visibility: "PUBLIC",
|
visibility: "PUBLIC",
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
|
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
|
||||||
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
||||||
});
|
});
|
||||||
|
|
@ -781,6 +875,36 @@ describe("Recipes", () => {
|
||||||
expect(res.body.steps).to.have.length(2);
|
expect(res.body.steps).to.have.length(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries a « selon le goût » seasoning line through an edit (issue #128)", async () => {
|
||||||
|
const { agent } = await signup();
|
||||||
|
const tomate = await ingredientId("tomato");
|
||||||
|
const poivre = await ingredientId("blackPepper");
|
||||||
|
const gram = await unitId("gram");
|
||||||
|
const created = await agent.post("/recipes").send({
|
||||||
|
name: "Salade",
|
||||||
|
portions: 4,
|
||||||
|
ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }],
|
||||||
|
steps: [{ description: "Couper" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
|
name: "Salade poivrée",
|
||||||
|
portions: 4,
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: tomate, quantity: 100, unitId: gram },
|
||||||
|
{ ingredientId: poivre }, // selon le goût
|
||||||
|
],
|
||||||
|
steps: [{ description: "Couper" }, { description: "Poivrer" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
const seasoning = res.body.ingredients.find(
|
||||||
|
(line: { ingredient: { key: string } }) => line.ingredient.key === "blackPepper",
|
||||||
|
);
|
||||||
|
expect(seasoning.quantity).to.equal(null);
|
||||||
|
expect(seasoning.unit).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
it("recomputes each replaced step's technique sequence", async () => {
|
it("recomputes each replaced step's technique sequence", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
|
@ -789,7 +913,6 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Servir immédiatement" }],
|
steps: [{ description: "Servir immédiatement" }],
|
||||||
});
|
});
|
||||||
|
|
@ -797,7 +920,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Émincer les tomates" }],
|
steps: [{ description: "Émincer les tomates" }],
|
||||||
});
|
});
|
||||||
|
|
@ -820,7 +942,6 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [
|
steps: [
|
||||||
{ description: "Hacher les oignons" }, // chop
|
{ description: "Hacher les oignons" }, // chop
|
||||||
|
|
@ -849,7 +970,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: editedDescription }, { description: addedDescription }],
|
steps: [{ description: editedDescription }, { description: addedDescription }],
|
||||||
});
|
});
|
||||||
|
|
@ -890,7 +1010,6 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Servir immédiatement" }],
|
steps: [{ description: "Servir immédiatement" }],
|
||||||
});
|
});
|
||||||
|
|
@ -907,7 +1026,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: editedDescription }],
|
steps: [{ description: editedDescription }],
|
||||||
});
|
});
|
||||||
|
|
@ -942,7 +1060,6 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch("/recipes/999999").send({
|
const res = await agent.patch("/recipes/999999").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -951,30 +1068,6 @@ describe("Recipes", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
const created = await agent.post("/recipes").send({
|
|
||||||
name: "Salade",
|
|
||||||
portions: 4,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
||||||
steps: [{ description: "Couper" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
|
||||||
name: "Salade",
|
|
||||||
portions: 4,
|
|
||||||
dietIds: [999_999],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
||||||
steps: [{ description: "Couper" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
||||||
const { profileId } = await signup();
|
const { profileId } = await signup();
|
||||||
const { agent: otherAgent } = await signup();
|
const { agent: otherAgent } = await signup();
|
||||||
|
|
@ -987,7 +1080,6 @@ describe("Recipes", () => {
|
||||||
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
||||||
name: "Hack",
|
name: "Hack",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,12 @@ describe("Recipe tech-step corrections", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.correction.previousTechStep).to.equal(null);
|
expect(res.body.correction.previousTechStep).to.equal(null);
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
||||||
|
id: simmerId,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
expect(res.body.correction.start).to.equal(6);
|
expect(res.body.correction.start).to.equal(6);
|
||||||
expect(res.body.correction.end).to.equal(13);
|
expect(res.body.correction.end).to.equal(13);
|
||||||
// The step's real technique sequence reflects the correction right
|
// The step's real technique sequence reflects the correction right
|
||||||
|
|
@ -117,7 +122,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: simmerId, key: "simmer" },
|
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -128,6 +133,56 @@ describe("Recipe tech-step corrections", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("proposes a placeholder technique via correctedTechStepName when the catalog is missing one (issue #130)", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, correctedTechStepName: "Flamber au chalumeau" });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
// A placeholder `TechStep` was minted and the correction points at it.
|
||||||
|
expect(res.body.correction.correctedTechStep.isPlaceholder).to.equal(true);
|
||||||
|
expect(res.body.correction.correctedTechStep.displayName).to.equal("Flamber au chalumeau");
|
||||||
|
// ...and the step's live sequence carries it straight away.
|
||||||
|
expect(res.body.techSteps).to.have.length(1);
|
||||||
|
expect(res.body.techSteps[0].techStep.isPlaceholder).to.equal(true);
|
||||||
|
expect(res.body.techSteps[0].techStep.displayName).to.equal("Flamber au chalumeau");
|
||||||
|
expect(res.body.techSteps[0].source).to.equal("manual");
|
||||||
|
|
||||||
|
const row = await prisma.techStep.findUniqueOrThrow({
|
||||||
|
where: { id: res.body.techSteps[0].techStep.id },
|
||||||
|
});
|
||||||
|
expect(row.isPlaceholder).to.equal(true);
|
||||||
|
expect(row.key).to.match(/^placeholder:/);
|
||||||
|
expect(row.createdById).to.equal(profileId);
|
||||||
|
expect(row.createdAt).to.be.an.instanceOf(Date);
|
||||||
|
expect(row.reviewedAt).to.equal(null);
|
||||||
|
// It never leaks into the browsable catalog.
|
||||||
|
const reference = await agent.get("/reference/tech-steps");
|
||||||
|
expect(
|
||||||
|
reference.body.some(
|
||||||
|
(t: { displayName?: string }) => t.displayName === "Flamber au chalumeau",
|
||||||
|
),
|
||||||
|
).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects sending both correctedTechStepId and correctedTechStepName with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 6,
|
||||||
|
end: 13,
|
||||||
|
correctedTechStepId: await techStepId("simmer"),
|
||||||
|
correctedTechStepName: "Autre chose",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
@ -145,13 +200,23 @@ describe("Recipe tech-step corrections", () => {
|
||||||
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
expect(res.body.correction.previousTechStep).to.deep.equal({
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
id: simmerId,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
|
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
||||||
|
id: boilId,
|
||||||
|
key: "boil",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
// Still exactly one entry — the relabel updated the existing row
|
// Still exactly one entry — the relabel updated the existing row
|
||||||
// rather than adding a second one alongside it.
|
// rather than adding a second one alongside it.
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: boilId, key: "boil" },
|
techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -299,7 +364,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: simmerId, key: "simmer" },
|
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -535,7 +600,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: preheatId, key: "preheat" },
|
techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null },
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 11,
|
end: 11,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -721,8 +786,18 @@ describe("Recipe tech-step corrections", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body).to.have.length(2);
|
expect(res.body).to.have.length(2);
|
||||||
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
expect(res.body[0].correctedTechStep).to.deep.equal({
|
||||||
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
id: boilId,
|
||||||
|
key: "boil",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
|
expect(res.body[1].correctedTechStep).to.deep.equal({
|
||||||
|
id: simmerId,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when nothing has been submitted yet", async () => {
|
it("returns an empty list when nothing has been submitted yet", async () => {
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,30 @@ describe("Reference data", () => {
|
||||||
// number — this catalog has grown since (26 -> 74) and will again.
|
// number — this catalog has grown since (26 -> 74) and will again.
|
||||||
expect(res.body).to.have.length(TECH_STEPS.length);
|
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||||
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders techniques alphabetically by key", async () => {
|
it("orders techniques alphabetically by key", async () => {
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,76 @@ describe("Shopping list", () => {
|
||||||
expect(res.body.items[0].quantity).to.equal(300);
|
expect(res.body.items[0].quantity).to.equal(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists a « selon le goût » seasoning ingredient once, with no quantity, never summed (issue #128)", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const houseId: number = houseRes.body.id;
|
||||||
|
const authorId: number = houseRes.body.adminId;
|
||||||
|
|
||||||
|
const tomatoId = await ingredientId("tomato");
|
||||||
|
const saltId = await ingredientId("salt");
|
||||||
|
const gramId = await unitId("gram");
|
||||||
|
|
||||||
|
// 100g tomato + salt "selon le goût" (quantity/unit null), planned
|
||||||
|
// twice at 4 portions each (recipe yields 2): tomato scales to 400g,
|
||||||
|
// salt stays a single unquantified line.
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Tomates au sel",
|
||||||
|
authorId,
|
||||||
|
portions: 2,
|
||||||
|
ingredients: {
|
||||||
|
create: [
|
||||||
|
{ ingredientId: tomatoId, quantity: 100, unitId: gramId },
|
||||||
|
{ ingredientId: saltId, quantity: null, unitId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||||
|
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "dejeuner",
|
||||||
|
recipeId: recipe.id,
|
||||||
|
portions: 4,
|
||||||
|
portionsRequested: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "mercredi",
|
||||||
|
meal: "diner",
|
||||||
|
recipeId: recipe.id,
|
||||||
|
portions: 4,
|
||||||
|
portionsRequested: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
const salt = res.body.items.filter(
|
||||||
|
(item: { ingredient: { key: string } }) => item.ingredient.key === "salt",
|
||||||
|
);
|
||||||
|
expect(salt).to.have.length(1);
|
||||||
|
expect(salt[0].quantity).to.equal(null);
|
||||||
|
expect(salt[0].unit).to.equal(null);
|
||||||
|
const tomato = res.body.items.find(
|
||||||
|
(item: { ingredient: { key: string } }) => item.ingredient.key === "tomato",
|
||||||
|
);
|
||||||
|
expect(tomato.quantity).to.equal(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,12 @@ describe("Sources", () => {
|
||||||
const [step] = res.body.steps;
|
const [step] = res.body.steps;
|
||||||
expect(step.description).to.equal("Chop the onions finely");
|
expect(step.description).to.equal("Chop the onions finely");
|
||||||
expect(step.techSteps).to.have.length(1);
|
expect(step.techSteps).to.have.length(1);
|
||||||
expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
|
expect(step.techSteps[0].techStep).to.deep.equal({
|
||||||
|
id: chop.id,
|
||||||
|
key: "chop",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
|
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
|
||||||
).to.equal("chop");
|
).to.equal("chop");
|
||||||
|
|
@ -382,6 +387,8 @@ describe("Sources", () => {
|
||||||
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
|
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
|
||||||
id: simmer.id,
|
id: simmer.id,
|
||||||
key: "simmer",
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -426,7 +433,6 @@ describe("Sources", () => {
|
||||||
return {
|
return {
|
||||||
name: "Fake recipe 1 (revue)",
|
name: "Fake recipe 1 (revue)",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
|
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
105
apps/web/cypress/component/PasswordInput.cy.tsx
Normal file
105
apps/web/cypress/component/PasswordInput.cy.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -194,6 +194,41 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.get("@onSubmitted").should("have.been.calledOnce");
|
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", () => {
|
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", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,45 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
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", () => {
|
it("switches to the reviewed archive tab", () => {
|
||||||
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
|
// 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
|
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ describe("Admin layout", () => {
|
||||||
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
||||||
cy.contains("nav a", "Catalogue").click();
|
cy.contains("nav a", "Catalogue").click();
|
||||||
cy.url().should("include", "/admin/catalogue");
|
cy.url().should("include", "/admin/catalogue");
|
||||||
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
|
cy.contains("h1", "Éléments hors-catalogue").should("be.visible");
|
||||||
|
|
||||||
cy.contains("nav a", "Tableau de bord").click();
|
cy.contains("nav a", "Tableau de bord").click();
|
||||||
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);
|
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);
|
||||||
|
|
|
||||||
111
apps/web/cypress/e2e/admin-users.cy.ts
Normal file
111
apps/web/cypress/e2e/admin-users.cy.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/web/cypress/e2e/auth-form.cy.ts
Normal file
50
apps/web/cypress/e2e/auth-form.cy.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,41 +1,54 @@
|
||||||
Feature: Onboarding wizard
|
Feature: Onboarding wizard
|
||||||
As a newly signed-up user
|
As a newly signed-up user
|
||||||
I want to set my diet, household, and allergens
|
I want to set my preferences (diet + allergens), then my household
|
||||||
So that my profile is ready before I start planning meals
|
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:
|
Background:
|
||||||
Given the planning request returns nothing
|
Given the planning request returns nothing
|
||||||
|
|
||||||
Scenario: Walks through the wizard, creating a household on the way (which surfaces the sources step), and lands on the home page
|
Scenario: Walks through every step, creating a household (which surfaces the sources step), and lands on the home page
|
||||||
Given the diets reference list has options
|
Given the diets reference list has options
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
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 the allergies reference list has options
|
||||||
And updating allergies will succeed
|
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
|
And I have signed up
|
||||||
Then the URL should include "/onboarding/regime"
|
Then the URL should include "/onboarding/regime"
|
||||||
And I should see "Étape 1 sur 3"
|
And I should see "Étape 1 sur 3"
|
||||||
When I select "Végétarien" from the "diet" field
|
When I select "Végétarien" from the "diet" field
|
||||||
And I click the button "Continuer"
|
And I click the button "Continuer"
|
||||||
Then the diet update request should have been made with diet id 2
|
Then the diet update request should have been made with diet id 2
|
||||||
And the URL should include "/onboarding/foyer"
|
|
||||||
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/allergenes"
|
And the URL should include "/onboarding/allergenes"
|
||||||
And I should see "Étape 4 sur 4"
|
And I should see "Étape 2 sur 3"
|
||||||
And I should see the section "Allergies"
|
And I should see the section "Allergies"
|
||||||
And I should see the section "Intolérances"
|
And I should see the section "Intolérances"
|
||||||
When I check the checkbox "Arachides"
|
When I check the checkbox "Arachides"
|
||||||
And I click the button "Terminer"
|
And I click the button "Continuer"
|
||||||
Then the allergies update request should have been made with allergy id 1
|
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"
|
||||||
|
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 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 click the button "Terminer"
|
||||||
|
Then the source selection update request should have been made with source id 1
|
||||||
And the URL should be the home page
|
And the URL should be the home page
|
||||||
And I should see the heading "Planning de la semaine"
|
And I should see the heading "Planning de la semaine"
|
||||||
|
|
||||||
Scenario: Lets the regime and allergens steps be skipped without changing anything
|
Scenario: Lets the preferences steps be skipped without changing anything, then skips the household step too
|
||||||
Given the diets reference list is empty
|
Given the diets reference list is empty
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
And the household request returns no household
|
||||||
|
|
@ -45,39 +58,32 @@ Feature: Onboarding wizard
|
||||||
Then the URL should include "/onboarding/regime"
|
Then the URL should include "/onboarding/regime"
|
||||||
When I click the button "Continuer"
|
When I click the button "Continuer"
|
||||||
Then the diet update request should have been made with no diet id
|
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"
|
And the URL should include "/onboarding/foyer"
|
||||||
When I click the button "Passer cette étape"
|
When I click the button "Passer cette étape"
|
||||||
Then the URL should include "/onboarding/allergenes"
|
Then the URL should be the home page
|
||||||
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: Shows and saves the sources step instead of skipping it, when sources are available
|
Scenario: Creating a household with no sources implemented ends the wizard through the silently-skipped sources step
|
||||||
Given the diets reference list is empty
|
Given the diets reference list is empty
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
And the household request returns no household
|
||||||
And creating a household will succeed
|
And creating a household will succeed
|
||||||
And the sources reference list has options
|
And the sources reference list is empty
|
||||||
And the household's enabled sources are empty
|
|
||||||
And saving the source selection will succeed
|
|
||||||
And the allergies reference list is empty
|
And the allergies reference list is empty
|
||||||
And updating allergies will succeed
|
And updating allergies will succeed
|
||||||
And I have signed up
|
And I have signed up
|
||||||
When I click the button "Continuer"
|
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"
|
Then the URL should include "/onboarding/foyer"
|
||||||
When I fill in the "houseName" field with "Chez Alice"
|
When I fill in the "houseName" field with "Chez Alice"
|
||||||
And I click the button "Créer"
|
And I click the button "Créer"
|
||||||
Then the household creation request should have been made with name "Chez Alice"
|
Then the household creation request should have been made with name "Chez Alice"
|
||||||
And the URL should include "/onboarding/sources"
|
And the URL should be the home page
|
||||||
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: Lets the household step be completed by joining an existing household instead of creating one
|
Scenario: Joining an existing household skips the sources step entirely and ends the wizard
|
||||||
Given the diets reference list is empty
|
Given the diets reference list is empty
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
And the household request returns no household
|
||||||
|
|
@ -87,8 +93,10 @@ Feature: Onboarding wizard
|
||||||
And updating allergies will succeed
|
And updating allergies will succeed
|
||||||
And I have signed up
|
And I have signed up
|
||||||
When I click the button "Continuer"
|
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"
|
Then the URL should include "/onboarding/foyer"
|
||||||
When I fill in the "inviteCode" field with "abcd2345"
|
When I fill in the "inviteCode" field with "abcd2345"
|
||||||
And I click the button "Rejoindre"
|
And I click the button "Rejoindre"
|
||||||
Then the household join request should have been made with invite code "ABCD2345"
|
Then the household join request should have been made with invite code "ABCD2345"
|
||||||
And the URL should include "/onboarding/allergenes"
|
And the URL should be the home page
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,24 @@ Feature: Recipe form — associating ingredients
|
||||||
Then the ingredient "Carotte" should be visible in the picker
|
Then the ingredient "Carotte" should be visible in the picker
|
||||||
And there should be 0 ingredient rows
|
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
|
Scenario: The save/cancel bar stays pinned to the foot of a long form
|
||||||
When I visit "/recettes/nouvelle"
|
When I visit "/recettes/nouvelle"
|
||||||
And I add a step
|
And I add a step
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,25 @@ 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", () => {
|
When("I scroll the recipe form to the top", () => {
|
||||||
// `.app-content` is the one scroll container (AppLayout.scss) — the sticky
|
// `.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.
|
// action bar pins against it, so it's what has to be scrolled to prove it.
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,19 @@ Feature: Browsing external recipe sources
|
||||||
When I click the button "Réessayer"
|
When I click the button "Réessayer"
|
||||||
Then I should see the source item "Beef Wellington"
|
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
|
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
|
Given the recipe catalog contains nothing
|
||||||
And the sources reference list has options
|
And the sources reference list has options
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,60 @@ Given("the household has enabled TheMealDB", () => {
|
||||||
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
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", () => {
|
Given("browsing TheMealDB returns some items", () => {
|
||||||
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ Feature: Shopping list
|
||||||
And the shopping list should show "Tomate" at quantity "400 g"
|
And the shopping list should show "Tomate" at quantity "400 g"
|
||||||
And the shopping list should show "Oeuf" at quantity "6 unité"
|
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
|
Scenario: Navigating to another week fetches and shows that week's own list
|
||||||
Given the shopping list for "2026-08-17" is empty
|
Given the shopping list for "2026-08-17" is empty
|
||||||
And the shopping list for "2026-08-24" contains:
|
And the shopping list for "2026-08-24" contains:
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,38 @@ 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
|
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
|
||||||
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
||||||
// the prev/next arrows directly by class, same approach here.
|
// the prev/next arrows directly by class, same approach here.
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ Given("the allergies reference list is empty", () => {
|
||||||
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Every onboarding scenario that reaches the household step also reaches
|
// An onboarding scenario whose household step *creates* a household then
|
||||||
// `/onboarding/sources` right after (when a household got created/joined —
|
// reaches `/onboarding/sources` (see `OnboardingHouseholdPage`'s
|
||||||
// see `OnboardingHouseholdPage`'s `goToNextStep`), which reads this before
|
// `goToNextStep`), which reads this before self-skipping straight to the
|
||||||
// self-skipping to `/onboarding/allergenes` if it comes back empty.
|
// home page if it comes back empty.
|
||||||
Given("the sources reference list is empty", () => {
|
Given("the sources reference list is empty", () => {
|
||||||
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
|
||||||
import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage";
|
import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage";
|
||||||
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
|
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
|
||||||
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
|
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 { LoginPage } from "./pages/auth/LoginPage";
|
||||||
import { SignupPage } from "./pages/auth/SignupPage";
|
import { SignupPage } from "./pages/auth/SignupPage";
|
||||||
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
||||||
|
|
@ -51,14 +52,16 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps
|
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps
|
||||||
* working.
|
* working.
|
||||||
*
|
*
|
||||||
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
|
* `/onboarding/*` (regime/allergenes/foyer/sources, in that order) is also
|
||||||
* `RequireAuth`-gated — reached right after signup, once a session already
|
* `RequireAuth`-gated — reached right after signup, once a session already
|
||||||
* exists — but deliberately its own top-level route group, *not* nested
|
* exists — but deliberately its own top-level route group, *not* nested
|
||||||
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
|
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
|
||||||
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
||||||
* `/onboarding/sources` is conditional — only reached when the `foyer` step
|
* `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources`
|
||||||
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
* is conditional — only reached when the `foyer` step *created* a household
|
||||||
* skipped otherwise, straight to `/onboarding/allergenes`.
|
* (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
|
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
|
||||||
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
|
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
|
||||||
|
|
@ -138,6 +141,14 @@ export function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/allergenes"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingAllergensPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/onboarding/foyer"
|
path="/onboarding/foyer"
|
||||||
element={
|
element={
|
||||||
|
|
@ -154,14 +165,6 @@ export function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/onboarding/allergenes"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<OnboardingAllergensPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
|
|
@ -196,6 +199,7 @@ export function App() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
|
<Route path="utilisateurs" element={<AdminUsersPage />} />
|
||||||
<Route path="monitoring" element={<AdminMonitoringPage />} />
|
<Route path="monitoring" element={<AdminMonitoringPage />} />
|
||||||
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
||||||
<Route path="catalogue" element={<AdminCatalogPage />} />
|
<Route path="catalogue" element={<AdminCatalogPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import {
|
import {
|
||||||
|
type AdminCreateUserInput,
|
||||||
type AdminLoginInput,
|
type AdminLoginInput,
|
||||||
type AdminTourScenarioView,
|
type AdminTourScenarioView,
|
||||||
|
type AdminUserCreatedView,
|
||||||
|
type AdminUserListItemView,
|
||||||
type AdminUserView,
|
type AdminUserView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
type CatalogPlaceholderGroupView,
|
type CatalogPlaceholderGroupView,
|
||||||
|
|
@ -11,10 +14,12 @@ import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type ForgejoIntegrationStatus,
|
type ForgejoIntegrationStatus,
|
||||||
type MarkPlaceholdersReviewedInput,
|
type MarkPlaceholdersReviewedInput,
|
||||||
|
type MarkTechStepPlaceholdersReviewedInput,
|
||||||
type MetricsView,
|
type MetricsView,
|
||||||
type PruneOrphansResultView,
|
type PruneOrphansResultView,
|
||||||
type RetrainRequestInput,
|
type RetrainRequestInput,
|
||||||
type RetrainResultView,
|
type RetrainResultView,
|
||||||
|
type TechStepPlaceholderView,
|
||||||
type TourTargetView,
|
type TourTargetView,
|
||||||
type TrainingDataSnippetView,
|
type TrainingDataSnippetView,
|
||||||
type TrainingSuggestionAdminView,
|
type TrainingSuggestionAdminView,
|
||||||
|
|
@ -129,6 +134,20 @@ export class AdminApiClient {
|
||||||
return this._request(`/metrics?days=${days}`);
|
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. */
|
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
|
||||||
public getSuggestions(filters: {
|
public getSuggestions(filters: {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -195,6 +214,21 @@ export class AdminApiClient {
|
||||||
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
|
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). */
|
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
|
||||||
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
|
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
|
||||||
return this._request("/tours");
|
return this._request("/tours");
|
||||||
|
|
|
||||||
78
apps/web/src/components/ui/PasswordInput.tsx
Normal file
78
apps/web/src/components/ui/PasswordInput.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/src/components/ui/password-input.scss
Normal file
58
apps/web/src/components/ui/password-input.scss
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// =============================================================================
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,7 +61,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit button: full-width, visually separated from the fields above it.
|
// Submit button: full-width, visually separated from the fields above it.
|
||||||
button {
|
// Scoped to `[type="submit"]` specifically — the card also contains the
|
||||||
|
// PasswordInput's `type="button"` eye toggle, which must NOT inherit this
|
||||||
|
// full-width primary-coloured treatment (it styles itself in
|
||||||
|
// components/ui/password-input.scss).
|
||||||
|
button[type="submit"] {
|
||||||
margin-top: var(--space-md);
|
margin-top: var(--space-md);
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
|
|
|
||||||
|
|
@ -451,7 +451,9 @@ export function RecipePickerDialog({
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{activeSourceKey === null && (
|
{/* The search field shows on every tab — real ones and each source's
|
||||||
|
(issue #133), matching `RecipesPage`; the ingredient/diet/household
|
||||||
|
filters below it only apply to the saved-recipe tabs. */}
|
||||||
<div className="recipe-picker__filters">
|
<div className="recipe-picker__filters">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
|
@ -461,6 +463,8 @@ export function RecipePickerDialog({
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{activeSourceKey === null && (
|
||||||
|
<>
|
||||||
<div className="recipe-picker__ingredient-filter">
|
<div className="recipe-picker__ingredient-filter">
|
||||||
<span className="recipe-picker__filter-label">
|
<span className="recipe-picker__filter-label">
|
||||||
{t("planning.picker.ingredientsFilterLabel")}
|
{t("planning.picker.ingredientsFilterLabel")}
|
||||||
|
|
@ -513,8 +517,9 @@ export function RecipePickerDialog({
|
||||||
{t("planning.picker.suitableForHouseholdLabel")}
|
{t("planning.picker.suitableForHouseholdLabel")}
|
||||||
</CheckboxOption>
|
</CheckboxOption>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<RecipeTabs
|
<RecipeTabs
|
||||||
active={activeTab}
|
active={activeTab}
|
||||||
|
|
@ -526,6 +531,7 @@ export function RecipePickerDialog({
|
||||||
<RecipeSourcesPanel
|
<RecipeSourcesPanel
|
||||||
key={activeSourceKey}
|
key={activeSourceKey}
|
||||||
sourceKey={activeSourceKey}
|
sourceKey={activeSourceKey}
|
||||||
|
debouncedSearch={debouncedSearch}
|
||||||
onSelectImportedRecipe={handleSelectImportedRecipe}
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||||
onDraftSelected={handleDraftSelected}
|
onDraftSelected={handleDraftSelected}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { IngredientView, UnitView } from "@batch-cooking/shared";
|
import type { IngredientView, UnitView } from "@batch-cooking/shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckboxOption } from "../../../components/ui/Checkbox";
|
||||||
import { AllergenBadges } from "../badges/AllergenBadges";
|
import { AllergenBadges } from "../badges/AllergenBadges";
|
||||||
import { DietBadges } from "../badges/DietBadges";
|
import { DietBadges } from "../badges/DietBadges";
|
||||||
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
||||||
|
|
@ -7,25 +8,30 @@ import { IngredientTypeIcon } from "./ingredient-icons";
|
||||||
import { ingredientLabel } from "./ingredient-label";
|
import { ingredientLabel } from "./ingredient-label";
|
||||||
import "../recipes.scss";
|
import "../recipes.scss";
|
||||||
|
|
||||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
|
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. A line marked `toTaste` ("selon le goût") carries no quantity/unit at all — see {@link toTaste}. */
|
||||||
export function IngredientRow({
|
export function IngredientRow({
|
||||||
ingredient,
|
ingredient,
|
||||||
quantity,
|
quantity,
|
||||||
unitId,
|
unitId,
|
||||||
unitsCatalog,
|
unitsCatalog,
|
||||||
|
toTaste,
|
||||||
duplicate = false,
|
duplicate = false,
|
||||||
onQuantityChange,
|
onQuantityChange,
|
||||||
onUnitChange,
|
onUnitChange,
|
||||||
|
onToTasteChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unitId: number | null;
|
unitId: number | null;
|
||||||
unitsCatalog: UnitView[];
|
unitsCatalog: UnitView[];
|
||||||
|
/** `true` = a "selon le goût" seasoning line (salt, pepper…): the quantity/unit controls are hidden and the line submits with neither, stored as `null`/`null` on `RecipeIngredient` (schema.prisma). Never `--incomplete` for a missing unit while this is set. */
|
||||||
|
toTaste: boolean;
|
||||||
/** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */
|
/** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */
|
||||||
duplicate?: boolean;
|
duplicate?: boolean;
|
||||||
onQuantityChange: (quantity: string) => void;
|
onQuantityChange: (quantity: string) => void;
|
||||||
onUnitChange: (unitId: number) => void;
|
onUnitChange: (unitId: number) => void;
|
||||||
|
onToTasteChange: (toTaste: boolean) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -34,8 +40,9 @@ export function IngredientRow({
|
||||||
// (see issue #53: an import pre-filled from a source can land here with
|
// (see issue #53: an import pre-filled from a source can land here with
|
||||||
// an ingredient resolved but no unit — e.g. a bare count like "4 Egg
|
// an ingredient resolved but no unit — e.g. a bare count like "4 Egg
|
||||||
// Yolks" — and nothing used to tell the user which line was blocking
|
// Yolks" — and nothing used to tell the user which line was blocking
|
||||||
// them, or why).
|
// them, or why). A "selon le goût" line has no unit *by design*, so it's
|
||||||
const unitMissing = unitId === null;
|
// never treated as incomplete.
|
||||||
|
const unitMissing = !toTaste && unitId === null;
|
||||||
const needsAttention = unitMissing || duplicate;
|
const needsAttention = unitMissing || duplicate;
|
||||||
// A free-text placeholder line (the catalog had nothing matching) — shown
|
// A free-text placeholder line (the catalog had nothing matching) — shown
|
||||||
// with an "à compléter" badge and none of the allergen/diet/reproducible
|
// with an "à compléter" badge and none of the allergen/diet/reproducible
|
||||||
|
|
@ -56,6 +63,8 @@ export function IngredientRow({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{!toTaste && (
|
||||||
|
<>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -81,6 +90,18 @@ export function IngredientRow({
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* "Selon le goût" — a seasoning line with nothing to measure. Toggling
|
||||||
|
it on hides the quantity/unit controls above and submits the line
|
||||||
|
with neither. */}
|
||||||
|
<CheckboxOption
|
||||||
|
className="ingredient-row__to-taste"
|
||||||
|
checked={toTaste}
|
||||||
|
onChange={onToTasteChange}
|
||||||
|
>
|
||||||
|
{t("recipes.form.toTasteToggle")}
|
||||||
|
</CheckboxOption>
|
||||||
{!isPlaceholder && (
|
{!isPlaceholder && (
|
||||||
<>
|
<>
|
||||||
<AllergenBadges allergens={ingredient.allergens} />
|
<AllergenBadges allergens={ingredient.allergens} />
|
||||||
|
|
|
||||||
|
|
@ -360,13 +360,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
|
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
|
||||||
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
|
// Reuses .recipes-page__catalog and .recipe-table(-wrap) as-is (see
|
||||||
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's
|
// RecipeSourcesPanel.tsx/SourceItemTable.tsx); the search field is the
|
||||||
// actually new to this tab gets its own rules here.
|
// page header's own now (issue #133) — only what's actually new to this
|
||||||
|
// tab gets its own rules here.
|
||||||
.recipes-page__header--sources {
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-item-table__imported-badge {
|
.source-item-table__imported-badge {
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
|
|
@ -889,6 +886,26 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Ma technique n'est pas dans la liste" sub-form (issue #130) — a name
|
||||||
|
// field + a small confirm, shown in place of the "not in list" link once
|
||||||
|
// it's clicked.
|
||||||
|
&__missing-technique {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__remove-match {
|
&__remove-match {
|
||||||
|
|
@ -1573,6 +1590,16 @@
|
||||||
width: 11rem;
|
width: 11rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Selon le goût" toggle — the app's selectable-card checkbox
|
||||||
|
// (global.scss's `label:has(> input[type="checkbox"])`), shrunk to sit
|
||||||
|
// inline in the row without dominating it.
|
||||||
|
&__to-taste {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
&__remove {
|
&__remove {
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
createRecipeSchema,
|
createRecipeSchema,
|
||||||
type DietView,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type Meal,
|
type Meal,
|
||||||
|
|
@ -16,7 +15,6 @@ import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, apiClient } from "../../../api/client";
|
import { ApiError, apiClient } from "../../../api/client";
|
||||||
import { makeClientKey } from "../../../lib/client-key";
|
import { makeClientKey } from "../../../lib/client-key";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
import { DietTagSelect } from "../badges/DietTagSelect";
|
|
||||||
import { IngredientPicker } from "../ingredients/IngredientPicker";
|
import { IngredientPicker } from "../ingredients/IngredientPicker";
|
||||||
import { IngredientRow } from "../ingredients/IngredientRow";
|
import { IngredientRow } from "../ingredients/IngredientRow";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,12 +27,13 @@ import "../recipes.scss";
|
||||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
||||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||||
|
|
||||||
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). */
|
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). `toTaste` = a "selon le goût" seasoning line, submitted with no quantity/unit. */
|
||||||
interface IngredientLine {
|
interface IngredientLine {
|
||||||
key: string;
|
key: string;
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unitId: number | null;
|
unitId: number | null;
|
||||||
|
toTaste: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */
|
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */
|
||||||
|
|
@ -59,8 +58,8 @@ type LoadState = "loading" | "loaded" | "error";
|
||||||
*
|
*
|
||||||
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId`,
|
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId`,
|
||||||
* structurally the same form as `RecipeFormPage` — same sub-components
|
* structurally the same form as `RecipeFormPage` — same sub-components
|
||||||
* (`IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`),
|
* (`IngredientRow`, `IngredientPicker`, `StepListEditor`), same
|
||||||
* same `CreateRecipeInput` submit shape — plus one thing a manual creation
|
* `CreateRecipeInput` submit shape — plus one thing a manual creation
|
||||||
* never has to handle: ingredient lines the automatic matching
|
* never has to handle: ingredient lines the automatic matching
|
||||||
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à
|
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à
|
||||||
* compléter" list, each needing a real ingredient picked (or the line
|
* compléter" list, each needing a real ingredient picked (or the line
|
||||||
|
|
@ -94,7 +93,6 @@ export function RecipeImportForm({
|
||||||
|
|
||||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
|
||||||
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
@ -102,7 +100,6 @@ export function RecipeImportForm({
|
||||||
const [picture, setPicture] = useState("");
|
const [picture, setPicture] = useState("");
|
||||||
const [portions, setPortions] = useState("4");
|
const [portions, setPortions] = useState("4");
|
||||||
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
|
||||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||||
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
|
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
|
||||||
[],
|
[],
|
||||||
|
|
@ -122,14 +119,12 @@ export function RecipeImportForm({
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
apiClient.getIngredients(),
|
apiClient.getIngredients(),
|
||||||
apiClient.getDiets(),
|
|
||||||
apiClient.getUnits(),
|
apiClient.getUnits(),
|
||||||
apiClient.previewSourceItem(sourceKey, externalId),
|
apiClient.previewSourceItem(sourceKey, externalId),
|
||||||
])
|
])
|
||||||
.then(([ingredients, diets, units, draft]) => {
|
.then(([ingredients, units, draft]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setIngredientsCatalog(ingredients);
|
setIngredientsCatalog(ingredients);
|
||||||
setDietsCatalog(diets);
|
|
||||||
setUnitsCatalog(units);
|
setUnitsCatalog(units);
|
||||||
|
|
||||||
setName(draft.name);
|
setName(draft.name);
|
||||||
|
|
@ -146,6 +141,12 @@ export function RecipeImportForm({
|
||||||
ingredient: line.ingredient,
|
ingredient: line.ingredient,
|
||||||
quantity: line.quantity !== null ? String(line.quantity) : "",
|
quantity: line.quantity !== null ? String(line.quantity) : "",
|
||||||
unitId: line.unit?.id ?? null,
|
unitId: line.unit?.id ?? null,
|
||||||
|
// A source line that resolved to a real ingredient but carried
|
||||||
|
// no quantity (e.g. "salt to taste") — see
|
||||||
|
// `translateRecipeIngredients`, which leaves `quantity`/`unitId`
|
||||||
|
// null in exactly that case. Land it as a "selon le goût" line
|
||||||
|
// rather than blocking submit on a missing quantity.
|
||||||
|
toTaste: line.quantity === null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
unresolved.push({
|
unresolved.push({
|
||||||
|
|
@ -179,13 +180,13 @@ export function RecipeImportForm({
|
||||||
function addIngredient(ingredient: IngredientView) {
|
function addIngredient(ingredient: IngredientView) {
|
||||||
setIngredientLines((lines) => [
|
setIngredientLines((lines) => [
|
||||||
...lines,
|
...lines,
|
||||||
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
{ key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIngredientLine(
|
function updateIngredientLine(
|
||||||
key: string,
|
key: string,
|
||||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
|
||||||
) {
|
) {
|
||||||
setIngredientLines((lines) =>
|
setIngredientLines((lines) =>
|
||||||
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||||
|
|
@ -203,7 +204,13 @@ export function RecipeImportForm({
|
||||||
if (line) {
|
if (line) {
|
||||||
setIngredientLines((resolved) => [
|
setIngredientLines((resolved) => [
|
||||||
...resolved,
|
...resolved,
|
||||||
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null },
|
{
|
||||||
|
key: makeClientKey(),
|
||||||
|
ingredient,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitId: null,
|
||||||
|
toTaste: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return lines.filter((l) => l.key !== unresolvedKey);
|
return lines.filter((l) => l.key !== unresolvedKey);
|
||||||
|
|
@ -223,6 +230,7 @@ export function RecipeImportForm({
|
||||||
ingredient: makePlaceholderIngredientView(line.rawText.trim()),
|
ingredient: makePlaceholderIngredientView(line.rawText.trim()),
|
||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
unitId: null,
|
unitId: null,
|
||||||
|
toTaste: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +249,9 @@ export function RecipeImportForm({
|
||||||
// this, a pre-filled import whose auto-matched ingredients are otherwise
|
// this, a pre-filled import whose auto-matched ingredients are otherwise
|
||||||
// complete could leave `canSubmit` false with nothing on the page saying
|
// complete could leave `canSubmit` false with nothing on the page saying
|
||||||
// why (see issue #53).
|
// why (see issue #53).
|
||||||
const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null);
|
const hasIngredientMissingUnit = ingredientLines.some(
|
||||||
|
(line) => !line.toTaste && line.unitId === null,
|
||||||
|
);
|
||||||
|
|
||||||
// Two raw source lines can independently resolve to the same catalog
|
// Two raw source lines can independently resolve to the same catalog
|
||||||
// ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") —
|
// ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") —
|
||||||
|
|
@ -270,7 +280,9 @@ export function RecipeImportForm({
|
||||||
Number.isInteger(Number(portions)) &&
|
Number.isInteger(Number(portions)) &&
|
||||||
Number(portions) > 0 &&
|
Number(portions) > 0 &&
|
||||||
ingredientLines.length > 0 &&
|
ingredientLines.length > 0 &&
|
||||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
ingredientLines.every(
|
||||||
|
(line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null),
|
||||||
|
) &&
|
||||||
!hasDuplicateIngredient &&
|
!hasDuplicateIngredient &&
|
||||||
unresolvedIngredients.length === 0 &&
|
unresolvedIngredients.length === 0 &&
|
||||||
steps.length > 0 &&
|
steps.length > 0 &&
|
||||||
|
|
@ -286,7 +298,6 @@ export function RecipeImportForm({
|
||||||
picture: picture.trim() || null,
|
picture: picture.trim() || null,
|
||||||
portions: Number(portions),
|
portions: Number(portions),
|
||||||
visibility,
|
visibility,
|
||||||
dietIds,
|
|
||||||
ingredients: ingredientLines.map((line) => ({
|
ingredients: ingredientLines.map((line) => ({
|
||||||
// Free-text lines (nothing in the catalog matched) submit as
|
// Free-text lines (nothing in the catalog matched) submit as
|
||||||
// `placeholderName` so the API creates the placeholder row — same
|
// `placeholderName` so the API creates the placeholder row — same
|
||||||
|
|
@ -294,11 +305,9 @@ export function RecipeImportForm({
|
||||||
...(isUnsavedPlaceholder(line.ingredient)
|
...(isUnsavedPlaceholder(line.ingredient)
|
||||||
? { placeholderName: line.ingredient.displayName ?? "" }
|
? { placeholderName: line.ingredient.displayName ?? "" }
|
||||||
: { ingredientId: line.ingredient.id }),
|
: { ingredientId: line.ingredient.id }),
|
||||||
quantity: Number(line.quantity),
|
// A "selon le goût" line submits with neither quantity nor unit —
|
||||||
// `canSubmit` already requires every line to have a unit picked —
|
// same branch as RecipeFormPage's identical submit.
|
||||||
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
...(line.toTaste ? {} : { quantity: Number(line.quantity), unitId: line.unitId ?? 0 }),
|
||||||
// RecipeFormPage's identical submit.
|
|
||||||
unitId: line.unitId ?? 0,
|
|
||||||
})),
|
})),
|
||||||
steps: steps.map((step) => ({
|
steps: steps.map((step) => ({
|
||||||
description: step.description.trim(),
|
description: step.description.trim(),
|
||||||
|
|
@ -420,8 +429,6 @@ export function RecipeImportForm({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
|
||||||
|
|
||||||
<section className="recipe-form__section">
|
<section className="recipe-form__section">
|
||||||
<h2>{t("recipes.ingredientsTitle")}</h2>
|
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||||
<ul className="recipe-form__ingredient-list">
|
<ul className="recipe-form__ingredient-list">
|
||||||
|
|
@ -432,9 +439,11 @@ export function RecipeImportForm({
|
||||||
quantity={line.quantity}
|
quantity={line.quantity}
|
||||||
unitId={line.unitId}
|
unitId={line.unitId}
|
||||||
unitsCatalog={unitsCatalog}
|
unitsCatalog={unitsCatalog}
|
||||||
|
toTaste={line.toTaste}
|
||||||
duplicate={duplicateIngredientIds.has(line.ingredient.id)}
|
duplicate={duplicateIngredientIds.has(line.ingredient.id)}
|
||||||
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||||
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
||||||
|
onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })}
|
||||||
onRemove={() => removeIngredientLine(line.key)}
|
onRemove={() => removeIngredientLine(line.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel"
|
||||||
import { SourceItemTable } from "./SourceItemTable";
|
import { SourceItemTable } from "./SourceItemTable";
|
||||||
import "../recipes.scss";
|
import "../recipes.scss";
|
||||||
|
|
||||||
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
|
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
|
||||||
const LOAD_MORE_PLACEHOLDER_COUNT = 4;
|
const LOAD_MORE_PLACEHOLDER_COUNT = 4;
|
||||||
|
|
||||||
|
|
@ -64,15 +61,25 @@ type BrowseState =
|
||||||
* upward, and re-previews on mount/prop-change if handed one back.
|
* upward, and re-previews on mount/prop-change if handed one back.
|
||||||
* `RecipePickerDialog` leaves both unset: previewing inside that dialog
|
* `RecipePickerDialog` leaves both unset: previewing inside that dialog
|
||||||
* has no URL of its own to keep in sync.
|
* has no URL of its own to keep in sync.
|
||||||
|
*
|
||||||
|
* `debouncedSearch` is owned by the caller (issue #133): one search field
|
||||||
|
* in `RecipesPage`'s header serves every tab — real ones and each
|
||||||
|
* source's — so the query survives switching between sources (this panel
|
||||||
|
* still remounts on `sourceKey` via its `key`, but the term lives above
|
||||||
|
* that). The debounce happens in the caller too; this panel just refetches
|
||||||
|
* whenever the prop changes.
|
||||||
*/
|
*/
|
||||||
export function RecipeSourcesPanel({
|
export function RecipeSourcesPanel({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
|
debouncedSearch,
|
||||||
onSelectImportedRecipe,
|
onSelectImportedRecipe,
|
||||||
onDraftSelected,
|
onDraftSelected,
|
||||||
initialSelection,
|
initialSelection,
|
||||||
onItemSelected,
|
onItemSelected,
|
||||||
}: {
|
}: {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
|
/** Already-debounced search term from `RecipesPage`'s shared field — drives `browseSource`'s `query`. */
|
||||||
|
debouncedSearch: string;
|
||||||
/** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
|
/** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
|
||||||
onSelectImportedRecipe: (recipe: RecipeView) => void;
|
onSelectImportedRecipe: (recipe: RecipeView) => void;
|
||||||
/** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
|
/** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
|
||||||
|
|
@ -82,8 +89,6 @@ export function RecipeSourcesPanel({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
||||||
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
||||||
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
|
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||||
// The *next* page, fetched ahead of time as soon as the current one is on
|
// The *next* page, fetched ahead of time as soon as the current one is on
|
||||||
|
|
@ -152,11 +157,6 @@ export function RecipeSourcesPanel({
|
||||||
};
|
};
|
||||||
}, [initialSelection?.externalId]);
|
}, [initialSelection?.externalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setBrowseState({ status: "loading" });
|
setBrowseState({ status: "loading" });
|
||||||
|
|
@ -294,17 +294,8 @@ export function RecipeSourcesPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
// The search field lives in `RecipesPage`'s header now (issue #133) —
|
||||||
<div className="recipes-page__header recipes-page__header--sources">
|
// this panel is just the source's list + preview.
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
className="recipes-page__search"
|
|
||||||
placeholder={t("recipes.sources.searchPlaceholder")}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="recipes-page__catalog">
|
<div className="recipes-page__catalog">
|
||||||
{browseState.status === "loading" && (
|
{browseState.status === "loading" && (
|
||||||
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
||||||
|
|
@ -340,6 +331,5 @@ export function RecipeSourcesPanel({
|
||||||
|
|
||||||
<RecipeDetailPanel state={previewState} showActions={false} />
|
<RecipeDetailPanel state={previewState} showActions={false} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ import {
|
||||||
* fall back to the full review screen (`ImportRecipePage`), pre-filled from
|
* fall back to the full review screen (`ImportRecipePage`), pre-filled from
|
||||||
* this exact same draft, for a person to fill in what's missing.
|
* this exact same draft, for a person to fill in what's missing.
|
||||||
*
|
*
|
||||||
* Default `visibility`/`dietIds` mirror what a person would otherwise leave
|
* Default `visibility` mirrors what a person would otherwise leave
|
||||||
* untouched on that same form (`PERSONAL`, no diet tags) — nothing here is
|
* untouched on that same form (`PERSONAL`) — nothing here is a guess about
|
||||||
* a guess about data the draft doesn't have an opinion on.
|
* data the draft doesn't have an opinion on. Regimes aren't part of the
|
||||||
|
* payload anymore (issue #131) — they're computed from the ingredients.
|
||||||
* `createRecipeSchema.safeParse` is still the actual authority on whether
|
* `createRecipeSchema.safeParse` is still the actual authority on whether
|
||||||
* the result is submittable (a positive-portions check, string lengths,
|
* the result is submittable (a positive-portions check, string lengths,
|
||||||
* etc.) — the checks above it exist only for what a schema alone can't
|
* etc.) — the checks above it exist only for what a schema alone can't
|
||||||
|
|
@ -42,7 +43,6 @@ export function tryBuildCompleteImport(draft: RecipeImportDraftView): CreateReci
|
||||||
picture: draft.picture,
|
picture: draft.picture,
|
||||||
portions: draft.portions,
|
portions: draft.portions,
|
||||||
visibility: "PERSONAL",
|
visibility: "PERSONAL",
|
||||||
dietIds: [],
|
|
||||||
ingredients: draft.ingredients.map((line) => ({
|
ingredients: draft.ingredients.map((line) => ({
|
||||||
// Every line is fully resolved by this point — guarded above.
|
// Every line is fully resolved by this point — guarded above.
|
||||||
ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id,
|
ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Tooltip } from "../../../components/ui/Tooltip";
|
||||||
import { useTour } from "../../tour/TourContext";
|
import { useTour } from "../../tour/TourContext";
|
||||||
import { splitDescriptionSegments } from "./highlight-tech-steps";
|
import { splitDescriptionSegments } from "./highlight-tech-steps";
|
||||||
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
||||||
|
import { techStepLabel } from "./tech-step-label";
|
||||||
import { temperatureLabel } from "./temperature-label";
|
import { temperatureLabel } from "./temperature-label";
|
||||||
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
||||||
|
|
||||||
|
|
@ -318,11 +319,12 @@ export function StepDescription({
|
||||||
|
|
||||||
const techStep = segment.techStep;
|
const techStep = segment.techStep;
|
||||||
const isManual = segment.source === "manual";
|
const isManual = segment.source === "manual";
|
||||||
|
// `displayName` for a user-proposed technique the catalog is
|
||||||
|
// missing (issue #130), the i18n label otherwise.
|
||||||
|
const techLabel = techStepLabel(techStep, t);
|
||||||
const tooltipLabel = isManual
|
const tooltipLabel = isManual
|
||||||
? t("recipes.techStepCorrection.manualTooltip", {
|
? t("recipes.techStepCorrection.manualTooltip", { technique: techLabel })
|
||||||
technique: t(`catalog.techSteps.${techStep.key}`),
|
: techLabel;
|
||||||
})
|
|
||||||
: t(`catalog.techSteps.${techStep.key}`);
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={key} content={tooltipLabel}>
|
<Tooltip key={key} content={tooltipLabel}>
|
||||||
{/* A real <button>, not a <mark>, so it's natively focusable
|
{/* A real <button>, not a <mark>, so it's natively focusable
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { ApiError, apiClient } from "../../../api/client";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
import { ingredientLabel } from "../ingredients/ingredient-label";
|
import { ingredientLabel } from "../ingredients/ingredient-label";
|
||||||
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
||||||
|
import { techStepLabel } from "./tech-step-label";
|
||||||
import { temperatureLabel } from "./temperature-label";
|
import { temperatureLabel } from "./temperature-label";
|
||||||
import type { TextSelectionRange } from "./use-text-selection";
|
import type { TextSelectionRange } from "./use-text-selection";
|
||||||
|
|
||||||
|
|
@ -358,6 +359,13 @@ export function TechStepCorrectionPopover({
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
||||||
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
||||||
|
// A free-text technique the catalog doesn't have (issue #130) — set from
|
||||||
|
// the "pas dans la liste" sub-form. Mutually exclusive with
|
||||||
|
// `selectedTechStepId`: whichever is set is what `confirm()` submits (as
|
||||||
|
// `correctedTechStepName` vs `correctedTechStepId`).
|
||||||
|
const [proposedTechStepName, setProposedTechStepName] = useState<string | null>(null);
|
||||||
|
const [isNamingMissingTechnique, setIsNamingMissingTechnique] = useState(false);
|
||||||
|
const [missingTechniqueDraft, setMissingTechniqueDraft] = useState("");
|
||||||
// The technique picker hides behind "Changer" once something's already
|
// The technique picker hides behind "Changer" once something's already
|
||||||
// picked (see this component's own doc comment) — starts open only when
|
// picked (see this component's own doc comment) — starts open only when
|
||||||
// there's nothing to show yet (a brand-new selection).
|
// there's nothing to show yet (a brand-new selection).
|
||||||
|
|
@ -506,7 +514,7 @@ export function TechStepCorrectionPopover({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (selectedTechStepId === null) return;
|
if (selectedTechStepId === null && proposedTechStepName === null) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -514,7 +522,11 @@ export function TechStepCorrectionPopover({
|
||||||
start: range.start,
|
start: range.start,
|
||||||
end: range.end,
|
end: range.end,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
correctedTechStepId: selectedTechStepId,
|
// Exactly one of these — a catalog id, or free text for a technique
|
||||||
|
// the catalog is missing (issue #130).
|
||||||
|
...(proposedTechStepName !== null
|
||||||
|
? { correctedTechStepName: proposedTechStepName }
|
||||||
|
: { correctedTechStepId: selectedTechStepId ?? undefined }),
|
||||||
...(metadataTouched
|
...(metadataTouched
|
||||||
? {
|
? {
|
||||||
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
|
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
|
||||||
|
|
@ -616,9 +628,13 @@ export function TechStepCorrectionPopover({
|
||||||
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
||||||
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
||||||
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
||||||
|
const selectedTechStep =
|
||||||
|
selectedTechStepId !== null ? techSteps?.find((ts) => ts.id === selectedTechStepId) : undefined;
|
||||||
const currentTechniqueLabel =
|
const currentTechniqueLabel =
|
||||||
selectedTechStepId !== null
|
proposedTechStepName !== null
|
||||||
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`)
|
? proposedTechStepName
|
||||||
|
: selectedTechStep
|
||||||
|
? techStepLabel(selectedTechStep, t)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -652,7 +668,11 @@ export function TechStepCorrectionPopover({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__technique-change"
|
className="tech-step-correction-popover__technique-change"
|
||||||
onClick={() => setIsChangingTechnique(true)}
|
onClick={() => {
|
||||||
|
setIsChangingTechnique(true);
|
||||||
|
setProposedTechStepName(null);
|
||||||
|
setIsNamingMissingTechnique(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.changeTechnique")}
|
{t("recipes.techStepCorrection.changeTechnique")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -670,19 +690,61 @@ export function TechStepCorrectionPopover({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(currentTechniqueLabel === null || isChangingTechnique) && (
|
{(currentTechniqueLabel === null || isChangingTechnique) && (
|
||||||
|
<>
|
||||||
<CatalogSearchPicker
|
<CatalogSearchPicker
|
||||||
items={techSteps.map((techStep) => ({
|
items={techSteps.map((techStep) => ({
|
||||||
id: techStep.id,
|
id: techStep.id,
|
||||||
label: t(`catalog.techSteps.${techStep.key}`),
|
label: techStepLabel(techStep, t),
|
||||||
}))}
|
}))}
|
||||||
selectedId={selectedTechStepId ?? undefined}
|
selectedId={selectedTechStepId ?? undefined}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
setSelectedTechStepId(id);
|
setSelectedTechStepId(id);
|
||||||
|
setProposedTechStepName(null);
|
||||||
|
setIsNamingMissingTechnique(false);
|
||||||
setIsChangingTechnique(false);
|
setIsChangingTechnique(false);
|
||||||
}}
|
}}
|
||||||
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
||||||
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
||||||
/>
|
/>
|
||||||
|
{/* Escape hatch for a technique the catalog is missing (issue
|
||||||
|
#130) — the correction still applies immediately, and the
|
||||||
|
proposed name lands in the admin catalogue-gap review. */}
|
||||||
|
{isNamingMissingTechnique ? (
|
||||||
|
<div className="tech-step-correction-popover__missing-technique">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={missingTechniqueDraft}
|
||||||
|
onChange={(e) => setMissingTechniqueDraft(e.target.value)}
|
||||||
|
placeholder={t("recipes.techStepCorrection.missingTechniquePlaceholder")}
|
||||||
|
aria-label={t("recipes.techStepCorrection.missingTechniqueLabel")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__confirm-mini"
|
||||||
|
disabled={missingTechniqueDraft.trim().length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setProposedTechStepName(missingTechniqueDraft.trim());
|
||||||
|
setSelectedTechStepId(null);
|
||||||
|
setIsNamingMissingTechnique(false);
|
||||||
|
setIsChangingTechnique(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.missingTechniqueAdd")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__technique-change"
|
||||||
|
onClick={() => {
|
||||||
|
setMissingTechniqueDraft("");
|
||||||
|
setIsNamingMissingTechnique(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.techniqueNotInList")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -993,7 +1055,9 @@ export function TechStepCorrectionPopover({
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__confirm-button"
|
className="tech-step-correction-popover__confirm-button"
|
||||||
onClick={confirm}
|
onClick={confirm}
|
||||||
disabled={isSubmitting || selectedTechStepId === null}
|
disabled={
|
||||||
|
isSubmitting || (selectedTechStepId === null && proposedTechStepName === null)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.confirm")}
|
{t("recipes.techStepCorrection.confirm")}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
20
apps/web/src/features/recipes/steps/tech-step-label.ts
Normal file
20
apps/web/src/features/recipes/steps/tech-step-label.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { TechStepView } from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display label for a technique — its French catalog label
|
||||||
|
* (`t(\`catalog.techSteps.${key}\`)`) for a real seeded technique, or the
|
||||||
|
* user's verbatim text for a **placeholder** (`isPlaceholder`, the free text
|
||||||
|
* a user typed while correcting a step when the catalog was missing the
|
||||||
|
* technique — issue #130, `TechStep.isPlaceholder` in schema.prisma), which
|
||||||
|
* has no i18n key at all. Same `displayName ?? t(...)` split as
|
||||||
|
* `ingredients/ingredient-label.ts`; `t` is passed in so this stays a plain
|
||||||
|
* function.
|
||||||
|
*/
|
||||||
|
export function techStepLabel(
|
||||||
|
techStep: Pick<TechStepView, "key" | "isPlaceholder" | "displayName">,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): string {
|
||||||
|
return techStep.isPlaceholder && techStep.displayName !== null
|
||||||
|
? techStep.displayName
|
||||||
|
: t(`catalog.techSteps.${techStep.key}`);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -20,6 +21,7 @@ import "./AdminLayout.scss";
|
||||||
*/
|
*/
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
|
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
|
||||||
|
{ to: "/admin/utilisateurs", key: "users", Icon: Users, end: false },
|
||||||
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
|
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
|
||||||
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
|
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
|
||||||
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
|
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@
|
||||||
"vendredi": "Vendredi",
|
"vendredi": "Vendredi",
|
||||||
"samedi": "Samedi",
|
"samedi": "Samedi",
|
||||||
"dimanche": "Dimanche"
|
"dimanche": "Dimanche"
|
||||||
|
},
|
||||||
|
"passwordInput": {
|
||||||
|
"show": "Afficher le mot de passe",
|
||||||
|
"hide": "Masquer le mot de passe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|
@ -219,6 +223,10 @@
|
||||||
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
||||||
"searchTechniquePlaceholder": "Rechercher une technique…",
|
"searchTechniquePlaceholder": "Rechercher une technique…",
|
||||||
"noTechniqueFound": "Aucune technique trouvée.",
|
"noTechniqueFound": "Aucune technique trouvée.",
|
||||||
|
"techniqueNotInList": "Ma technique n'est pas dans la liste",
|
||||||
|
"missingTechniquePlaceholder": "Nommez la technique manquante…",
|
||||||
|
"missingTechniqueLabel": "Technique manquante",
|
||||||
|
"missingTechniqueAdd": "Ajouter",
|
||||||
"ingredientsSection": "Ingrédients",
|
"ingredientsSection": "Ingrédients",
|
||||||
"utensilsSection": "Ustensiles",
|
"utensilsSection": "Ustensiles",
|
||||||
"temperatureSection": "Température",
|
"temperatureSection": "Température",
|
||||||
|
|
@ -251,7 +259,6 @@
|
||||||
"diets": "Régime associé"
|
"diets": "Régime associé"
|
||||||
},
|
},
|
||||||
"sources": {
|
"sources": {
|
||||||
"searchPlaceholder": "Rechercher…",
|
|
||||||
"empty": "Aucune recette trouvée.",
|
"empty": "Aucune recette trouvée.",
|
||||||
"alreadyImported": "Déjà importée",
|
"alreadyImported": "Déjà importée",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
|
|
@ -357,13 +364,15 @@
|
||||||
"submit": "Enregistrer",
|
"submit": "Enregistrer",
|
||||||
"submitting": "Enregistrement…",
|
"submitting": "Enregistrement…",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
"toTasteToggle": "Selon le goût",
|
||||||
"genericError": "Le formulaire contient des erreurs"
|
"genericError": "Le formulaire contient des erreurs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shoppingList": {
|
"shoppingList": {
|
||||||
"title": "Liste de courses",
|
"title": "Liste de courses",
|
||||||
"loading": "Chargement de la liste de courses…",
|
"loading": "Chargement de la liste de courses…",
|
||||||
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning.",
|
||||||
|
"toTaste": "selon le goût"
|
||||||
},
|
},
|
||||||
"cookingSession": {
|
"cookingSession": {
|
||||||
"title": "Cuisiner cette semaine",
|
"title": "Cuisiner cette semaine",
|
||||||
|
|
@ -1225,6 +1234,7 @@
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
|
"users": "Utilisateurs",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"corrections": "Corrections",
|
"corrections": "Corrections",
|
||||||
"catalog": "Catalogue",
|
"catalog": "Catalogue",
|
||||||
|
|
@ -1234,6 +1244,37 @@
|
||||||
"layout": {
|
"layout": {
|
||||||
"logout": "Se déconnecter"
|
"logout": "Se déconnecter"
|
||||||
},
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Utilisateurs",
|
||||||
|
"lead": "Comptes utilisateurs de l'application.",
|
||||||
|
"newButton": "Nouvel utilisateur",
|
||||||
|
"empty": "Aucun compte pour le moment.",
|
||||||
|
"noHousehold": "—",
|
||||||
|
"col": {
|
||||||
|
"date": "Créé le",
|
||||||
|
"name": "Nom",
|
||||||
|
"email": "Email",
|
||||||
|
"household": "Foyer"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Créer un compte",
|
||||||
|
"firstName": "Prénom",
|
||||||
|
"lastName": "Nom",
|
||||||
|
"email": "Email",
|
||||||
|
"submit": "Créer le compte",
|
||||||
|
"submitting": "Création…",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"title": "Compte créé",
|
||||||
|
"intro": "Transmettez ce mot de passe temporaire à",
|
||||||
|
"passwordLabel": "Mot de passe temporaire",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié",
|
||||||
|
"warning": "Notez-le maintenant : il ne sera plus affiché.",
|
||||||
|
"done": "Terminé"
|
||||||
|
}
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"lead": "Métriques d'utilisation de l'application.",
|
"lead": "Métriques d'utilisation de l'application.",
|
||||||
|
|
@ -1371,9 +1412,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"title": "Ingrédients hors-catalogue",
|
"title": "Éléments hors-catalogue",
|
||||||
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
|
"lead": "Saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
|
||||||
"empty": "Aucun ingrédient hors-catalogue.",
|
"empty": "Aucun ingrédient hors-catalogue.",
|
||||||
|
"techStepEmpty": "Aucune technique proposée à revoir.",
|
||||||
|
"kind": {
|
||||||
|
"ingredients": "Ingrédients",
|
||||||
|
"techSteps": "Techniques"
|
||||||
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
"pending": "À traiter",
|
"pending": "À traiter",
|
||||||
"reviewed": "Traités"
|
"reviewed": "Traités"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
|
import type { CatalogPlaceholderGroupView, TechStepPlaceholderView } from "@batch-cooking/shared";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { adminApiClient } from "../../../api/admin-client";
|
import { adminApiClient } from "../../../api/admin-client";
|
||||||
|
|
@ -6,25 +6,82 @@ import "../admin-page.scss";
|
||||||
import "./catalog-page.scss";
|
import "./catalog-page.scss";
|
||||||
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
|
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
|
||||||
|
|
||||||
type CatalogState =
|
type IngredientState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
||||||
| { status: "error" };
|
| { status: "error" };
|
||||||
|
|
||||||
|
type TechStepState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; rows: TechStepPlaceholderView[] }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/** Which catalogue gap the page is reviewing — off-catalog ingredients, or techniques users proposed while correcting a step (issue #130). */
|
||||||
|
type CatalogKind = "ingredients" | "techSteps";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the
|
* Off-catalog review. Two lists a maintainer works through to see what the
|
||||||
* free text users typed when the seeded catalog fell short — see
|
* seeded catalog is missing: the free-text **ingredients** users typed on a
|
||||||
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
|
* recipe line (grouped by normalized name), and the free-text **techniques**
|
||||||
* name, so a maintainer sees what the catalog is missing and how many
|
* users proposed while correcting a step (issue #130, a flat list — that's
|
||||||
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
|
* rarer). Actions are deliberately minimal: mark a gap as handled (and, for
|
||||||
* as handled, or purge rows no recipe references any more. Actually adding
|
* ingredients, purge rows no recipe references any more). Adding the real
|
||||||
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
|
* catalog entry stays a manual edit of `reference-seed-data.ts` + the locale
|
||||||
* locale files.
|
* files.
|
||||||
*/
|
*/
|
||||||
export function CatalogPage() {
|
export function CatalogPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [kind, setKind] = useState<CatalogKind>("ingredients");
|
||||||
const [tab, setTab] = useState<CatalogTab>("pending");
|
const [tab, setTab] = useState<CatalogTab>("pending");
|
||||||
const [state, setState] = useState<CatalogState>({ status: "loading" });
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
|
||||||
|
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
|
||||||
|
|
||||||
|
<div className="catalog-kinds" role="tablist">
|
||||||
|
{(["ingredients", "techSteps"] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={kind === value}
|
||||||
|
className={kind === value ? "active" : undefined}
|
||||||
|
onClick={() => setKind(value)}
|
||||||
|
>
|
||||||
|
{t(`admin.catalog.kind.${value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="catalog-tabs" role="tablist">
|
||||||
|
{(["pending", "reviewed"] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === value}
|
||||||
|
className={tab === value ? "active" : undefined}
|
||||||
|
onClick={() => setTab(value)}
|
||||||
|
>
|
||||||
|
{t(`admin.catalog.tab.${value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === "ingredients" ? (
|
||||||
|
<IngredientPlaceholders tab={tab} />
|
||||||
|
) : (
|
||||||
|
<TechStepPlaceholders tab={tab} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The off-catalog ingredient list — grouped by normalized name, with a prune-orphans action. */
|
||||||
|
function IngredientPlaceholders({ tab }: { tab: CatalogTab }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<IngredientState>({ status: "loading" });
|
||||||
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
|
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
|
||||||
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
|
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
|
||||||
const [isPruning, setIsPruning] = useState(false);
|
const [isPruning, setIsPruning] = useState(false);
|
||||||
|
|
@ -78,25 +135,8 @@ export function CatalogPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<>
|
||||||
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
|
|
||||||
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
|
|
||||||
|
|
||||||
<div className="catalog-toolbar">
|
<div className="catalog-toolbar">
|
||||||
<div className="catalog-tabs" role="tablist">
|
|
||||||
{(["pending", "reviewed"] as const).map((value) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={tab === value}
|
|
||||||
className={tab === value ? "active" : undefined}
|
|
||||||
onClick={() => setTab(value)}
|
|
||||||
>
|
|
||||||
{t(`admin.catalog.tab.${value}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
|
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
|
||||||
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
|
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -124,7 +164,90 @@ export function CatalogPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The proposed-technique list (issue #130) — a flat list, each row markable as reviewed. */
|
||||||
|
function TechStepPlaceholders({ tab }: { tab: CatalogTab }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<TechStepState>({ status: "loading" });
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback((forTab: CatalogTab) => {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
adminApiClient
|
||||||
|
.getTechStepPlaceholders(reviewedParam(forTab))
|
||||||
|
.then((rows) => {
|
||||||
|
if (seq === requestSeq.current) setState({ status: "loaded", rows });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seq === requestSeq.current) setState({ status: "error" });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(tab);
|
||||||
|
}, [tab, load]);
|
||||||
|
|
||||||
|
function markReviewed(id: number) {
|
||||||
|
setBusyId(id);
|
||||||
|
adminApiClient
|
||||||
|
.markTechStepPlaceholdersReviewed({ techStepIds: [id] })
|
||||||
|
.then(() => load(tab))
|
||||||
|
.catch(() => load(tab))
|
||||||
|
.finally(() => setBusyId(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return <p className="admin-page__placeholder">{t("admin.common.loading")}</p>;
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
return <p className="admin-page__placeholder">{t("admin.common.loadError")}</p>;
|
||||||
|
}
|
||||||
|
if (state.rows.length === 0) {
|
||||||
|
return <p className="admin-page__placeholder">{t("admin.catalog.techStepEmpty")}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="catalog-list">
|
||||||
|
{state.rows.map((row) => {
|
||||||
|
const firstSeen = formatDate(row.createdAt);
|
||||||
|
return (
|
||||||
|
<li key={row.id} className="catalog-card">
|
||||||
|
<div className="catalog-card__head">
|
||||||
|
<h2 className="catalog-card__name">{row.displayName}</h2>
|
||||||
|
<span className="catalog-card__count">
|
||||||
|
{t("admin.catalog.recipeCount", { count: row.recipeCount })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{row.sampleRecipes.length > 0 && (
|
||||||
|
<p className="catalog-card__recipes">
|
||||||
|
{t("admin.catalog.seenIn")}{" "}
|
||||||
|
{row.sampleRecipes.map((recipe) => recipe.name).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="catalog-card__foot">
|
||||||
|
{firstSeen && (
|
||||||
|
<span className="catalog-card__seen">
|
||||||
|
{t("admin.catalog.firstSeen", { date: firstSeen })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.reviewedAt === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => markReviewed(row.id)}
|
||||||
|
disabled={busyId === row.id}
|
||||||
|
>
|
||||||
|
{busyId === row.id ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ingrédients / Techniques switch (issue #130) — sits above the
|
||||||
|
// pending/reviewed tabs; a bolder segmented control so the two levels read
|
||||||
|
// as distinct.
|
||||||
|
.catalog-kinds {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2.5px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-tabs {
|
.catalog-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
|
|
||||||
269
apps/web/src/pages/admin/users/UsersPage.tsx
Normal file
269
apps/web/src/pages/admin/users/UsersPage.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import {
|
||||||
|
type AdminUserCreatedView,
|
||||||
|
type AdminUserListItemView,
|
||||||
|
adminCreateUserSchema,
|
||||||
|
ErrorCode,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AdminApiError, adminApiClient } from "../../../api/admin-client";
|
||||||
|
import { Dialog } from "../../../components/ui/Dialog";
|
||||||
|
import { fieldErrorsFrom } from "../../../lib/zod-errors";
|
||||||
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
|
import "../admin-page.scss";
|
||||||
|
import "./users-page.scss";
|
||||||
|
import { copyToClipboard, formatCreatedAt, fullName } from "./users";
|
||||||
|
|
||||||
|
/** Load state for `GET /admin/api/users` — same discriminated-union shape as the other admin pages. */
|
||||||
|
type UsersState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; rows: AdminUserListItemView[] }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin user management (`/admin/utilisateurs`). A read-only table of every
|
||||||
|
* end-user account plus a "create an account by hand" dialog: the operator
|
||||||
|
* fills in name + email, the server generates a one-time temporary password
|
||||||
|
* and hands it back for the operator to relay. No edit/disable/delete —
|
||||||
|
* deleting an account stays a deliberate self-service action.
|
||||||
|
*/
|
||||||
|
export function UsersPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<UsersState>({ status: "loading" });
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
adminApiClient
|
||||||
|
.getUsers()
|
||||||
|
.then((rows) => {
|
||||||
|
if (seq === requestSeq.current) setState({ status: "loaded", rows });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seq === requestSeq.current) setState({ status: "error" });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page users-page">
|
||||||
|
<header className="users-page__header">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-page__title">{t("admin.users.title")}</h1>
|
||||||
|
<p className="admin-page__lead">{t("admin.users.lead")}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="users-page__new" onClick={() => setIsCreating(true)}>
|
||||||
|
{t("admin.users.newButton")}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{state.status === "loading" && (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
||||||
|
)}
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
||||||
|
)}
|
||||||
|
{state.status === "loaded" && state.rows.length === 0 && (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.users.empty")}</p>
|
||||||
|
)}
|
||||||
|
{state.status === "loaded" && state.rows.length > 0 && (
|
||||||
|
<div className="users-table-wrap">
|
||||||
|
<table className="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("admin.users.col.date")}</th>
|
||||||
|
<th>{t("admin.users.col.name")}</th>
|
||||||
|
<th>{t("admin.users.col.email")}</th>
|
||||||
|
<th>{t("admin.users.col.household")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{state.rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td className="users-table__date">{formatCreatedAt(row.createdAt)}</td>
|
||||||
|
<td>{fullName(row)}</td>
|
||||||
|
<td className="users-table__email">{row.email}</td>
|
||||||
|
<td>{row.householdName ?? t("admin.users.noHousehold")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<CreateUserDialog
|
||||||
|
onClose={(created) => {
|
||||||
|
setIsCreating(false);
|
||||||
|
if (created) load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-step create dialog: the form, then — on success — a one-time reveal of
|
||||||
|
* the generated temporary password. `onClose(created)` tells the parent
|
||||||
|
* whether to refetch the list (`true` once an account was created, even if
|
||||||
|
* the operator then closes from the reveal step).
|
||||||
|
*/
|
||||||
|
function CreateUserDialog({ onClose }: { onClose: (created: boolean) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [result, setResult] = useState<AdminUserCreatedView | null>(null);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setFormError(null);
|
||||||
|
const parsed = adminCreateUserSchema.safeParse({ firstName, lastName, email });
|
||||||
|
if (!parsed.success) {
|
||||||
|
setFieldErrors(fieldErrorsFrom(parsed.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFieldErrors({});
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
setResult(await adminApiClient.createUser(parsed.data));
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(
|
||||||
|
errorMessageService.getLabel(
|
||||||
|
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return <PasswordRevealDialog result={result} onDone={() => onClose(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t("admin.users.form.title")}
|
||||||
|
onClose={() => onClose(false)}
|
||||||
|
className="user-create-dialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => onClose(false)} disabled={isSubmitting}>
|
||||||
|
{t("admin.users.form.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="user-create-dialog__submit"
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? t("admin.users.form.submitting") : t("admin.users.form.submit")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
data-testid="create-user-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label htmlFor="newUserFirstName">{t("admin.users.form.firstName")}</label>
|
||||||
|
<input
|
||||||
|
id="newUserFirstName"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
maxLength={100}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setFirstName(event.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.firstName && <p className="field-error">{fieldErrors.firstName}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="newUserLastName">{t("admin.users.form.lastName")}</label>
|
||||||
|
<input
|
||||||
|
id="newUserLastName"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
maxLength={100}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setLastName(event.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.lastName && <p className="field-error">{fieldErrors.lastName}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="newUserEmail">{t("admin.users.form.email")}</label>
|
||||||
|
<input
|
||||||
|
id="newUserEmail"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
{/* Submit on Enter without showing a second button — the footer's is the visible one. */}
|
||||||
|
<button type="submit" hidden />
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-time temporary-password reveal shown after a successful create. */
|
||||||
|
function PasswordRevealDialog({
|
||||||
|
result,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
result: AdminUserCreatedView;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t("admin.users.created.title")}
|
||||||
|
onClose={onDone}
|
||||||
|
className="user-create-dialog"
|
||||||
|
footer={
|
||||||
|
<button type="button" className="user-create-dialog__submit" onClick={onDone}>
|
||||||
|
{t("admin.users.created.done")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div data-testid="temp-password-reveal">
|
||||||
|
<p>
|
||||||
|
{t("admin.users.created.intro")} <strong>{fullName(result.user)}</strong> (
|
||||||
|
{result.user.email})
|
||||||
|
</p>
|
||||||
|
<label htmlFor="tempPassword">{t("admin.users.created.passwordLabel")}</label>
|
||||||
|
<div className="user-create-dialog__password-row">
|
||||||
|
<input
|
||||||
|
id="tempPassword"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={result.temporaryPassword}
|
||||||
|
onFocus={(event) => event.target.select()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void copyToClipboard(result.temporaryPassword).then(setCopied);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? t("admin.users.created.copied") : t("admin.users.created.copy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="user-create-dialog__warning">{t("admin.users.created.warning")}</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
apps/web/src/pages/admin/users/users-page.scss
Normal file
148
apps/web/src/pages/admin/users/users-page.scss
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// =============================================================================
|
||||||
|
// UsersPage — the admin user-account list + "create an account" dialog.
|
||||||
|
// Shares the table language of messages-page.scss / corrections-page.scss;
|
||||||
|
// its own file since nothing outside this page uses these classes.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.users-page__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Nouvel utilisateur" — the page's single primary action.
|
||||||
|
.users-page__new {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create-user modal — the `Dialog` primitive supplies the panel/scroll/close;
|
||||||
|
// this only sizes the fields, the footer buttons and the password reveal.
|
||||||
|
.user-create-dialog {
|
||||||
|
&.dialog-panel {
|
||||||
|
max-width: 32rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: var(--space-sm) 0 var(--space-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
margin: var(--space-xs) 0 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only temp password + its "Copier" button, side by side.
|
||||||
|
&__password-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warning {
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/web/src/pages/admin/users/users.ts
Normal file
32
apps/web/src/pages/admin/users/users.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { AdminUserListItemView } from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers for `UsersPage` — kept out of the `.tsx` (repo convention: no
|
||||||
|
* derivation/formatting logic in a component file) so they're trivially
|
||||||
|
* testable and the component stays markup + wiring only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Full name of an account, for the list's "Nom" column. */
|
||||||
|
export function fullName(user: Pick<AdminUserListItemView, "firstName" | "lastName">): string {
|
||||||
|
return `${user.firstName} ${user.lastName}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `createdAt` (ISO 8601) rendered for the "Créé le" column, French locale. */
|
||||||
|
export function formatCreatedAt(isoDate: string): string {
|
||||||
|
return new Date(isoDate).toLocaleString("fr-FR");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies `text` to the clipboard, resolving `true` on success and `false`
|
||||||
|
* when the browser blocks it (insecure context, permission denied, no
|
||||||
|
* `navigator.clipboard`). Never throws — the caller just shows/withholds a
|
||||||
|
* "Copié" confirmation.
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
|
import { PasswordInput } from "../../components/ui/PasswordInput";
|
||||||
import { useAuth } from "../../features/auth/AuthContext";
|
import { useAuth } from "../../features/auth/AuthContext";
|
||||||
// Shared with SignupPage — see the file for why it's colocated in
|
// Shared with SignupPage — see the file for why it's colocated in
|
||||||
// features/auth/ rather than duplicated per page.
|
// features/auth/ rather than duplicated per page.
|
||||||
|
|
@ -78,11 +79,10 @@ export function LoginPage() {
|
||||||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||||
|
|
||||||
<label htmlFor="password">{t("auth.login.passwordLabel")}</label>
|
<label htmlFor="password">{t("auth.login.passwordLabel")}</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={setPassword}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
|
import { PasswordInput } from "../../components/ui/PasswordInput";
|
||||||
import { useAuth } from "../../features/auth/AuthContext";
|
import { useAuth } from "../../features/auth/AuthContext";
|
||||||
// Shared with LoginPage — see the file for why it's colocated in
|
// Shared with LoginPage — see the file for why it's colocated in
|
||||||
// features/auth/ rather than duplicated per page.
|
// features/auth/ rather than duplicated per page.
|
||||||
|
|
@ -37,7 +38,7 @@ export function SignupPage() {
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
/** Validates, then submits the form; on success, starts the regime/household/allergens onboarding wizard rather than going straight to the home. */
|
/** Validates, then submits the form; on success, starts the régime → allergènes → foyer onboarding wizard rather than going straight to the home. */
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
@ -98,11 +99,10 @@ export function SignupPage() {
|
||||||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||||
|
|
||||||
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
|
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={setPassword}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,23 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../../api/client";
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
import { AllergySelect } from "../../features/profile/AllergySelect";
|
import { AllergySelect } from "../../features/profile/AllergySelect";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
import { onboardingStepIndicator } from "./onboarding-steps";
|
||||||
import "./onboarding.scss";
|
import "./onboarding.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last step of the post-signup onboarding wizard, routed at
|
* Second step of the post-signup onboarding wizard, routed at
|
||||||
* `/onboarding/allergenes`. Starts from an empty selection — a freshly
|
* `/onboarding/allergenes` — the allergens half of "préférences" (régime
|
||||||
* signed-up profile has none yet, so there's no need for the extra
|
* is the first half), now ahead of the household step rather than the
|
||||||
|
* wizard's final screen (see #129). Starts from an empty selection — a
|
||||||
|
* freshly signed-up profile has none yet, so there's no need for the extra
|
||||||
* `GET /profile/allergies` round trip a "resume where I left off" flow
|
* `GET /profile/allergies` round trip a "resume where I left off" flow
|
||||||
* would require (the `/parametres/preferences` settings page is the
|
* would require (the `/parametres/preferences` settings page is the
|
||||||
* always-fetch source of truth for editing an existing selection later).
|
* always-fetch source of truth for editing an existing selection later).
|
||||||
*
|
*
|
||||||
* Also fetches the current household on mount, only to know whether the
|
* No household fetch anymore: this step no longer needs to retroactively
|
||||||
* `/onboarding/sources` step ran before this one (household step created
|
* work out whether a sources step ran, since it now comes before the
|
||||||
* one and it had a source to configure) — the step count shown reflects
|
* household step entirely — the "sur 3" vs "4 sur 4" split lives in
|
||||||
* whichever path the visitor actually took, "3 sur 3" or "4 sur 4"
|
* {@link onboardingStepIndicator}.
|
||||||
* (there's no shared wizard state to read this from — see
|
|
||||||
* `OnboardingHouseholdPage`'s doc comment on why each step is independent).
|
|
||||||
*/
|
*/
|
||||||
export function OnboardingAllergensPage() {
|
export function OnboardingAllergensPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -29,27 +30,22 @@ export function OnboardingAllergensPage() {
|
||||||
|
|
||||||
const [allergies, setAllergies] = useState<AllergyView[]>([]);
|
const [allergies, setAllergies] = useState<AllergyView[]>([]);
|
||||||
const [allergyIds, setAllergyIds] = useState<number[]>([]);
|
const [allergyIds, setAllergyIds] = useState<number[]>([]);
|
||||||
const [totalSteps, setTotalSteps] = useState(3);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
|
apiClient
|
||||||
.then(([allergiesResult, house]) => {
|
.getAllergies()
|
||||||
if (cancelled) return;
|
.then((allergiesResult) => {
|
||||||
setAllergies(allergiesResult);
|
if (!cancelled) setAllergies(allergiesResult);
|
||||||
setTotalSteps(house !== null ? 4 : 3);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Neither piece is essential for this last step to render — the
|
|
||||||
// allergy list just stays empty (nothing to pick from) and the
|
|
||||||
// step count keeps its 3-of-3 default, same "don't strand the
|
|
||||||
// visitor over a transient failure" reasoning as
|
|
||||||
// OnboardingSourcesPage's own catch, just with nothing to
|
|
||||||
// navigate away to since this already is the last step.
|
|
||||||
})
|
})
|
||||||
|
// Not essential for this step to render — the allergy list just
|
||||||
|
// stays empty (nothing to pick from) rather than stranding the
|
||||||
|
// visitor over a transient failure, same reasoning as
|
||||||
|
// OnboardingSourcesPage's own catch.
|
||||||
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsLoading(false);
|
if (!cancelled) setIsLoading(false);
|
||||||
});
|
});
|
||||||
|
|
@ -58,14 +54,14 @@ export function OnboardingAllergensPage() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Finishes the wizard — the profile journey is complete, back to the app itself. */
|
/** Moves on to the household step — preferences (régime + allergènes) are done. */
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.updateAllergyIds(allergyIds);
|
await apiClient.updateAllergyIds(allergyIds);
|
||||||
void navigate("/");
|
void navigate("/onboarding/foyer");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
|
@ -78,7 +74,7 @@ export function OnboardingAllergensPage() {
|
||||||
<main className="onboarding-page">
|
<main className="onboarding-page">
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
<p className="onboarding-step">
|
<p className="onboarding-step">
|
||||||
{t("onboarding.step", { current: totalSteps, total: totalSteps })}
|
{t("onboarding.step", onboardingStepIndicator("allergens"))}
|
||||||
</p>
|
</p>
|
||||||
<h1>{t("onboarding.allergens.title")}</h1>
|
<h1>{t("onboarding.allergens.title")}</h1>
|
||||||
|
|
||||||
|
|
@ -104,7 +100,7 @@ export function OnboardingAllergensPage() {
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting || isLoading}>
|
<button type="submit" disabled={isSubmitting || isLoading}>
|
||||||
{t("onboarding.finish")}
|
{t("onboarding.continue")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ApiError, apiClient } from "../../api/client";
|
||||||
import { useAuth } from "../../features/auth/AuthContext";
|
import { useAuth } from "../../features/auth/AuthContext";
|
||||||
import { DietSelect } from "../../features/profile/DietSelect";
|
import { DietSelect } from "../../features/profile/DietSelect";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
import { onboardingStepIndicator } from "./onboarding-steps";
|
||||||
import "./onboarding.scss";
|
import "./onboarding.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,6 +16,9 @@ import "./onboarding.scss";
|
||||||
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
|
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
|
||||||
* `null` — no extra fetch needed just to know the starting value, unlike
|
* `null` — no extra fetch needed just to know the starting value, unlike
|
||||||
* the household step which has no such shortcut for the current name.
|
* the household step which has no such shortcut for the current name.
|
||||||
|
*
|
||||||
|
* Followed by the allergens step (both make up "préférences" — see #129);
|
||||||
|
* the household step comes after those, not between them.
|
||||||
*/
|
*/
|
||||||
export function OnboardingDietPage() {
|
export function OnboardingDietPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -53,7 +57,7 @@ export function OnboardingDietPage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.updateDiet(dietId);
|
await apiClient.updateDiet(dietId);
|
||||||
void navigate("/onboarding/foyer");
|
void navigate("/onboarding/allergenes");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
|
@ -65,7 +69,7 @@ export function OnboardingDietPage() {
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<main className="onboarding-page">
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
|
<p className="onboarding-step">{t("onboarding.step", onboardingStepIndicator("regime"))}</p>
|
||||||
<h1>{t("onboarding.diet.title")}</h1>
|
<h1>{t("onboarding.diet.title")}</h1>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,24 @@ import { ApiError, apiClient } from "../../api/client";
|
||||||
import { HouseNameField } from "../../features/profile/HouseNameField";
|
import { HouseNameField } from "../../features/profile/HouseNameField";
|
||||||
import { fieldErrorsFrom } from "../../lib/zod-errors";
|
import { fieldErrorsFrom } from "../../lib/zod-errors";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
import { onboardingStepIndicator } from "./onboarding-steps";
|
||||||
import "./onboarding.scss";
|
import "./onboarding.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Second step of the post-signup onboarding wizard, routed at
|
* Third step of the post-signup onboarding wizard, routed at
|
||||||
* `/onboarding/foyer` — unlike the regime/allergens steps, this one is
|
* `/onboarding/foyer` — after the régime + allergènes "préférences" steps
|
||||||
* genuinely optional rather than just "skippable via an unedited default":
|
* (see #129). Unlike those, this one is genuinely optional rather than
|
||||||
* no household is created at signup anymore (see the API's
|
* just "skippable via an unedited default": no household is created at
|
||||||
* `auth.service.ts`), so there's nothing to prefill or implicitly keep by
|
* signup anymore (see the API's `auth.service.ts`), so there's nothing to
|
||||||
* pressing "Continuer" — the visitor explicitly creates one, joins one by
|
* prefill or implicitly keep by pressing "Continuer" — the visitor
|
||||||
* invite code, or skips the step outright via {@link SkipButton}.
|
* explicitly creates one, joins one by invite code, or skips the step
|
||||||
|
* outright via {@link SkipButton}.
|
||||||
|
*
|
||||||
|
* This step's outcome decides whether the wizard has a 4th step: only
|
||||||
|
* *creating* a household leads on to `/onboarding/sources` (a per-house
|
||||||
|
* preference to seed while the visitor is here); joining one or skipping
|
||||||
|
* ends the wizard straight to `/` (a joined household's sources were set
|
||||||
|
* by whoever created it; a skipped step has no household to attach any to).
|
||||||
*
|
*
|
||||||
* Still checks `getCurrentHouse()` on mount and skips straight to the
|
* Still checks `getCurrentHouse()` on mount and skips straight to the
|
||||||
* "already have one" state if it finds one — defensive against revisiting
|
* "already have one" state if it finds one — defensive against revisiting
|
||||||
|
|
@ -50,19 +58,22 @@ export function OnboardingHouseholdPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A household is what the next step (`/onboarding/sources`) needs to
|
* `/onboarding/sources` only makes sense for a household the visitor just
|
||||||
* attach a preference to — created/joined/already-there goes on to it,
|
* *created* — that's the sole caller passing `true`. Joining one, skipping
|
||||||
* skipped goes straight to `/onboarding/allergenes` instead (nothing to
|
* the step, or finding one already there (browser back — can't tell
|
||||||
* configure sources for without a household).
|
* created from joined, and the sources step self-skips when there's
|
||||||
|
* nothing to configure anyway) all end the wizard at `/`.
|
||||||
*/
|
*/
|
||||||
function goToNextStep(hasHousehold: boolean) {
|
function goToNextStep(createdHousehold: boolean) {
|
||||||
void navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
|
void navigate(createdHousehold ? "/onboarding/sources" : "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<main className="onboarding-page">
|
||||||
<div className="onboarding-card">
|
<div className="onboarding-card">
|
||||||
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
|
<p className="onboarding-step">
|
||||||
|
{t("onboarding.step", onboardingStepIndicator("household"))}
|
||||||
|
</p>
|
||||||
<h1>{t("onboarding.household.title")}</h1>
|
<h1>{t("onboarding.household.title")}</h1>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -70,15 +81,15 @@ export function OnboardingHouseholdPage() {
|
||||||
) : house !== null ? (
|
) : house !== null ? (
|
||||||
<>
|
<>
|
||||||
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
|
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
|
||||||
<button type="button" onClick={() => goToNextStep(true)}>
|
<button type="button" onClick={() => goToNextStep(false)}>
|
||||||
{t("onboarding.continue")}
|
{t("onboarding.finish")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
|
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
|
||||||
<CreateHouseholdForm onDone={() => goToNextStep(true)} />
|
<CreateHouseholdForm onDone={() => goToNextStep(true)} />
|
||||||
<JoinHouseholdForm onDone={() => goToNextStep(true)} />
|
<JoinHouseholdForm onDone={() => goToNextStep(false)} />
|
||||||
<SkipButton onSkip={() => goToNextStep(false)} />
|
<SkipButton onSkip={() => goToNextStep(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,23 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../../api/client";
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
import { SourceSelect } from "../../features/house/SourceSelect";
|
import { SourceSelect } from "../../features/house/SourceSelect";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
import { onboardingStepIndicator } from "./onboarding-steps";
|
||||||
import "./onboarding.scss";
|
import "./onboarding.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Third step of the post-signup onboarding wizard, routed at
|
* Final step of the post-signup onboarding wizard, routed at
|
||||||
* `/onboarding/sources` — only reached when a household now exists (see
|
* `/onboarding/sources` — reached only when the previous step *created* a
|
||||||
* `OnboardingHouseholdPage`'s `goToStep`, which skips straight to
|
* household (see `OnboardingHouseholdPage`'s `goToNextStep`, which ends the
|
||||||
* `/onboarding/allergenes` when the previous step was skipped): enabled
|
* wizard at `/` on every other path): enabled sources are a per-household
|
||||||
* sources are a per-household preference (`HouseSource`, schema.prisma),
|
* preference (`HouseSource`, schema.prisma), so there's nothing to attach
|
||||||
* so there's nothing to attach a selection to without one.
|
* a selection to otherwise.
|
||||||
*
|
*
|
||||||
* Starts from an empty selection — a newly created/joined household has
|
* Starts from an empty selection — a newly created household has nothing
|
||||||
* nothing enabled yet by design (opt-in, see `house.service.ts`'s
|
* enabled yet by design (opt-in, see `house.service.ts`'s
|
||||||
* `getHouseSourceIds`). If no source is implemented yet (`getSources()`
|
* `getHouseSourceIds`). If no source is implemented yet (`getSources()`
|
||||||
* comes back empty — currently always the case), there's nothing to pick
|
* comes back empty — currently always the case), there's nothing to pick
|
||||||
* from either, so this step silently steps itself over rather than showing
|
* from either, so this step silently finishes the wizard (straight to `/`)
|
||||||
* an empty screen.
|
* rather than showing an empty screen.
|
||||||
*/
|
*/
|
||||||
export function OnboardingSourcesPage() {
|
export function OnboardingSourcesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -40,7 +41,7 @@ export function OnboardingSourcesPage() {
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
void navigate("/onboarding/allergenes", { replace: true });
|
void navigate("/", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSources(result);
|
setSources(result);
|
||||||
|
|
@ -50,7 +51,7 @@ export function OnboardingSourcesPage() {
|
||||||
// Nothing to configure sources for if we can't even list them — the
|
// Nothing to configure sources for if we can't even list them — the
|
||||||
// wizard shouldn't strand the visitor here over a transient failure
|
// wizard shouldn't strand the visitor here over a transient failure
|
||||||
// fetching an optional step's own data.
|
// fetching an optional step's own data.
|
||||||
if (!cancelled) void navigate("/onboarding/allergenes", { replace: true });
|
if (!cancelled) void navigate("/", { replace: true });
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
@ -63,7 +64,7 @@ export function OnboardingSourcesPage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.updateHouseSourceIds(sourceIds);
|
await apiClient.updateHouseSourceIds(sourceIds);
|
||||||
void navigate("/onboarding/allergenes");
|
void navigate("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
|
@ -75,7 +76,9 @@ export function OnboardingSourcesPage() {
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<main className="onboarding-page">
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 4 })}</p>
|
<p className="onboarding-step">
|
||||||
|
{t("onboarding.step", onboardingStepIndicator("sources", { hasSources: true }))}
|
||||||
|
</p>
|
||||||
<h1>{t("onboarding.sources.title")}</h1>
|
<h1>{t("onboarding.sources.title")}</h1>
|
||||||
<p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p>
|
<p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p>
|
||||||
|
|
||||||
|
|
@ -93,7 +96,7 @@ export function OnboardingSourcesPage() {
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting || isLoading}>
|
<button type="submit" disabled={isSubmitting || isLoading}>
|
||||||
{t("onboarding.continue")}
|
{t("onboarding.finish")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
45
apps/web/src/pages/onboarding/onboarding-steps.ts
Normal file
45
apps/web/src/pages/onboarding/onboarding-steps.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* The post-signup onboarding wizard's step order, in one place so the
|
||||||
|
* per-page "Étape N sur M" indicators can't drift out of sync when the
|
||||||
|
* sequence changes.
|
||||||
|
*
|
||||||
|
* Order: régime → allergènes → foyer → [sources]. `"sources"` is a
|
||||||
|
* conditional 4th step, reached only when the visitor *creates* a
|
||||||
|
* household on the foyer step — joining one or skipping the step ends the
|
||||||
|
* wizard at foyer (a joined household's sources were already configured by
|
||||||
|
* whoever created it, and there's nothing to attach a source selection to
|
||||||
|
* without a household at all).
|
||||||
|
*
|
||||||
|
* Each page renders its own indicator with no shared runtime wizard state
|
||||||
|
* to read from (see {@link OnboardingHouseholdPage}'s doc on why the steps
|
||||||
|
* are independent), so régime/allergènes/foyer — all upstream of the
|
||||||
|
* "create vs join/skip" decision — can't yet know whether a 4th step is
|
||||||
|
* coming and show "sur 3"; only the `"sources"` step, which by definition
|
||||||
|
* is only ever reached on the 4-step path, shows "4 sur 4". This mirrors
|
||||||
|
* the pre-#129 flow, whose last step likewise only learned the real total
|
||||||
|
* after the fact.
|
||||||
|
*/
|
||||||
|
export type OnboardingStep = "regime" | "allergens" | "household" | "sources";
|
||||||
|
|
||||||
|
/** The wizard's two possible step sequences, keyed by whether the sources step runs. */
|
||||||
|
export const ONBOARDING_STEP_SEQUENCE = {
|
||||||
|
/** régime → allergènes → foyer: household joined or skipped (or still upstream of that choice). */
|
||||||
|
withoutSources: ["regime", "allergens", "household"],
|
||||||
|
/** régime → allergènes → foyer → sources: the visitor created a household. */
|
||||||
|
withSources: ["regime", "allergens", "household", "sources"],
|
||||||
|
} as const satisfies Record<string, readonly OnboardingStep[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{ current, total }` to feed `t("onboarding.step", …)` for a given step.
|
||||||
|
* Defaults to the 3-step sequence; the `"sources"` step passes
|
||||||
|
* `hasSources: true` for its "4 sur 4".
|
||||||
|
*/
|
||||||
|
export function onboardingStepIndicator(
|
||||||
|
step: OnboardingStep,
|
||||||
|
{ hasSources = false }: { hasSources?: boolean } = {},
|
||||||
|
): { current: number; total: number } {
|
||||||
|
const sequence: readonly OnboardingStep[] = hasSources
|
||||||
|
? ONBOARDING_STEP_SEQUENCE.withSources
|
||||||
|
: ONBOARDING_STEP_SEQUENCE.withoutSources;
|
||||||
|
return { current: sequence.indexOf(step) + 1, total: sequence.length };
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
createRecipeSchema,
|
createRecipeSchema,
|
||||||
type DietView,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type RecipeVisibility,
|
type RecipeVisibility,
|
||||||
|
|
@ -11,7 +10,6 @@ import { type FormEvent, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../../api/client";
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
|
|
||||||
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
|
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
|
||||||
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
|
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
|
||||||
import {
|
import {
|
||||||
|
|
@ -44,6 +42,8 @@ interface IngredientLine {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unitId: number | null;
|
unitId: number | null;
|
||||||
|
/** `true` = a "selon le goût" seasoning line — submits with no quantity/unit (stored as `null`/`null`), and `canSubmit` doesn't require either. */
|
||||||
|
toTaste: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
|
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
|
||||||
|
|
@ -66,7 +66,6 @@ export function RecipeFormPage() {
|
||||||
|
|
||||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
|
||||||
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
@ -78,7 +77,6 @@ export function RecipeFormPage() {
|
||||||
// get past the gate if 4 is already right for their recipe.
|
// get past the gate if 4 is already right for their recipe.
|
||||||
const [portions, setPortions] = useState("4");
|
const [portions, setPortions] = useState("4");
|
||||||
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
|
||||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||||
|
|
||||||
|
|
@ -91,14 +89,12 @@ export function RecipeFormPage() {
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
apiClient.getIngredients(),
|
apiClient.getIngredients(),
|
||||||
apiClient.getDiets(),
|
|
||||||
apiClient.getUnits(),
|
apiClient.getUnits(),
|
||||||
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
|
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
.then(([ingredients, diets, units, recipe]) => {
|
.then(([ingredients, units, recipe]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setIngredientsCatalog(ingredients);
|
setIngredientsCatalog(ingredients);
|
||||||
setDietsCatalog(diets);
|
|
||||||
setUnitsCatalog(units);
|
setUnitsCatalog(units);
|
||||||
if (recipe) {
|
if (recipe) {
|
||||||
setName(recipe.name);
|
setName(recipe.name);
|
||||||
|
|
@ -106,13 +102,13 @@ export function RecipeFormPage() {
|
||||||
setPicture(recipe.picture ?? "");
|
setPicture(recipe.picture ?? "");
|
||||||
setPortions(String(recipe.portions));
|
setPortions(String(recipe.portions));
|
||||||
setVisibility(recipe.visibility);
|
setVisibility(recipe.visibility);
|
||||||
setDietIds(recipe.diets.map((diet) => diet.id));
|
|
||||||
setIngredientLines(
|
setIngredientLines(
|
||||||
recipe.ingredients.map((line) => ({
|
recipe.ingredients.map((line) => ({
|
||||||
key: makeClientKey(),
|
key: makeClientKey(),
|
||||||
ingredient: line.ingredient,
|
ingredient: line.ingredient,
|
||||||
quantity: String(line.quantity),
|
quantity: line.quantity === null ? "" : String(line.quantity),
|
||||||
unitId: line.unit.id,
|
unitId: line.unit?.id ?? null,
|
||||||
|
toTaste: line.quantity === null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setSteps(
|
setSteps(
|
||||||
|
|
@ -137,7 +133,7 @@ export function RecipeFormPage() {
|
||||||
function addIngredient(ingredient: IngredientView) {
|
function addIngredient(ingredient: IngredientView) {
|
||||||
setIngredientLines((lines) => [
|
setIngredientLines((lines) => [
|
||||||
...lines,
|
...lines,
|
||||||
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
{ key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,13 +146,14 @@ export function RecipeFormPage() {
|
||||||
ingredient: makePlaceholderIngredientView(name),
|
ingredient: makePlaceholderIngredientView(name),
|
||||||
quantity: "",
|
quantity: "",
|
||||||
unitId: null,
|
unitId: null,
|
||||||
|
toTaste: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIngredientLine(
|
function updateIngredientLine(
|
||||||
key: string,
|
key: string,
|
||||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
|
||||||
) {
|
) {
|
||||||
setIngredientLines((lines) =>
|
setIngredientLines((lines) =>
|
||||||
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||||
|
|
@ -172,17 +169,22 @@ export function RecipeFormPage() {
|
||||||
// `RecipeImportForm` (issue #53), just less likely to bite here since a
|
// `RecipeImportForm` (issue #53), just less likely to bite here since a
|
||||||
// manually-added line starts with no unit by design, right where the
|
// manually-added line starts with no unit by design, right where the
|
||||||
// person is already looking.
|
// person is already looking.
|
||||||
const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null);
|
const hasIngredientMissingUnit = ingredientLines.some(
|
||||||
|
(line) => !line.toTaste && line.unitId === null,
|
||||||
|
);
|
||||||
|
|
||||||
// Gates the submit button — the schema (checked again on submit, see
|
// Gates the submit button — the schema (checked again on submit, see
|
||||||
// `handleSubmit`) is the source of truth, this is just instant feedback
|
// `handleSubmit`) is the source of truth, this is just instant feedback
|
||||||
// that doesn't need a round trip through zod on every keystroke.
|
// that doesn't need a round trip through zod on every keystroke. A "selon
|
||||||
|
// le goût" line needs neither quantity nor unit.
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
name.trim().length > 0 &&
|
name.trim().length > 0 &&
|
||||||
Number.isInteger(Number(portions)) &&
|
Number.isInteger(Number(portions)) &&
|
||||||
Number(portions) > 0 &&
|
Number(portions) > 0 &&
|
||||||
ingredientLines.length > 0 &&
|
ingredientLines.length > 0 &&
|
||||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
ingredientLines.every(
|
||||||
|
(line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null),
|
||||||
|
) &&
|
||||||
steps.length > 0 &&
|
steps.length > 0 &&
|
||||||
steps.every((step) => step.description.trim().length > 0);
|
steps.every((step) => step.description.trim().length > 0);
|
||||||
|
|
||||||
|
|
@ -207,7 +209,6 @@ export function RecipeFormPage() {
|
||||||
picture: picture.trim() || null,
|
picture: picture.trim() || null,
|
||||||
portions: Number(portions),
|
portions: Number(portions),
|
||||||
visibility,
|
visibility,
|
||||||
dietIds,
|
|
||||||
ingredients: ingredientLines.map((line) => ({
|
ingredients: ingredientLines.map((line) => ({
|
||||||
// A just-added free-text line has no catalog id yet — ask the API to
|
// A just-added free-text line has no catalog id yet — ask the API to
|
||||||
// create the placeholder row via `placeholderName`. An edit-loaded
|
// create the placeholder row via `placeholderName`. An edit-loaded
|
||||||
|
|
@ -216,13 +217,18 @@ export function RecipeFormPage() {
|
||||||
...(isUnsavedPlaceholder(line.ingredient)
|
...(isUnsavedPlaceholder(line.ingredient)
|
||||||
? { placeholderName: line.ingredient.displayName ?? "" }
|
? { placeholderName: line.ingredient.displayName ?? "" }
|
||||||
: { ingredientId: line.ingredient.id }),
|
: { ingredientId: line.ingredient.id }),
|
||||||
|
// A "selon le goût" line submits with neither — the schema requires
|
||||||
|
// quantity and unit to be both present or both absent.
|
||||||
|
...(line.toTaste
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
quantity: Number(line.quantity),
|
quantity: Number(line.quantity),
|
||||||
// `canSubmit` already requires every line to have a unit picked
|
// `canSubmit` already requires every non-toTaste line to have a
|
||||||
// before the button is enabled — `?? 0` is just to satisfy the
|
// unit — `?? 0` only satisfies the type; the schema's
|
||||||
// type here; if it's ever reached with no unit set, the schema's
|
// `positive()` check rejects a 0 the same way it rejects an
|
||||||
// `positive()` check rejects it the same way an invalid quantity
|
// invalid quantity.
|
||||||
// already does.
|
|
||||||
unitId: line.unitId ?? 0,
|
unitId: line.unitId ?? 0,
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
steps: steps.map((step) => ({
|
steps: steps.map((step) => ({
|
||||||
description: step.description.trim(),
|
description: step.description.trim(),
|
||||||
|
|
@ -321,8 +327,6 @@ export function RecipeFormPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
|
||||||
|
|
||||||
<section className="recipe-form__section">
|
<section className="recipe-form__section">
|
||||||
<h2>{t("recipes.ingredientsTitle")}</h2>
|
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||||
<ul className="recipe-form__ingredient-list">
|
<ul className="recipe-form__ingredient-list">
|
||||||
|
|
@ -333,8 +337,10 @@ export function RecipeFormPage() {
|
||||||
quantity={line.quantity}
|
quantity={line.quantity}
|
||||||
unitId={line.unitId}
|
unitId={line.unitId}
|
||||||
unitsCatalog={unitsCatalog}
|
unitsCatalog={unitsCatalog}
|
||||||
|
toTaste={line.toTaste}
|
||||||
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||||
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
||||||
|
onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })}
|
||||||
onRemove={() => removeIngredientLine(line.key)}
|
onRemove={() => removeIngredientLine(line.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,9 @@ export function RecipesPage() {
|
||||||
<div className="recipes-page">
|
<div className="recipes-page">
|
||||||
<div className="recipes-page__header">
|
<div className="recipes-page__header">
|
||||||
<h1>{t("recipes.title")}</h1>
|
<h1>{t("recipes.title")}</h1>
|
||||||
{activeSourceKey === null && (
|
{/* One field for every tab — real ones and each source's (issue
|
||||||
|
#133). The term is kept here, above `RecipeSourcesPanel`'s
|
||||||
|
`key`-forced remount, so switching sources keeps the query. */}
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="recipes-page__search"
|
className="recipes-page__search"
|
||||||
|
|
@ -187,7 +189,6 @@ export function RecipesPage() {
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
||||||
{t("recipes.newButton")}
|
{t("recipes.newButton")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -203,6 +204,7 @@ export function RecipesPage() {
|
||||||
<RecipeSourcesPanel
|
<RecipeSourcesPanel
|
||||||
key={activeSourceKey}
|
key={activeSourceKey}
|
||||||
sourceKey={activeSourceKey}
|
sourceKey={activeSourceKey}
|
||||||
|
debouncedSearch={debouncedSearch}
|
||||||
// Only meaningful when it actually belongs to this source — e.g.
|
// Only meaningful when it actually belongs to this source — e.g.
|
||||||
// clicking straight from TheMealDB's item "9999" to Marmiton's tab
|
// clicking straight from TheMealDB's item "9999" to Marmiton's tab
|
||||||
// changes `activeSourceKey` before `selectedSourceItem` (URL-driven)
|
// changes `activeSourceKey` before `selectedSourceItem` (URL-driven)
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ type ShoppingListState =
|
||||||
| { status: "loaded"; list: ShoppingListView }
|
| { status: "loaded"; list: ShoppingListView }
|
||||||
| { status: "error" };
|
| { status: "error" };
|
||||||
|
|
||||||
/** React list key for a `(ingredient, unit)` line — also the key the "in the cart" checklist is stored under (see {@link shoppingListProgress}). */
|
/** React list key for a `(ingredient, unit)` line — also the key the "in the cart" checklist is stored under (see {@link shoppingListProgress}). A "selon le goût" line has no unit; `"tt"` keeps its key distinct from any real unit line for the same ingredient. */
|
||||||
function itemKey(item: ShoppingListItemView): string {
|
function itemKey(item: ShoppingListItemView): string {
|
||||||
return `${item.ingredient.id}-${item.unit.id}`;
|
return `${item.ingredient.id}-${item.unit?.id ?? "tt"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -149,8 +149,11 @@ function ShoppingListItems({ weekKey, items }: { weekKey: string; items: Shoppin
|
||||||
{ingredientLabel(item.ingredient, t)}
|
{ingredientLabel(item.ingredient, t)}
|
||||||
</span>
|
</span>
|
||||||
<span className="shopping-list__item-quantity">
|
<span className="shopping-list__item-quantity">
|
||||||
{formatShoppingListQuantity(item.quantity)}{" "}
|
{item.quantity === null || item.unit === null
|
||||||
{t(`catalog.units.${item.unit.key}`)}
|
? t("shoppingList.toTaste")
|
||||||
|
: `${formatShoppingListQuantity(item.quantity)} ${t(
|
||||||
|
`catalog.units.${item.unit.key}`,
|
||||||
|
)}`}
|
||||||
</span>
|
</span>
|
||||||
</CheckboxOption>
|
</CheckboxOption>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@ services:
|
||||||
# apps/api/Dockerfile) — no separate nginx/web container, no cross-origin
|
# apps/api/Dockerfile) — no separate nginx/web container, no cross-origin
|
||||||
# CORS_ORIGIN to keep in sync between two ports.
|
# CORS_ORIGIN to keep in sync between two ports.
|
||||||
app:
|
app:
|
||||||
|
# Prebuilt image pushed by .github/workflows/release.yml to the Forgejo
|
||||||
|
# container registry on every `vX.Y.Z` tag. `IMAGE_TAG` (root .env,
|
||||||
|
# default `latest`) pins which release to run — a pull-based Portainer
|
||||||
|
# stack / `docker compose pull` uses this. The `build:` block below stays
|
||||||
|
# as a local fallback: `docker compose up` builds it if the image isn't
|
||||||
|
# present, `docker compose build` (re)creates it under this same name.
|
||||||
|
image: git.dev.kyuno.fr/kyuno/batchcooking-app:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/api/Dockerfile
|
dockerfile: apps/api/Dockerfile
|
||||||
|
|
@ -54,6 +61,16 @@ services:
|
||||||
# has every /admin/* route 401. Must be a *different* secret than
|
# has every /admin/* route 401. Must be a *different* secret than
|
||||||
# JWT_SECRET.
|
# JWT_SECRET.
|
||||||
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
|
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
|
||||||
|
# Forgejo integration for "create a ticket from a contact message"
|
||||||
|
# (apps/web's /admin/messages, issue #81). All three read from this
|
||||||
|
# stack's .env; the feature fails closed — the button is hidden and
|
||||||
|
# the endpoint answers 503 — unless all three are set to a non-empty
|
||||||
|
# value, same posture as ADMIN_JWT_SECRET above. A `.dockerignore`d
|
||||||
|
# `.env` never reaches this container, so they must be passed here
|
||||||
|
# explicitly; a blank value counts as unset (see config/env.ts).
|
||||||
|
FORGEJO_BASE_URL: ${FORGEJO_BASE_URL:-}
|
||||||
|
FORGEJO_TOKEN: ${FORGEJO_TOKEN:-}
|
||||||
|
FORGEJO_REPO: ${FORGEJO_REPO:-}
|
||||||
# Compose network service name, not localhost — same reasoning as
|
# Compose network service name, not localhost — same reasoning as
|
||||||
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
||||||
# tech-step-intent-service is a core dependency (see its own entry
|
# tech-step-intent-service is a core dependency (see its own entry
|
||||||
|
|
@ -77,6 +94,9 @@ services:
|
||||||
# port — reachable only from `app` on the compose network, nothing ever
|
# port — reachable only from `app` on the compose network, nothing ever
|
||||||
# calls into it from outside.
|
# calls into it from outside.
|
||||||
tech-step-intent-service:
|
tech-step-intent-service:
|
||||||
|
# See the `app` service's `image:` comment — same release-pushed / local
|
||||||
|
# fallback split, shared `IMAGE_TAG`.
|
||||||
|
image: git.dev.kyuno.fr/kyuno/batchcooking-intent-service:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: services/tech-step-intent-service/Dockerfile
|
dockerfile: services/tech-step-intent-service/Dockerfile
|
||||||
|
|
@ -121,6 +141,9 @@ services:
|
||||||
# less deployment can omit this service entirely and `app` still runs
|
# less deployment can omit this service entirely and `app` still runs
|
||||||
# fine, just without the offline audit/feedback-loop jobs.
|
# fine, just without the offline audit/feedback-loop jobs.
|
||||||
tech-step-llm-worker:
|
tech-step-llm-worker:
|
||||||
|
# See the `app` service's `image:` comment — same release-pushed / local
|
||||||
|
# fallback split, shared `IMAGE_TAG`.
|
||||||
|
image: git.dev.kyuno.fr/kyuno/batchcooking-llm-worker:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: services/tech-step-llm-worker/Dockerfile
|
dockerfile: services/tech-step-llm-worker/Dockerfile
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js";
|
||||||
export * from "./data/catalog-labels-fr.js";
|
export * from "./data/catalog-labels-fr.js";
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
export * from "./logic/planning-portions.js";
|
export * from "./logic/planning-portions.js";
|
||||||
|
export * from "./logic/recipe-diets.js";
|
||||||
export * from "./schemas/account.js";
|
export * from "./schemas/account.js";
|
||||||
export * from "./schemas/admin.js";
|
export * from "./schemas/admin.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
|
|
|
||||||
71
packages/shared/src/logic/recipe-diets.ts
Normal file
71
packages/shared/src/logic/recipe-diets.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import type { DietView, IngredientView } from "../types/reference.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure derivation of a recipe's dietary regimes from its ingredients — no
|
||||||
|
* I/O, so it runs identically in the API (shaping `RecipeSummaryView.diets`
|
||||||
|
* / `RecipeView.diets`) and, if ever needed, the web app. Colocated in
|
||||||
|
* `@batch-cooking/shared` for that "one formula, both sides" reason, same as
|
||||||
|
* {@link ../logic/planning-portions}.
|
||||||
|
*
|
||||||
|
* Regimes are no longer hand-tagged on a recipe (issue #131) — `RecipeDiet`
|
||||||
|
* was dropped. A recipe now follows a regime **iff every one of its
|
||||||
|
* ingredients is compatible with it** (`IngredientView.diets`, resolved from
|
||||||
|
* the `IngredientDiet` seed data), with two special cases the seed
|
||||||
|
* deliberately never stores on `IngredientDiet` (see its schema doc
|
||||||
|
* comment):
|
||||||
|
* - `omnivore` — every ingredient trivially qualifies, so it's never
|
||||||
|
* surfaced as a tag (would be pure noise).
|
||||||
|
* - `glutenFree` — derived here from allergens instead: the recipe is
|
||||||
|
* gluten-free iff no ingredient carries the `gluten` allergen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regime keys the `IngredientDiet` seed actually stores compatibility
|
||||||
|
* for (`reference-seed-data.ts`'s `defaultDiets`/`dietUids`) — in the
|
||||||
|
* canonical `DIETS` order, minus the two special cases above.
|
||||||
|
*/
|
||||||
|
const INGREDIENT_DERIVED_DIET_KEYS = ["vegetarian", "vegan", "pescatarian"] as const;
|
||||||
|
|
||||||
|
/** Allergen key (`AllergyView.key`) that rules a recipe out of `glutenFree`. */
|
||||||
|
const GLUTEN_ALLERGEN_KEY = "gluten";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regimes a recipe qualifies for, as full {@link DietView}s, in
|
||||||
|
* canonical order. Empty for a recipe with **no ingredients**, or with **any
|
||||||
|
* placeholder (off-catalog) ingredient** — an ingredient the catalog doesn't
|
||||||
|
* know can't certify anything.
|
||||||
|
*
|
||||||
|
* The three ingredient-derived regimes' `DietView`s are taken straight from
|
||||||
|
* the ingredients' own already-resolved `diets` (`{ id, key }`); `glutenFree`
|
||||||
|
* has no `IngredientDiet` rows to borrow one from, so its `DietView` is
|
||||||
|
* passed in by the caller (which has the reference `Diet` table to hand).
|
||||||
|
* Pass `undefined` to leave `glutenFree` out entirely.
|
||||||
|
*/
|
||||||
|
export function computeRecipeDietViews(
|
||||||
|
ingredients: Pick<IngredientView, "diets" | "allergens" | "isPlaceholder">[],
|
||||||
|
glutenFreeDiet: DietView | undefined,
|
||||||
|
): DietView[] {
|
||||||
|
if (ingredients.length === 0 || ingredients.some((ingredient) => ingredient.isPlaceholder)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: DietView[] = [];
|
||||||
|
for (const key of INGREDIENT_DERIVED_DIET_KEYS) {
|
||||||
|
const perIngredient = ingredients.map((ingredient) =>
|
||||||
|
ingredient.diets.find((diet) => diet.key === key),
|
||||||
|
);
|
||||||
|
const first = perIngredient[0];
|
||||||
|
if (first !== undefined && perIngredient.every((diet) => diet !== undefined)) {
|
||||||
|
result.push(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const carriesGluten = ingredients.some((ingredient) =>
|
||||||
|
ingredient.allergens.some((allergen) => allergen.key === GLUTEN_ALLERGEN_KEY),
|
||||||
|
);
|
||||||
|
if (!carriesGluten && glutenFreeDiet !== undefined) {
|
||||||
|
result.push(glutenFreeDiet);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,24 @@ export const adminLoginSchema = z.object({
|
||||||
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
||||||
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
|
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `POST /admin/api/users` — an operator provisioning an
|
||||||
|
* end-user account by hand (test accounts, pre-registration, support). Its
|
||||||
|
* own contract, not a re-export of `signupSchema` (same stance as
|
||||||
|
* {@link adminLoginSchema} vs `loginSchema`), and notably **without a
|
||||||
|
* `password` field**: the server generates a one-time temporary password
|
||||||
|
* and returns it once (see `apps/api`'s `admin-users.service.ts`). The
|
||||||
|
* name/email rules mirror `signupSchema` so a hand-created account is
|
||||||
|
* indistinguishable from a self-signed-up one afterwards.
|
||||||
|
*/
|
||||||
|
export const adminCreateUserSchema = z.object({
|
||||||
|
firstName: z.string().trim().min(1, "Le prénom est requis").max(100),
|
||||||
|
lastName: z.string().trim().min(1, "Le nom est requis").max(100),
|
||||||
|
email: z.string().trim().toLowerCase().email("Email invalide"),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link adminCreateUserSchema}'s validated output. */
|
||||||
|
export type AdminCreateUserInput = z.infer<typeof adminCreateUserSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query params for `GET /admin/api/metrics` — `?days=` bounds how far back the
|
* Query params for `GET /admin/api/metrics` — `?days=` bounds how far back the
|
||||||
* time-series go (and how many daily buckets they carry). Coerced from the
|
* time-series go (and how many daily buckets they carry). Coerced from the
|
||||||
|
|
@ -129,6 +147,26 @@ export const markPlaceholdersReviewedSchema = z.object({
|
||||||
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
|
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
|
||||||
export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>;
|
export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query params for `GET /admin/api/catalog/tech-step-placeholders` — the
|
||||||
|
* missing technique review (issue #130). Same tri-state `reviewed` as
|
||||||
|
* {@link listPlaceholdersQuerySchema}.
|
||||||
|
*/
|
||||||
|
export const listTechStepPlaceholdersQuerySchema = z.object({
|
||||||
|
reviewed: z.enum(["true", "false"]).optional(),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link listTechStepPlaceholdersQuerySchema}. */
|
||||||
|
export type ListTechStepPlaceholdersQuery = z.infer<typeof listTechStepPlaceholdersQuerySchema>;
|
||||||
|
|
||||||
|
/** Body of `PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed` — stamps `reviewedAt` on the given placeholder `TechStep` ids. */
|
||||||
|
export const markTechStepPlaceholdersReviewedSchema = z.object({
|
||||||
|
techStepIds: z.array(z.number().int().positive()).min(1).max(500),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link markTechStepPlaceholdersReviewedSchema}. */
|
||||||
|
export type MarkTechStepPlaceholdersReviewedInput = z.infer<
|
||||||
|
typeof markTechStepPlaceholdersReviewedSchema
|
||||||
|
>;
|
||||||
|
|
||||||
// --- Guided-tour scenario CRUD (`/admin/api/tours`, issue #44) ----------
|
// --- Guided-tour scenario CRUD (`/admin/api/tours`, issue #44) ----------
|
||||||
|
|
||||||
/** One step of a scenario, as sent by the admin editor. `route` is `null` to stay on the previous step's route; `title`/`content` are free text. */
|
/** One step of a scenario, as sent by the admin editor. `route` is `null` to stay on the previous step's route; `title`/`content` are free text. */
|
||||||
|
|
|
||||||
|
|
@ -16,21 +16,28 @@ import { z } from "zod";
|
||||||
* row for this line (see `Ingredient.isPlaceholder` in schema.prisma and
|
* row for this line (see `Ingredient.isPlaceholder` in schema.prisma and
|
||||||
* `recipe.service.ts`'s `createRecipeInternal`); it is never browsable.
|
* `recipe.service.ts`'s `createRecipeInternal`); it is never browsable.
|
||||||
*
|
*
|
||||||
* Requiring exactly one keeps `RecipeIngredient` unchanged (still a real
|
* `quantity`/`unitId` are **both present or both absent** — absent means a
|
||||||
* `ingredientId` after the service resolves the line).
|
* "selon le goût" seasoning line (salt, pepper…), stored as `null`/`null` on
|
||||||
|
* `RecipeIngredient`, excluded from portion scaling and shopping-list
|
||||||
|
* aggregation. A quantity without a unit (or the reverse) is rejected here.
|
||||||
*/
|
*/
|
||||||
const recipeIngredientInputSchema = z
|
const recipeIngredientInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
ingredientId: z.number().int().positive().optional(),
|
ingredientId: z.number().int().positive().optional(),
|
||||||
/** Free-text ingredient name for a line the catalog couldn't cover — see this schema's doc comment. Mutually exclusive with `ingredientId`. */
|
/** Free-text ingredient name for a line the catalog couldn't cover — see this schema's doc comment. Mutually exclusive with `ingredientId`. */
|
||||||
placeholderName: z.string().trim().min(1).max(120).optional(),
|
placeholderName: z.string().trim().min(1).max(120).optional(),
|
||||||
quantity: z.number().positive("La quantité doit être positive"),
|
/** Omitted for a "selon le goût" line — see this schema's doc comment. Present ⇒ `unitId` present too. */
|
||||||
/** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */
|
quantity: z.number().positive("La quantité doit être positive").optional(),
|
||||||
unitId: z.number().int().positive(),
|
/** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. Omitted for a "selon le goût" line. */
|
||||||
|
unitId: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), {
|
.refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), {
|
||||||
message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre",
|
message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre",
|
||||||
path: ["ingredientId"],
|
path: ["ingredientId"],
|
||||||
|
})
|
||||||
|
.refine((line) => (line.quantity === undefined) === (line.unitId === undefined), {
|
||||||
|
message: "Indiquez une quantité et une unité, ou aucune des deux (« selon le goût »)",
|
||||||
|
path: ["quantity"],
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,8 +68,6 @@ export const createRecipeSchema = z
|
||||||
/** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */
|
/** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */
|
||||||
portions: z.number().int().positive("Le nombre de portions doit être positif"),
|
portions: z.number().int().positive("Le nombre de portions doit être positif"),
|
||||||
visibility: recipeVisibilitySchema.default("PERSONAL"),
|
visibility: recipeVisibilitySchema.default("PERSONAL"),
|
||||||
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
|
|
||||||
dietIds: z.array(z.number().int().positive()),
|
|
||||||
ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
|
ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
|
||||||
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
|
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
|
||||||
})
|
})
|
||||||
|
|
@ -230,6 +235,13 @@ const manualStepTechStepTemperatureInputSchema = z
|
||||||
* `description` length to validate `start`/`end` against, which this shape
|
* `description` length to validate `start`/`end` against, which this shape
|
||||||
* alone can't see.
|
* alone can't see.
|
||||||
*
|
*
|
||||||
|
* `correctedTechStepName` is the escape hatch for a technique the catalog
|
||||||
|
* doesn't have at all (issue #130): free text instead of an id. The service
|
||||||
|
* creates a placeholder `TechStep` (`isPlaceholder: true`) for it and then
|
||||||
|
* proceeds exactly as if `correctedTechStepId` had named that new row.
|
||||||
|
* Mutually exclusive with `correctedTechStepId`; either one satisfies every
|
||||||
|
* "a corrected technique is required" check below.
|
||||||
|
*
|
||||||
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
|
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
|
||||||
* the technique they're asserting (`correctedTechStepId`), same
|
* the technique they're asserting (`correctedTechStepId`), same
|
||||||
* `source: "manual"` distinction the technique itself gets. The three
|
* `source: "manual"` distinction the technique itself gets. The three
|
||||||
|
|
@ -255,6 +267,8 @@ export const submitTechStepCorrectionSchema = z
|
||||||
end: z.number().int().nonnegative(),
|
end: z.number().int().nonnegative(),
|
||||||
previousTechStepId: z.number().int().positive().nullable().optional(),
|
previousTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
|
/** Free-text technique the catalog is missing — see this schema's doc comment. Mutually exclusive with `correctedTechStepId`. */
|
||||||
|
correctedTechStepName: z.string().trim().min(1).max(80).optional(),
|
||||||
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
|
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
|
||||||
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
|
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
|
||||||
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
|
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
|
||||||
|
|
@ -264,10 +278,20 @@ export const submitTechStepCorrectionSchema = z
|
||||||
path: ["end"],
|
path: ["end"],
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(input) =>
|
(input) => input.correctedTechStepId == null || input.correctedTechStepName === undefined,
|
||||||
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
|
|
||||||
{
|
{
|
||||||
message: "at least one of previousTechStepId/correctedTechStepId is required",
|
message: "provide correctedTechStepId or correctedTechStepName, not both",
|
||||||
|
path: ["correctedTechStepName"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
(input.previousTechStepId ?? null) !== null ||
|
||||||
|
(input.correctedTechStepId ?? null) !== null ||
|
||||||
|
input.correctedTechStepName !== undefined,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"at least one of previousTechStepId/correctedTechStepId/correctedTechStepName is required",
|
||||||
path: ["correctedTechStepId"],
|
path: ["correctedTechStepId"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -276,9 +300,10 @@ export const submitTechStepCorrectionSchema = z
|
||||||
(input.ingredients === undefined &&
|
(input.ingredients === undefined &&
|
||||||
input.utensils === undefined &&
|
input.utensils === undefined &&
|
||||||
input.temperatures === undefined) ||
|
input.temperatures === undefined) ||
|
||||||
(input.correctedTechStepId ?? null) !== null,
|
(input.correctedTechStepId ?? null) !== null ||
|
||||||
|
input.correctedTechStepName !== undefined,
|
||||||
{
|
{
|
||||||
message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to",
|
message: "ingredients/utensils/temperatures require a corrected technique to attach to",
|
||||||
path: ["correctedTechStepId"],
|
path: ["correctedTechStepId"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,37 @@ export interface AdminUserView {
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /admin/api/users` — an end-user account as shown in the
|
||||||
|
* admin's user list. Declared by hand rather than derived from the Prisma
|
||||||
|
* `UserProfile` type (same reason as {@link AdminUserView} / {@link
|
||||||
|
* SafeUserProfile}: apps/web must not depend on `@prisma/client`), and
|
||||||
|
* deliberately *not* {@link SafeUserProfile}: this view drops `tokenVersion`
|
||||||
|
* (internal) and adds `householdName` (resolved server-side) + `createdAt`.
|
||||||
|
* `householdName` is `null` when the account belongs to no household;
|
||||||
|
* `createdAt` is an ISO 8601 string.
|
||||||
|
*/
|
||||||
|
export interface AdminUserListItemView {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
householdName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `POST /admin/api/users`. Carries the freshly-created account
|
||||||
|
* plus the **one-time** temporary password the server generated for it —
|
||||||
|
* never stored in plain text, never retrievable again, so the operator must
|
||||||
|
* copy it now and relay it to the user out of band.
|
||||||
|
*/
|
||||||
|
export interface AdminUserCreatedView {
|
||||||
|
user: AdminUserListItemView;
|
||||||
|
/** Plain-text temporary password, shown exactly once. */
|
||||||
|
temporaryPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */
|
/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */
|
||||||
export interface MetricsBreakdownRow {
|
export interface MetricsBreakdownRow {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -228,3 +259,25 @@ export interface CatalogPlaceholderGroupView {
|
||||||
export interface PruneOrphansResultView {
|
export interface PruneOrphansResultView {
|
||||||
deleted: number;
|
deleted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /admin/api/catalog/tech-step-placeholders` — a placeholder
|
||||||
|
* `TechStep` (see `TechStep.isPlaceholder`, issue #130): a technique a user
|
||||||
|
* typed while correcting a step because the catalog was missing it. A flat
|
||||||
|
* list, not grouped like the ingredient placeholders — correcting a
|
||||||
|
* technique (let alone hitting a missing one) is rare enough that
|
||||||
|
* near-duplicate spellings aren't the problem there.
|
||||||
|
*/
|
||||||
|
export interface TechStepPlaceholderView {
|
||||||
|
id: number;
|
||||||
|
/** The exact text the user typed. */
|
||||||
|
displayName: string;
|
||||||
|
/** ISO 8601, or `null` for a row created before the column existed. */
|
||||||
|
createdAt: string | null;
|
||||||
|
/** ISO 8601 once a maintainer has triaged it, else `null`. */
|
||||||
|
reviewedAt: string | null;
|
||||||
|
/** Distinct recipes whose steps currently carry this placeholder technique. */
|
||||||
|
recipeCount: number;
|
||||||
|
/** Up to 5 of those recipes, for a "seen in…" preview. */
|
||||||
|
sampleRecipes: { id: number; name: string }[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,17 @@ export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC";
|
||||||
* allergens), plus the quantity specific to this recipe (carried by
|
* allergens), plus the quantity specific to this recipe (carried by
|
||||||
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit`
|
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit`
|
||||||
* is likewise resolved to its full reference data (`Unit`) rather than a
|
* is likewise resolved to its full reference data (`Unit`) rather than a
|
||||||
* raw key — same "resolve at read time" treatment as `ingredient`, now that
|
* raw key — same "resolve at read time" treatment as `ingredient`.
|
||||||
* it's a catalog reference instead of free text (see `UnitView`).
|
*
|
||||||
|
* `quantity` and `unit` are **both `null`** for a "selon le goût" seasoning
|
||||||
|
* line (salt, pepper… added to taste) — see `RecipeIngredient.quantity` in
|
||||||
|
* schema.prisma. A consumer showing a quantity must handle that case (e.g.
|
||||||
|
* render "selon le goût" instead of a number).
|
||||||
*/
|
*/
|
||||||
export interface RecipeIngredientView {
|
export interface RecipeIngredientView {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: number;
|
quantity: number | null;
|
||||||
unit: UnitView;
|
unit: UnitView | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -157,10 +161,12 @@ export interface StepView {
|
||||||
* the same aggregation `GET /recipes/:id` performs for {@link RecipeView},
|
* the same aggregation `GET /recipes/:id` performs for {@link RecipeView},
|
||||||
* kept consistent so the table's badges match the detail panel's.
|
* kept consistent so the table's badges match the detail panel's.
|
||||||
*
|
*
|
||||||
* `isFavorite` and `diets` are resolved for the *requesting* user/recipe —
|
* `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma).
|
||||||
* `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma), while
|
* `diets` is **computed from the recipe's ingredients**, not viewer-specific
|
||||||
* `diets` is a property of the recipe itself (manually tagged by its
|
* and no longer hand-tagged (issue #131, `RecipeDiet` dropped) — see
|
||||||
* author, not viewer-specific).
|
* {@link computeRecipeDietViews}: a regime iff every ingredient is
|
||||||
|
* compatible with it, plus `glutenFree` iff no ingredient carries the
|
||||||
|
* `gluten` allergen. `[]` for a recipe with any placeholder ingredient.
|
||||||
*/
|
*/
|
||||||
export interface RecipeSummaryView {
|
export interface RecipeSummaryView {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
|
||||||
|
|
@ -203,11 +203,24 @@ export interface UnitView {
|
||||||
*
|
*
|
||||||
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
|
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
|
||||||
* label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as
|
* label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as
|
||||||
* {@link DietView.key}.
|
* {@link DietView.key} — **except** for a placeholder (see `isPlaceholder`),
|
||||||
|
* whose label is `displayName` verbatim. Consumers resolve the label as
|
||||||
|
* `displayName ?? t(\`catalog.techSteps.${key}\`)` (helper: `apps/web`'s
|
||||||
|
* `features/recipes/steps/tech-step-label.ts`).
|
||||||
*/
|
*/
|
||||||
export interface TechStepView {
|
export interface TechStepView {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
|
/**
|
||||||
|
* `true` = a free-text technique a user typed while correcting a step
|
||||||
|
* because the catalog had nothing matching (issue #130) — see
|
||||||
|
* `TechStep.isPlaceholder` in schema.prisma. Never returned by
|
||||||
|
* `GET /reference/tech-steps` (the browsable catalog excludes them); only
|
||||||
|
* ever seen on a step's own detected-technique sequence.
|
||||||
|
*/
|
||||||
|
isPlaceholder: boolean;
|
||||||
|
/** The user-typed name when `isPlaceholder` — `null` for a real catalog technique (its label is in i18n). */
|
||||||
|
displayName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,17 @@ import type { IngredientView, UnitView } from "./reference.js";
|
||||||
* built (see that field's own doc comment), so the same ingredient
|
* built (see that field's own doc comment), so the same ingredient
|
||||||
* requested in two different units surfaces as two separate lines rather
|
* requested in two different units surfaces as two separate lines rather
|
||||||
* than silently guessing a conversion.
|
* than silently guessing a conversion.
|
||||||
|
*
|
||||||
|
* `quantity` and `unit` are **both `null`** on a "selon le goût" line — a
|
||||||
|
* seasoning ingredient (salt, pepper…) used by at least one planned recipe
|
||||||
|
* with no quantity to buy by (see `RecipeIngredient.quantity`,
|
||||||
|
* schema.prisma). Such a line appears once, deduplicated by ingredient, and
|
||||||
|
* is never summed.
|
||||||
*/
|
*/
|
||||||
export interface ShoppingListItemView {
|
export interface ShoppingListItemView {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: number;
|
quantity: number | null;
|
||||||
unit: UnitView;
|
unit: UnitView | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ apps/web/src/
|
||||||
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
|
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
|
||||||
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
|
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
|
||||||
│ │ └── settings-pages.scss
|
│ │ └── settings-pages.scss
|
||||||
│ └── onboarding/ # wizard d'inscription (régime → foyer → [sources] → allergènes), voir plus bas
|
│ └── onboarding/ # wizard d'inscription (régime → allergènes → foyer → [sources]), voir plus bas
|
||||||
│ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
|
│ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
|
||||||
│ └── onboarding.scss
|
│ └── onboarding.scss
|
||||||
├── styles/
|
├── styles/
|
||||||
|
|
@ -156,9 +156,11 @@ flowchart TB
|
||||||
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
|
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
|
||||||
un lien existant.
|
un lien existant.
|
||||||
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
|
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
|
||||||
sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `foyer` →
|
sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `allergenes`
|
||||||
`sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un
|
(les deux « préférences ») → `foyer` → `sources` (conditionnelle — seulement
|
||||||
foyer, sinon on saute directement à l'étape suivante) → `allergenes`.
|
si l'étape `foyer` a **créé** un foyer ; rejoindre/passer termine le wizard
|
||||||
|
vers `/`). L'ordre et les compteurs « sur 3 » / « 4 sur 4 » vivent dans
|
||||||
|
`pages/onboarding/onboarding-steps.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -226,11 +228,11 @@ Deux surfaces partagent les mêmes composants de champ (`features/profile/` +
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
|
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
|
||||||
OB1 --> OB2["/onboarding/foyer"]
|
OB1 --> OB2["/onboarding/allergenes"]
|
||||||
OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
|
OB2 --> OB3["/onboarding/foyer"]
|
||||||
OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
|
OB3 -->|"foyer créé"| OB4["/onboarding/sources"]
|
||||||
OB3 --> OB4
|
OB3 -->|"rejoint / passé"| HOME["/ (PlanningPage)"]
|
||||||
OB4 --> HOME["/ (PlanningPage)"]
|
OB4 --> HOME
|
||||||
|
|
||||||
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
|
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
|
||||||
SETTINGSMENU --> S2["/parametres/preferences"]
|
SETTINGSMENU --> S2["/parametres/preferences"]
|
||||||
|
|
@ -240,18 +242,23 @@ flowchart LR
|
||||||
SETTINGSMENU --> S6["/parametres/credits"]
|
SETTINGSMENU --> S6["/parametres/credits"]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
|
- **`pages/onboarding/`** — wizard de **3 écrans** (régime → allergènes →
|
||||||
allergènes), lancé une seule fois juste après l'inscription. Routes
|
foyer), + un **4ᵉ conditionnel** (sources), lancé une seule fois juste après
|
||||||
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein
|
l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous
|
||||||
écran sans sidebar (`onboarding.scss`, même langage visuel que
|
`AppLayout` : wizard plein écran sans sidebar (`onboarding.scss`, même
|
||||||
`/login`/`/signup`). Chaque étape a un unique bouton "Continuer" qui envoie
|
langage visuel que `/login`/`/signup`). Régime et allergènes forment les
|
||||||
la valeur courante — pas de bouton "Passer" séparé, une valeur
|
deux « préférences » ; chacun a un unique bouton "Continuer" qui envoie la
|
||||||
"aucune"/vide *est* le skip. `/onboarding/sources` est **conditionnelle** :
|
valeur courante — une valeur "aucune"/vide *est* le skip. L'étape `foyer`,
|
||||||
seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer
|
elle, est explicitement optionnelle (créer / rejoindre / "Passer cette
|
||||||
(`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers
|
étape"). `/onboarding/sources` est **conditionnelle** : seulement atteinte
|
||||||
`/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en
|
si l'étape `foyer` vient de **créer** un foyer
|
||||||
silence vers l'étape suivante si `GET /reference/sources` revient vide ou en
|
(`OnboardingHouseholdPage`'s `goToNextStep`) — rejoindre ou passer termine
|
||||||
erreur, plutôt que d'afficher une étape sans rien à choisir.
|
le wizard vers `/`. `OnboardingSourcesPage` elle-même redirige en silence
|
||||||
|
vers `/` si `GET /reference/sources` revient vide ou en erreur, plutôt que
|
||||||
|
d'afficher une étape sans rien à choisir. L'ordre des étapes et les
|
||||||
|
compteurs affichés (« Étape 1 sur 3 » … « 4 sur 4 » pour le chemin avec
|
||||||
|
sources) sont centralisés dans `pages/onboarding/onboarding-steps.ts` —
|
||||||
|
chaque page les lisait auparavant en dur, ce qu'un réordonnancement cassait.
|
||||||
- **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne
|
- **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne
|
||||||
`HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la
|
`HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la
|
||||||
section suivante pour le détail de chacune), toutes en **hot saving** (pas
|
section suivante pour le détail de chacune), toutes en **hot saving** (pas
|
||||||
|
|
@ -568,6 +575,14 @@ Contenu de l'onglet d'une source : sa propre paire maître-détail —
|
||||||
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
|
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
|
||||||
source, même convention "monté seulement tant que pertinent" que `Dialog`.
|
source, même convention "monté seulement tant que pertinent" que `Dialog`.
|
||||||
|
|
||||||
|
Le champ de recherche est **commun à tous les onglets** (issue #133) : il vit
|
||||||
|
dans l'en-tête de `RecipesPage` (et de `RecipePickerDialog`), pas dans le
|
||||||
|
panneau. `search`/`debouncedSearch` sont portés par la page ; `RecipeSourcesPanel`
|
||||||
|
reçoit `debouncedSearch` en prop et refait son `browse` quand elle change. La
|
||||||
|
requête tapée survit donc au changement de source (le `key={sourceKey}` remonte
|
||||||
|
toujours la liste + le préchargement, mais le terme est au-dessus). Sur les
|
||||||
|
onglets réels le même terme alimente `listRecipes({ search })`.
|
||||||
|
|
||||||
Pagination en scroll infini, pas de bouton "voir plus" : une ligne
|
Pagination en scroll infini, pas de bouton "voir plus" : une ligne
|
||||||
sentinelle invisible en fin de liste (`SourceItemTable`, un
|
sentinelle invisible en fin de liste (`SourceItemTable`, un
|
||||||
`IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le
|
`IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le
|
||||||
|
|
@ -747,6 +762,14 @@ Clic sur une ligne :
|
||||||
`:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
|
`:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
|
||||||
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
|
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
|
||||||
d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.
|
d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.
|
||||||
|
- **`PasswordInput.tsx`** (+ `password-input.scss`) — un `<input>` de mot de
|
||||||
|
passe avec un bouton œil (`lucide-react` `Eye`/`EyeOff`) qui bascule son
|
||||||
|
`type` entre `"password"` et `"text"`. Visibilité en état local, toujours
|
||||||
|
masquée au montage, jamais persistée. Bouton `type="button"` +
|
||||||
|
`aria-label` traduit (`common.passwordInput.*`) + `aria-pressed`. Le style
|
||||||
|
du champ lui-même est hérité du contexte (comme `CheckboxOption` via
|
||||||
|
`global.scss`) ; la feuille colocalisée ne fait que positionner le bouton.
|
||||||
|
Utilisé par `LoginPage` et `SignupPage`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue