Compare commits

..

24 commits

Author SHA1 Message Date
1eb7308527 Merge pull request 'ci(release): publie les images Docker sur le registre Forgejo' (#145) from feat/release-push-docker-images into main
All checks were successful
CI / lint (push) Successful in 3m29s
CI / build (push) Successful in 3m34s
CI / intent-service-test (push) Successful in 5m14s
CI / e2e (push) Successful in 11m15s
CI / test (push) Successful in 19m53s
CI / build-android (push) Successful in 5m17s
Reviewed-on: #145
2026-09-10 22:23:41 +02:00
7d1ee934ca ci(release): build et push des images Docker sur le registre Forgejo
All checks were successful
CI / lint (push) Successful in 2m48s
CI / build (push) Successful in 2m55s
CI / intent-service-test (push) Successful in 4m57s
CI / e2e (push) Successful in 9m28s
CI / test (push) Successful in 19m10s
CI / build-android (push) Has been skipped
Le job `sanity-build` (build de `apps/api/Dockerfile` sans push) devient
`build-and-push-images` : `docker login` sur le registre Forgejo intégré
via le token Actions du run (`permissions: packages: write`), puis build +
push des 3 images `batchcooking-{app,intent-service,llm-worker}` taguées
`vX.Y.Z` et `latest`. Un job séquentiel plutôt qu'une matrix — 3 builds
concurrents saturent le daemon Docker du runner unique (cf. mémoire
`ci-forgejo-runner-constraints`) sans gain réel pour une release.

`docker-compose.yml` référence maintenant ces images
(`image: git.dev.kyuno.fr/kyuno/batchcooking-*:${IMAGE_TAG:-latest}`) avec
le bloc `build:` gardé en repli local — Portainer peut tirer les images
pré-construites ou continuer à builder depuis Git. `.env.example` documente
`IMAGE_TAG` ; le README (section registre + Release) est réécrit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:03:26 +02:00
5a915a6cee Merge pull request 'feat(admin): espace « Utilisateurs » pour créer des comptes' (#144) from feat/admin-user-accounts into main
Some checks failed
CI / lint (push) Successful in 3m13s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 5m18s
CI / e2e (push) Successful in 11m42s
CI / test (push) Failing after 17m38s
CI / build-android (push) Has been skipped
Reviewed-on: #144
2026-09-10 21:48:29 +02:00
9e847e15a1 feat(admin): page « Utilisateurs » pour créer des comptes
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build (push) Successful in 3m16s
CI / intent-service-test (push) Successful in 5m35s
CI / e2e (push) Successful in 11m32s
CI / test (push) Successful in 19m46s
CI / build-android (push) Has been skipped
Nouvelle section `/admin/utilisateurs` (nav + route) : tableau des comptes
(date, nom, email, foyer) et un `Dialog` de création en deux temps —
formulaire prénom/nom/email, puis affichage unique du mot de passe
temporaire généré, avec bouton « Copier ». Validation cliente via
`adminCreateUserSchema`, erreurs API traduites (`EMAIL_ALREADY_IN_USE`
déjà mappé). Spec Cypress `admin-users.cy.ts` (API mockée).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 20:59:23 +02:00
0f8a640da2 feat(api): endpoints admin de gestion des comptes utilisateurs
`GET /admin/api/users` (liste, plus récent d'abord, plafonnée à 200) et
`POST /admin/api/users` (création à la main), tous deux derrière
`requireAdmin`. La création génère un mot de passe temporaire aléatoire
(`randomInt`, charset sans caractères ambigus, comme `generateInviteCode`)
renvoyé une seule fois ; le compte démarre sans foyer, comme une
inscription self-service. Évènement `admin.user_created` (fire-and-forget)
pour distinguer les comptes provisionnés à la main dans le dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 20:59:15 +02:00
9123f6358e feat(shared): schéma et vues pour la création de comptes par un admin
`adminCreateUserSchema` (prénom/nom/email, sans mot de passe — généré
côté serveur) + les vues `AdminUserListItemView` / `AdminUserCreatedView`
consommées par `POST`/`GET /admin/api/users`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 20:59:08 +02:00
d1430498e0 Merge pull request 'fix(admin): passe les variables FORGEJO_* au conteneur app' (#143) from fix/forgejo-env-passthrough into main
Some checks failed
CI / lint (push) Successful in 3m26s
CI / intent-service-test (push) Successful in 4m15s
CI / build (push) Successful in 3m45s
CI / e2e (push) Successful in 10m2s
CI / test (push) Successful in 24m26s
CI / build-android (push) Failing after 2m19s
Reviewed-on: #143
2026-09-09 23:09:11 +02:00
429aa06ca6 fix(admin): passe les variables FORGEJO_* au conteneur app
Some checks failed
CI / lint (push) Successful in 2m56s
CI / build (push) Successful in 3m4s
CI / intent-service-test (push) Successful in 4m52s
CI / e2e (push) Successful in 11m1s
CI / test (push) Failing after 18m29s
CI / build-android (push) Has been skipped
L'intégration Forgejo (« Créer un ticket » depuis un message de contact,
issue #81) restait invisible en prod Docker : `docker-compose.yml` ne
listait pas `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO` dans le
bloc `environment:` du service `app`, et `**/.env` est `.dockerignore`d — le
process API ne voyait donc jamais ces variables, `ForgejoService.isConfigured()`
renvoyait `false` et le bouton restait masqué sur `/admin/messages`.

- `docker-compose.yml` : les 3 `FORGEJO_*` passées au conteneur via
  `${VAR:-}`, même forme que `ADMIN_JWT_SECRET` / `INTERNAL_WORKER_SECRET`.
- `config/env.ts` : `blankAsUnset` sur les 3 champs — `${VAR:-}` arrive
  comme `""` quand le `.env` du stack laisse la variable vide ; sans ça une
  valeur vide ferait échouer `.url()` / la regex `owner/repo` et planter
  `envSchema.parse()` au boot. Même parade que `COOKIE_SECURE`. `envSchema`
  exporté pour le test.
- `.env.example` (racine) : documente les 3 variables au niveau compose.
- `apps/api/test/env.test.ts` : blanc = absent, + non-régression sur une
  valeur non vide mais mal formée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:58:13 +02:00
26bd48bf4b Merge pull request 'chore(release): prépare la v0.2.3' (#142) from chore/release-0.2.3 into main
All checks were successful
CI / lint (push) Successful in 4m21s
CI / build (push) Successful in 3m37s
CI / intent-service-test (push) Successful in 6m58s
Release / sanity-build (push) Successful in 10m28s
Release / build-android-apk (push) Successful in 9m32s
Release / forgejo-release (push) Successful in 49s
Release / notify-portainer (push) Successful in 1s
CI / test (push) Successful in 31m32s
CI / e2e (push) Successful in 7m15s
CI / build-android (push) Has been skipped
2026-09-09 21:45:18 +02:00
a02d289c92 chore(release): prépare la v0.2.3
All checks were successful
CI / lint (push) Successful in 3m28s
CI / build (push) Successful in 3m59s
CI / intent-service-test (push) Successful in 5m0s
CI / e2e (push) Successful in 10m45s
CI / test (push) Successful in 19m44s
CI / build-android (push) Has been skipped
`apps/web/package.json` 0.2.2 → 0.2.3 — alimente `__APP_VERSION__` (pied de
sidebar + comparaison de `useAppUpdateCheck`), même convention que
`chore(release): prépare la v0.2.2`.

Le tag `v0.2.3` déclenchera `release.yml` : APK signé (versionName 0.2.3,
versionCode 203) + Release Forgejo.

Contenu depuis v0.2.2 : tickets #128-#134 (issue #3 « Améliorations
diverses ») — assaisonnements « selon le goût », wizard d'inscription
réordonné, techniques hors-catalogue, régimes calculés depuis les
ingrédients, barre d'actions collante du formulaire recette, recherche
commune aux sources, afficher/masquer le mot de passe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 21:24:35 +02:00
09904cffc1 Merge pull request 'feat(auth): bouton afficher / masquer le mot de passe (#134)' (#141) from feat/login-password-toggle into main
All checks were successful
CI / lint (push) Successful in 3m20s
CI / build (push) Successful in 3m23s
CI / intent-service-test (push) Successful in 5m13s
CI / e2e (push) Successful in 12m8s
CI / test (push) Successful in 20m48s
CI / build-android (push) Successful in 5m54s
2026-09-09 20:44:32 +02:00
5a6ddfc287 chore(ci): relance — dépôt apt Google Chrome de nouveau cohérent
All checks were successful
CI / lint (push) Successful in 2m38s
CI / build (push) Successful in 2m44s
CI / intent-service-test (push) Successful in 6m0s
CI / e2e (push) Successful in 11m56s
CI / test (push) Successful in 20m1s
CI / build-android (push) Has been skipped
Le hash InRelease vs Packages.gz du dépôt apt Google Chrome (upstream)
correspond à nouveau (233e56de…) après ~21 min de désynchro CDN qui a fait
échouer runs 277-279 sur `apt-get update` (jobs test + e2e).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:03:02 +02:00
16ceeda031 chore(ci): relance après un flake d'infra (apt « Hash Sum mismatch » Google Chrome)
Some checks failed
CI / test (push) Failing after 26s
CI / e2e (push) Failing after 1m13s
CI / lint (push) Successful in 1m59s
CI / build (push) Successful in 2m11s
CI / intent-service-test (push) Successful in 4m5s
CI / build-android (push) Has been skipped
Run 278 : `test` et `e2e` ont échoué en ~1 min sur `apt-get update` (dépôt
apt Google Chrome incohérent en amont), avant toute exécution de code.
`lint` + `build` verts. Vérif locale complète verte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 19:39:01 +02:00
24bd2239fc feat(auth): bouton afficher / masquer le mot de passe (#134)
Some checks failed
CI / test (push) Failing after 1m0s
CI / e2e (push) Failing after 1m43s
CI / lint (push) Successful in 2m19s
CI / build (push) Successful in 2m28s
CI / intent-service-test (push) Successful in 4m22s
CI / build-android (push) Has been skipped
Nouveau composant `components/ui/PasswordInput` : un `<input>` de mot de
passe avec un bouton œil (`lucide-react` Eye/EyeOff) qui bascule le `type`
entre "password" et "text". Visibilité en état local, toujours masquée au
montage, jamais persistée. Bouton `type="button"` (ne soumet pas le
formulaire) + `aria-label` traduit (`common.passwordInput.*`) +
`aria-pressed`.

Branché dans `LoginPage` et `SignupPage` à la place du `<input
type="password">` nu. La règle `.auth-card button` de `auth-form.scss` est
resserrée sur `button[type="submit"]` pour que le bouton œil n'hérite pas
du traitement plein-largeur du bouton de connexion.

Tests : `cypress/component/PasswordInput.cy.tsx` (7 cas, composant isolé) +
`cypress/e2e/auth-form.cy.ts` (mode layout, l'œil sur /login et /signup).

Closes #134

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 19:33:29 +02:00
0c4e8d089c Merge pull request 'feat(recipes): barre de recherche commune à tous les onglets de sources (#133)' (#140) from feat/common-source-search into main
Some checks failed
CI / build (push) Successful in 3m1s
CI / lint (push) Successful in 3m4s
CI / intent-service-test (push) Successful in 4m51s
CI / e2e (push) Successful in 10m50s
CI / test (push) Failing after 17m11s
CI / build-android (push) Has been skipped
2026-09-09 19:18:38 +02:00
51b7b8eec3 feat(recipes): barre de recherche commune à tous les onglets de sources (#133)
All checks were successful
CI / lint (push) Successful in 3m39s
CI / build (push) Successful in 3m32s
CI / intent-service-test (push) Successful in 5m51s
CI / e2e (push) Successful in 8m32s
CI / test (push) Successful in 22m26s
CI / build-android (push) Has been skipped
Avant : chaque onglet de source rendait son propre champ recherche, avec
son propre state, et `RecipeSourcesPanel` est remonté (`key={sourceKey}`)
à chaque changement de source — la requête tapée était donc perdue en
passant d'une source à une autre.

Maintenant : un seul champ, dans l'en-tête de `RecipesPage`. `search` /
`debouncedSearch` (déjà présents pour les onglets réels) sont rendus en
permanence et `debouncedSearch` est passé en prop à `RecipeSourcesPanel`,
qui n'a plus ni `useState` de recherche, ni `<input>`, ni effet de
debounce internes — son effet de `browse` dépend de la prop. La requête
survit donc au changement de source (le `key` remonte toujours la liste +
le préchargement, mais le terme vit au-dessus), et elle est commune aux
onglets réels comme aux sources.

`RecipePickerDialog` : même chose — le champ recherche sort du bloc de
filtres `activeSourceKey === null` (les filtres ingrédients/régimes/foyer
y restent, ils ne concernent que les onglets en base) et `debouncedSearch`
est passé au panneau.

- SCSS : `.recipes-page__header--sources` supprimé (le panneau n'a plus
  d'en-tête). Clé i18n morte `recipes.sources.searchPlaceholder` retirée.
- `specs/frontend-architecture.md` : recherche au niveau page.

Tests : `recipe-sources.feature` — nouveau scénario « la requête est
conservée en changeant d'onglet de source » (tape « poulet » sur TheMealDB,
bascule sur Marmiton, le champ garde « poulet » et Marmiton est interrogé
avec `query=poulet`). `pnpm lint` / `pnpm build` verts ; Cypress local :
recipe-sources 8/8, recipes 5/5, recipes.cy 12/12, planning 3/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:10:09 +02:00
36d26c2739 Merge pull request 'feat(onboarding): réordonne le wizard en compte → préférences → foyer → sources (#129)' (#139) from feat/onboarding-wizard-order into main
Some checks failed
CI / lint (push) Successful in 3m8s
CI / build (push) Successful in 3m22s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 10m40s
CI / test (push) Failing after 16m36s
CI / build-android (push) Has been skipped
Closes #129.
2026-09-09 16:59:49 +02:00
2a3fb0434a feat(onboarding): réordonne le wizard en compte → préférences → foyer → sources (#129)
All checks were successful
CI / lint (push) Successful in 3m54s
CI / build (push) Successful in 4m10s
CI / intent-service-test (push) Successful in 5m15s
CI / e2e (push) Successful in 8m32s
CI / test (push) Successful in 23m8s
CI / build-android (push) Has been skipped
Nouvel ordre : régime → allergènes (les deux « préférences ») → foyer →
sources. L'étape sources devient la dernière et n'est atteinte que si
l'étape foyer vient de **créer** un foyer — rejoindre ou passer termine le
wizard vers `/` (un foyer rejoint a déjà ses sources ; un foyer passé n'a
rien à quoi les rattacher).

- `onboarding-steps.ts` (nouveau) : séquence des étapes + `onboardingStepIndicator()`
  en un seul endroit. Les compteurs « Étape N sur M » étaient codés en dur
  page par page, ce qu'un réordonnancement cassait. régime/allergènes/foyer
  sont en amont du choix « créer vs rejoindre/passer » donc affichent « sur 3 » ;
  seule l'étape sources, jamais atteinte hors du chemin à 4 étapes, affiche « 4 sur 4 ».
- `OnboardingDietPage` → enchaîne sur `/onboarding/allergenes`.
- `OnboardingAllergensPage` → enchaîne sur `/onboarding/foyer` ; bouton « Continuer »
  (plus « Terminer ») ; suppression du `getCurrentHouse()` + `totalSteps` dynamique,
  devenus inutiles maintenant que cette étape précède le foyer.
- `OnboardingHouseholdPage` → `goToNextStep(createdHousehold)` : `/onboarding/sources`
  seulement si créé, sinon `/`. `JoinHouseholdForm`/`SkipButton`/branche défensive
  « déjà un foyer » terminent vers `/`.
- `OnboardingSourcesPage` → dernière étape : submit, court-circuit « aucune source »
  et catch vont tous vers `/` ; bouton « Terminer ».
- `App.tsx` : blocs `<Route>` réordonnés + commentaire de doc du groupe.
- `specs/frontend-architecture.md` : ordre, diagramme mermaid, condition sources.

Tests : `onboarding.feature` réécrit — 4 parcours (walk-through complet avec
foyer créé + sources ; préférences + foyer passés ; foyer créé sans source
implémentée → étape sources auto-sautée ; foyer rejoint → pas de sources),
compteurs vérifiés sur chaque chemin. `pnpm lint` / `pnpm build` verts ;
Cypress local : onboarding 4/4, régression auth/preferences/household-settings 16/16.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:34:49 +02:00
61079b3a2a Merge pull request 'feat(recipes): signaler une technique absente du catalogue (#130)' (#138) from feat/missing-tech-steps into main
Some checks failed
CI / lint (push) Successful in 3m12s
CI / build (push) Successful in 4m10s
CI / intent-service-test (push) Successful in 4m45s
CI / e2e (push) Successful in 13m30s
CI / test (push) Failing after 16m32s
CI / build-android (push) Has been skipped
Closes #130.
2026-09-09 16:25:27 +02:00
380f22d988 test: aligne les assertions restantes sur TechStepView { isPlaceholder, displayName } (#130)
All checks were successful
CI / lint (push) Successful in 3m4s
CI / build (push) Successful in 3m8s
CI / intent-service-test (push) Successful in 5m0s
CI / e2e (push) Successful in 10m58s
CI / test (push) Successful in 19m18s
CI / build-android (push) Has been skipped
Deux specs n'avaient pas été mises à jour quand `sources.service` s'est mis
à renvoyer un `techStep` complet et que le titre du catalogue admin est
passé de « Ingrédients hors-catalogue » à « Éléments hors-catalogue » :

- `sources.test.ts` : les deux `deep.equal({ id, key })` sur le techStep
  d'une étape de preview attendent maintenant aussi `isPlaceholder: false`
  et `displayName: null`.
- `admin-layout.cy.ts` : l'assertion du `h1` de /admin/catalogue suit le
  nouveau libellé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:04:57 +02:00
b49ef5bb40 feat(recipes): signaler une technique absente du catalogue (#130)
Some checks failed
CI / lint (push) Successful in 1m37s
CI / intent-service-test (push) Successful in 5m11s
CI / build (push) Successful in 1m9s
CI / e2e (push) Failing after 7m13s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq
colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/
`reviewedAt`), migration purement additive, FK `createdById → user_profiles`
en `SET NULL`.

Correction d'étape : `submitTechStepCorrectionSchema` accepte
`correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ;
`submitTechStepCorrection` crée une `TechStep` placeholder dans la
transaction et poursuit comme si son id avait été fourni. La correction
s'applique immédiatement (`source: manual`) ; nouvel event analytics
`tech_step.placeholder_proposed`.

`TechStepView` gagne `isPlaceholder`/`displayName` ; `GET
/reference/tech-steps` exclut les placeholders. Nouveau helper web
`techStepLabel` (`displayName ?? t(catalog.techSteps.<key>)`), utilisé par
`StepDescription` et le popover. Le popover a une échappatoire « Ma
technique n'est pas dans la liste » (champ libre).

Admin : `admin-tech-step-catalog.service.ts` + routes
`GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les
propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur
Ingrédients / Techniques.

Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue,
liste + mark-reviewed admin) + Cypress (composant popover, e2e admin
catalogue). Assertions `.techStep` existantes mises à jour pour les 2
nouveaux champs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:40:35 +02:00
81b4a149ff Merge pull request 'feat(recipes): régimes calculés depuis les ingrédients (#131)' (#137) from feat/auto-recipe-diets into main
All checks were successful
CI / lint (push) Successful in 3m2s
CI / build (push) Successful in 3m7s
CI / intent-service-test (push) Successful in 4m59s
CI / e2e (push) Successful in 10m47s
CI / test (push) Successful in 18m59s
CI / build-android (push) Successful in 7m27s
2026-09-09 15:20:14 +02:00
e8596a55bc feat(recipes): régimes d'une recette calculés depuis ses ingrédients (#131)
All checks were successful
CI / lint (push) Successful in 2m58s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m57s
CI / e2e (push) Successful in 10m45s
CI / test (push) Successful in 18m56s
CI / build-android (push) Has been skipped
`RecipeDiet` (tag manuel) est supprimé — table droppée, `dietIds` retiré de
`createRecipeSchema`, `DietTagSelect` retiré des formulaires de recette
(création + import). `RecipeSummaryView.diets` / `RecipeView.diets` sont
désormais **dérivés** via `computeRecipeDietViews` (`@batch-cooking/shared`,
pur, testé isolément) :

- un régime (vegetarian / vegan / pescatarian) est retenu ssi *tous* les
  ingrédients y sont compatibles (`IngredientDiet`, exposé par
  `IngredientView.diets`) ;
- `glutenFree` est dérivé des allergènes (aucun ingrédient ne porte
  `gluten`) — cohérent avec le fait que le seed ne le stocke pas dans
  `IngredientDiet` ;
- `[]` dès qu'une recette contient un ingrédient placeholder (hors-catalogue) :
  un ingrédient inconnu ne certifie rien.

Filtres du sélecteur de planning (`suitableForHousehold`, `dietIds`) : ne
peuvent plus être du SQL sur une colonne stockée — devenus des post-filtres
sur `diets` calculé dans `listRecipes` ; `omnivore` traité comme « aucune
contrainte ». `assertDietsExist` / `ErrorCode.DIET_NOT_FOUND` sur la création
recette supprimés (le code d'erreur reste, utilisé par la préférence profil).

`DietBadges` inchangé (affiche `diets` tel quel). `RecipePickerDialog` garde
son filtre régime (fonctionne via le post-filtre).

Tests : `recipe-diets.test.ts` (fonction pure) + `recipe.test.ts` réécrit
(régimes calculés attendus, tests DIET_NOT_FOUND retirés) ; Cypress OK
(formulaire sans sélecteur de régime, régression recettes/sources/planning).
Migration `DROP TABLE recipe_diet` — aucune donnée à conserver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:00:52 +02:00
019e3fb5e5 Merge pull request 'feat(recipes): ingrédients d'assaisonnement « selon le goût » (#128)' (#136) from feat/seasoning-ingredients into main
All checks were successful
CI / lint (push) Successful in 3m4s
CI / build (push) Successful in 3m6s
CI / intent-service-test (push) Successful in 5m9s
CI / e2e (push) Successful in 10m33s
CI / test (push) Successful in 19m6s
CI / build-android (push) Successful in 5m12s
2026-09-09 14:35:24 +02:00
76 changed files with 3407 additions and 729 deletions

View file

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

View file

@ -1,20 +1,34 @@
name: Release
# No image registry involved: Portainer is wired to this repo's Git remote
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
# that the image actually builds at the tagged commit, builds+attaches a
# signed Android APK, publishes a Forgejo Release, then (best-effort) pings
# Portainer's deploy webhook so it doesn't have to wait for its own polling
# interval.
# On a `vX.Y.Z` tag: build the three service images and push them to this
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
# and `latest`), build+attach a signed Android APK, publish a Forgejo
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
# pull those prebuilt images (docker-compose.yml references them via
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
# keeps a `build:` fallback next to every `image:`.
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
# (the `build-and-push-images` job below `docker login`s with it). If a
# future instance policy scopes the run token tighter, swap that login's
# password for a `write:package` PAT added as a repo secret.
packages: write
jobs:
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
steps:
# This runner's job containers don't come with docker preinstalled —
@ -25,10 +39,36 @@ jobs:
- uses: https://github.com/actions/checkout@v4
# Build only, no push, no registry — just confirms Portainer will be
# able to build this same Dockerfile successfully at this tag before
# anyone points it at this ref.
- run: docker build -f apps/api/Dockerfile .
- name: Build et push des 3 images vers le registre Forgejo
env:
# The per-run token Forgejo Actions mints — has `packages: write`
# via this workflow's `permissions:` block. `docker login` accepts
# it as the password for the actor who pushed the tag.
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
# registry namespace is the owner, lower-cased (Forgejo package
# names are case-insensitive but the CLI wants a stable form).
registry="${GITHUB_SERVER_URL#http*://}"
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
tag="$GITHUB_REF_NAME"
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
# Every Dockerfile's build context is the repo root (see
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
build_push() {
image="$registry/$owner/$1"
echo "::group::$1"
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
docker push "$image:$tag"
docker push "$image:latest"
echo "::endgroup::"
}
build_push batchcooking-app apps/api/Dockerfile
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
# Builds the same signed APK as ci.yml's build-android job (issue #64),
# with one difference: the version baked into the APK is derived from the
@ -124,11 +164,11 @@ jobs:
forgejo-release:
runs-on: ubuntu-latest
needs: [sanity-build, build-android-apk]
needs: [build-and-push-images, build-android-apk]
steps:
# Ce runner ne fournit ni docker ni curl par défaut dans ses
# conteneurs de job — même constat déjà fait pour docker (voir
# 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
run: apt-get update && apt-get install -y curl

View file

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

View file

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

View file

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

View file

@ -49,7 +49,6 @@ model Diet {
key String @unique
users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[]
@@map("diet")
@ -135,6 +134,9 @@ model UserProfile {
/// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
/// Placeholder `TechStep` rows this profile proposed while correcting a step
/// — see `TechStep.isPlaceholder`.
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[]
@ -375,7 +377,6 @@ model Recipe {
steps Step[]
planningItems PlanningItem[]
favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId])
@@map("recipe")
@ -396,21 +397,6 @@ model RecipeFavorite {
@@map("recipe_favorite")
}
/// Explicit join table for the recipe <-> diet "associated regime" tags
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
/// manual reminder set by whoever creates/edits the recipe, not computed
/// from its ingredients.
model RecipeDiet {
recipeId Int @map("recipe_id")
dietId Int @map("diet_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([recipeId, dietId])
@@map("recipe_diet")
}
/// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
@ -735,6 +721,33 @@ model TechStep {
id Int @id @default(autoincrement())
key String @unique
/// `true` = a "placeholder" technique: a free-text technique a user typed
/// while correcting a step, because nothing in the seeded catalog matched
/// (issue #131 for the ingredient precedent this mirrors — see
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
/// training data of its own, and is excluded from
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
/// like any other technique (the correction that created it applies
/// immediately), but a maintainer reviews it at
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// The exact text the user typed — `null` for a real catalog row (whose
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
/// `isPlaceholder`, enforced service-side.
displayName String? @map("display_name")
/// Profile that first proposed this technique — context for the admin
/// review. `onDelete: SetNull` so deleting an account never blocks on the
/// recipes still using its placeholder. `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was proposed. `null` for a real catalog row.
createdAt DateTime? @map("created_at")
/// Stamped once a maintainer has triaged this proposal
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
/// `null` while pending / for a real catalog row.
reviewedAt DateTime? @map("reviewed_at")
steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`.
@ -745,7 +758,10 @@ model TechStep {
/// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[]
/// The profile that proposed this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("tech_step")
}
@ -1001,7 +1017,7 @@ model StepTechStepCorrection {
///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion {

View file

@ -13,12 +13,26 @@ import { z } from "zod";
// separate test database this now requires.
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
/**
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
* is `.dockerignore`d, so that passthrough is the container's only source
* of these. Fields that must *fail closed* on a blank value rather than run
* their own format check against `""` (and crash `parse()` at boot) wrap
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
* folds the same `"" -> undefined` step into its own `.transform`.
*/
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
/**
* Schema for every environment variable the API reads. Parsing (below)
* fails fast at startup if something required is missing/invalid, instead
* of surfacing as a confusing runtime error later.
* 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). */
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
/** 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
* 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_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. */
FORGEJO_REPO: z
FORGEJO_REPO: z.preprocess(
blankAsUnset,
z
.string()
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
.optional(),
),
});
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

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

View file

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

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

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

View file

@ -5,6 +5,7 @@ import { adminContactRouter } from "./admin-contact.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
import { adminToursRouter } from "./admin-tours.routes.js";
import { adminUsersRouter } from "./admin-users.routes.js";
/**
* Aggregator for the admin application's API surface, mounted at
@ -28,3 +29,4 @@ adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

View file

@ -89,7 +89,12 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
unit: line.unit === null ? null : toUnitView(line.unit),
}));
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,
ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),

View file

@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
ErrorCode,
@ -380,10 +381,20 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
start: correction.start,
end: correction.end,
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,
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,
createdAt: correction.createdAt.toISOString(),
};
@ -449,7 +460,32 @@ export async function submitTechStepCorrection(
);
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({
data: {
stepId: step.id,
@ -457,7 +493,7 @@ export async function submitTechStepCorrection(
start: input.start,
end: input.end,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: input.correctedTechStepId ?? null,
correctedTechStepId: effectiveCorrectedTechStepId,
},
include: correctionInclude,
});
@ -467,7 +503,7 @@ export async function submitTechStepCorrection(
step.id,
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
effectiveCorrectedTechStepId,
input.ingredients === undefined &&
input.utensils === 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", {
actorId: correctorId,
@ -533,9 +570,20 @@ export async function submitTechStepCorrection(
recipeId,
stepId,
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) };
} catch (err) {

View file

@ -3,6 +3,7 @@ import { HttpError } from "@batch-cooking/error-tools";
import {
type AllergyView,
type CreateRecipeInput,
computeRecipeDietViews,
type DietView,
ErrorCode,
type IngredientView,
@ -29,7 +30,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
// is a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr";
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + 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) {
return {
ingredients: {
@ -74,11 +75,31 @@ function recipeInclude(viewerId: number) {
},
},
},
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude;
}
/**
* Every reference `Diet` row (`{ id, key }`), loaded once and memoized —
* `Diet` is seeded reference data that never changes at runtime, so caching
* it for the process's lifetime is safe. Used to attach a real `DietView` to
* the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to
* resolve the planning picker's `dietIds` filter / a household's members'
* regimes for the post-filter in {@link listRecipes}.
*/
let referenceDietsPromise: Promise<DietView[]> | null = null;
function loadReferenceDiets(): Promise<DietView[]> {
if (referenceDietsPromise === null) {
referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } });
}
return referenceDietsPromise;
}
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
async function loadGlutenFreeDiet(): Promise<DietView | undefined> {
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>;
}>;
@ -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}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
const byId = new Map<number, AllergyView>();
@ -136,10 +153,13 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
return [...byId.values()];
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
const allergens = aggregateAllergens(
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */
function toRecipeSummaryView(
recipe: RecipeWithDetails,
glutenFreeDiet: DietView | undefined,
): RecipeSummaryView {
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
toIngredientView(recipeIngredient.ingredient),
);
return {
id: recipe.id,
@ -149,8 +169,9 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
portions: recipe.portions,
authorId: recipe.authorId,
visibility: recipe.visibility,
allergens,
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
allergens: aggregateAllergens(ingredientViews),
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`.
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
isFavorite: recipe.favoritedBy.length > 0,
};
}
@ -190,7 +211,12 @@ export function toStepTechStepViews(
} = stepTechStep;
if (start === null || end === null) continue;
views.push({
techStep: { id: techStep.id, key: techStep.key },
techStep: {
id: techStep.id,
key: techStep.key,
isPlaceholder: techStep.isPlaceholder,
displayName: techStep.displayName,
},
start,
end,
// `source` is a plain DB `String`, not a Prisma enum (see
@ -240,8 +266,8 @@ export function toStepTechStepViews(
return views;
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient),
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
@ -249,7 +275,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
}));
return {
...toRecipeSummaryView(recipe),
...toRecipeSummaryView(recipe, glutenFreeDiet),
ingredients,
steps: recipe.steps.map((step) => ({
id: step.id,
@ -302,54 +328,57 @@ function visibleToViewerWhere(
}
/**
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
* and, for every member with a declared regime, are tagged with that
* regime — the planning recipe picker's "convient à tout le foyer" toggle
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
* extra query to gather the household's members' allergy/regime ids)
* rather than exposed to the client as raw per-member data: a member's
* allergies/regime are private the same way visibility already keeps a
* recipe's existence private (404, never 403) — nothing here should let
* one member infer another's medical/dietary info from the shape of a
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
* the schema already treats disliked ingredients as a taste preference,
* not a safety constraint (see that model's doc comment), so it doesn't
* belong in a filter framed around what's safe/appropriate to serve.
* The two halves of the planning recipe picker's "convient à tout le foyer"
* toggle (`suitableForHousehold` on `listRecipes`):
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that
* contains an allergen declared by a member (still expressible in SQL).
* - `requiredDietKeys` — every regime declared by a member (`omnivore`
* dropped: it constrains nothing). A recipe must satisfy *all* of them —
* a recipe suiting a vegetarian member doesn't automatically suit a
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a
* stored column to query (issue #131).
*
* Computed server-side (a small extra query for the members' allergy/regime
* data) rather than exposed to the client as raw per-member data: a
* member's allergies/regime are private the same way visibility already
* keeps a recipe's existence private. Deliberately excludes
* `UserProfileDislikedIngredient` — a taste preference, not a safety
* constraint (see that model's doc comment).
*/
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
async function suitableForHousehold(
houseId: number,
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
try {
const members = await prisma.userProfile.findMany({
where: { houseId },
select: { dietId: true, allergies: { select: { allergyId: true } } },
select: {
diet: { select: { key: true } },
allergies: { select: { allergyId: true } },
},
});
const requiredDietIds = [
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
const requiredDietKeys = [
...new Set(
members
.map((member) => member.diet?.key)
.filter((key): key is string => key !== undefined && key !== "omnivore"),
),
];
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[] = [];
if (requiredDietIds.length > 0) {
// Every diet declared by a member must be among this recipe's tags —
// not "at least one", since a recipe suiting a vegetarian member
// doesn't automatically suit a gluten-free one too.
conditions.push({
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
if (excludedAllergyIds.length > 0) {
conditions.push({
const allergenWhere: Prisma.RecipeWhereInput =
excludedAllergyIds.length > 0
? {
ingredients: {
none: {
ingredient: {
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } },
},
},
},
});
}
return { AND: conditions };
: {};
return { allergenWhere, requiredDietKeys };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
@ -383,7 +412,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
).map((row) => row.sourceId);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -397,11 +426,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
export interface ListRecipesFilters {
/** Case-insensitive name substring. */
search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[];
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
/** 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[];
}
@ -421,31 +450,32 @@ export async function listRecipes(
filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
try {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } });
}
// Regimes are computed from ingredients, not a stored column (issue
// #131), so the household-regime and `dietIds` filters can't be SQL
// anymore — they run as post-filters on the mapped summaries below.
// Everything that still *can* be a `where` stays one.
let requiredDietKeys: string[] = [];
// No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing.
if (suitableForHousehold && viewerHouseId !== null) {
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
if (suitable && viewerHouseId !== null) {
const household = await suitableForHousehold(viewerHouseId);
conditions.push(household.allergenWhere);
requiredDietKeys = household.requiredDietKeys;
}
if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of
// them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
// them, not just one.
conditions.push({
AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } },
})),
});
}
if (dietIds && dietIds.length > 0) {
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
switch (tab) {
case "favoris":
@ -465,14 +495,38 @@ export async function listRecipes(
break;
}
const recipes = await prisma.recipe.findMany({
const [recipes, referenceDiets] = await Promise.all([
prisma.recipe.findMany({
where: { AND: conditions },
include: recipeInclude(viewerId),
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) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -491,23 +545,23 @@ export async function getRecipe(
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return toRecipeView(recipe);
return toRecipeView(recipe, await loadGlutenFreeDiet());
} 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
* go — steps' `order` is derived from their position in `input.steps`,
* Creates a recipe with its ingredients and ordered steps in one go —
* steps' `order` is derived from their position in `input.steps`,
* ingredients reference existing reference `Ingredient` rows by id (see
* `GET /reference/ingredients`; there's no way to create one here).
* `authorId`/`authorHouseId` are fixed at creation and never change on
* later edits (see {@link updateRecipe}).
* `GET /reference/ingredients`; there's no way to create one here). Its
* regimes aren't stored — they're computed from the ingredients on read
* (issue #131). `authorId`/`authorHouseId` are fixed at creation and never
* change on later edits (see {@link updateRecipe}).
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createRecipe(
input: CreateRecipeInput,
@ -517,7 +571,7 @@ export async function createRecipe(
try {
return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -534,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 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createImportedRecipe(
input: CreateRecipeInput,
@ -545,7 +598,7 @@ export async function createImportedRecipe(
try {
return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -642,7 +695,7 @@ async function resolveIngredientLines(
}
return { resolved, createdPlaceholders };
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -659,7 +712,6 @@ async function createRecipeInternal(
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
await assertDietsExist(input.dietIds);
// Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async
// (a trained model, not a pure regex test), so its result has to
@ -709,7 +761,6 @@ async function createRecipeInternal(
techSteps: { create: toStepTechStepCreateInputs(matches) },
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
include: recipeInclude(authorId),
});
@ -727,24 +778,25 @@ async function createRecipeInternal(
});
}
return toRecipeView(created);
return toRecipeView(created, await loadGlutenFreeDiet());
} 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
* 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
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
* never transfers ownership.
* /profile/allergies`). Regimes aren't part of the payload — they're
* recomputed from the ingredients on the next read (issue #131).
* `authorId`/`authorHouseId` are untouched — editing never transfers
* ownership.
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function updateRecipe(
id: number,
@ -762,7 +814,6 @@ export async function updateRecipe(
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
// Interactive transaction (not the array form) so any new free-text
@ -773,7 +824,6 @@ export async function updateRecipe(
const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } });
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
viewerId,
@ -808,7 +858,6 @@ export async function updateRecipe(
techSteps: { create: toStepTechStepCreateInputs(matches) },
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
});
return createdPlaceholders;
@ -821,9 +870,9 @@ export async function updateRecipe(
});
}
return toRecipeView(await findRecipeOrThrow(id, viewerId));
return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet());
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -856,7 +905,7 @@ export async function deleteRecipe(
await prisma.recipe.delete({ where: { id } });
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -884,7 +933,7 @@ export async function addFavorite(
create: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -895,7 +944,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
where: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -918,7 +967,7 @@ export async function assertRecipeVisible(
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -934,7 +983,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
}
return recipe;
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -953,7 +1002,7 @@ async function assertIsAuthor(
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -975,7 +1024,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -997,29 +1046,6 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(dietIds)];
if (uniqueIds.length === 0) return;
const found = await prisma.diet.findMany({
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
throw err; // see suitableForHousehold()'s catch comment above
}
}

View file

@ -77,11 +77,21 @@ export async function getUnits(): Promise<UnitView[]> {
/**
* All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`). 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[]> {
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) {
throw err; // see getDiets()'s catch comment above
}

View file

@ -180,9 +180,20 @@ export async function previewSourceItem(
),
loadIngredientCatalog(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(
parsed.ingredients,

View file

@ -139,3 +139,118 @@ describe("Admin catalog — off-catalog ingredients", () => {
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
});
});
/**
* `/admin/api/catalog/tech-step-placeholders` — the missing-technique review
* (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route
* behind `requireAdmin`.
*/
describe("Admin catalog — missing techniques", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
/** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */
async function seedPlaceholderTechnique(name: string): Promise<number> {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const techStep = await prisma.techStep.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: name,
createdById: author.id,
createdAt: new Date(),
},
});
await prisma.recipe.create({
data: {
name: faker.lorem.words(3),
authorId: author.id,
portions: 2,
visibility: "PUBLIC",
steps: {
create: [
{
description: "Une étape.",
order: 0,
techSteps: {
create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }],
},
},
],
},
},
});
return techStep.id;
}
it("rejects both routes without an admin session", async () => {
expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal(
401,
);
expect(
(
await request(app)
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
.send({ techStepIds: [1] })
).status,
).to.equal(401);
});
it("lists proposed techniques with their recipe count, impact-ordered", async () => {
await seedPlaceholderTechnique("Flamber au chalumeau");
await seedPlaceholderTechnique("Confire à basse température");
const agent = await adminAgent();
const res = await agent.get("/admin/api/catalog/tech-step-placeholders");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([
"Flamber au chalumeau",
"Confire à basse température",
]);
expect(res.body[0].recipeCount).to.equal(1);
expect(res.body[0].reviewedAt).to.equal(null);
});
it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => {
const id = await seedPlaceholderTechnique("Snacker à la plancha");
const agent = await adminAgent();
const patched = await agent
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
.send({ techStepIds: [id] });
expect(patched.status).to.equal(200);
expect(patched.body.reviewed).to.equal(1);
expect(
(await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt,
).to.be.an.instanceOf(Date);
expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0);
const reviewed = await agent
.get("/admin/api/catalog/tech-step-placeholders")
.query({ reviewed: "true" });
expect(reviewed.body).to.have.length(1);
});
});

View file

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

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

View file

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

View file

@ -312,20 +312,16 @@ describe("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 tomate = await ingredientId("tomato");
const oeuf = await ingredientId("egg");
const piece = await unitId("piece");
const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: "vegetarian" },
});
const res = await agent.post("/recipes").send({
name: "Omelette provençale",
description: "Rapide et savoureuse",
portions: 2,
dietIds: [vegetarien.id],
ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: oeuf, quantity: 3, unitId: piece },
@ -343,7 +339,84 @@ describe("Recipes", () => {
).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
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 () => {
@ -355,7 +428,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -367,7 +439,6 @@ describe("Recipes", () => {
name: "Foyer",
visibility: "HOUSE",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -386,7 +457,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }],
});
@ -399,7 +469,12 @@ describe("Recipes", () => {
// response itself carries exactly what to highlight, not just the id.
const resStep = res.body.steps[0];
expect(resStep.techSteps).to.have.length(1);
expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
expect(resStep.techSteps[0].techStep).to.deep.equal({
id: simmer,
key: "simmer",
isPlaceholder: false,
displayName: null,
});
const { start, end } = resStep.techSteps[0];
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
});
@ -413,7 +488,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }],
});
@ -448,7 +522,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
@ -467,7 +540,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [
{ description: "Hacher les oignons" },
@ -497,7 +569,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
steps: [{ description: "Cuire au four pendant 30 minutes" }],
@ -519,7 +590,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Poêlée",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// The case that motivated the sequence model: one instruction, two techniques.
steps: [{ description }],
@ -550,7 +620,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -566,7 +635,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
steps: [{ description: "Étape" }],
});
@ -575,30 +643,12 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [],
steps: [{ description: "Étape" }],
});
@ -621,7 +671,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomate, quantity: 1, unitId: piece },
{ ingredientId: tomate, quantity: 2, unitId: piece },
@ -639,7 +688,6 @@ describe("Recipes", () => {
const piece = await unitId("piece");
const basePayload = {
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
};
@ -662,7 +710,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({
name: "Tomates au sel",
portions: 2,
dietIds: [],
ingredients: [
{ ingredientId: tomate, quantity: 200, unitId: gram },
{ ingredientId: sel }, // selon le goût — no quantity/unit
@ -685,7 +732,6 @@ describe("Recipes", () => {
const base = {
name: "Test",
portions: 2,
dietIds: [],
steps: [{ description: "Étape" }],
};
@ -722,7 +768,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
@ -808,7 +853,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
@ -817,7 +861,6 @@ describe("Recipes", () => {
name: "Salade composée",
portions: 6,
visibility: "PUBLIC",
dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
});
@ -840,7 +883,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }],
steps: [{ description: "Couper" }],
});
@ -848,7 +890,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade poivrée",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomate, quantity: 100, unitId: gram },
{ ingredientId: poivre }, // selon le goût
@ -872,7 +913,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
@ -880,7 +920,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Émincer les tomates" }],
});
@ -903,7 +942,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [
{ description: "Hacher les oignons" }, // chop
@ -932,7 +970,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }, { description: addedDescription }],
});
@ -973,7 +1010,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
@ -990,7 +1026,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }],
});
@ -1025,7 +1060,6 @@ describe("Recipes", () => {
const res = await agent.patch("/recipes/999999").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
@ -1034,30 +1068,6 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
portions: 4,
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { profileId } = await signup();
const { agent: otherAgent } = await signup();
@ -1070,7 +1080,6 @@ describe("Recipes", () => {
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});

View file

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

View file

@ -148,7 +148,30 @@ describe("Reference data", () => {
// number — this catalog has grown since (26 -> 74) and will again.
expect(res.body).to.have.length(TECH_STEPS.length);
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
expect(res.body[0]).to.have.keys(["id", "key"]);
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 () => {

View file

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

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

View file

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

View file

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

View file

@ -39,7 +39,7 @@ describe("Admin layout", () => {
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
cy.contains("nav a", "Catalogue").click();
cy.url().should("include", "/admin/catalogue");
cy.contains("h1", "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.url().should("eq", `${Cypress.config().baseUrl}/admin`);

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

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

View file

@ -1,41 +1,54 @@
Feature: Onboarding wizard
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
# Order since #129: compte -> régime -> allergènes -> foyer -> sources,
# and the sources step only when the foyer step *created* a household
# (joining one or skipping ends the wizard at the home page).
Background:
Given the planning request returns nothing
Scenario: Walks through 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
And selecting the diet will succeed
And the household request returns no household
And creating a household will succeed
And the sources reference list is empty
And the allergies reference list has options
And updating allergies will succeed
And creating a household will succeed
And the sources reference list has options
And the household's enabled sources are empty
And saving the source selection will succeed
And I have signed up
Then the URL should include "/onboarding/regime"
And I should see "Étape 1 sur 3"
When I select "Végétarien" from the "diet" field
And I click the button "Continuer"
Then the diet update request should have been made with diet id 2
And the URL should include "/onboarding/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 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 "Intolérances"
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
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 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
And selecting the diet will succeed
And the household request returns no household
@ -45,39 +58,32 @@ Feature: Onboarding wizard
Then the URL should include "/onboarding/regime"
When I click the button "Continuer"
Then the diet update request should have been made with no diet id
And the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the allergies update request should have been made with no allergy ids
And the URL should include "/onboarding/foyer"
When I click the button "Passer cette étape"
Then the URL should include "/onboarding/allergenes"
When I click the button "Terminer"
Then the allergies update request should have been made with no allergy ids
And the URL should be the home page
Then 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
And selecting the diet will succeed
And the household request returns no household
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 the sources reference list is empty
And the allergies reference list is empty
And updating allergies will succeed
And I have signed up
When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer"
When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice"
And the URL should include "/onboarding/sources"
And I should see "Étape 3 sur 4"
And I should see the section "Sources disponibles"
And I should see "TheMealDB"
When I check the checkbox "TheMealDB"
And I click the button "Continuer"
Then the source selection update request should have been made with source id 1
And the URL should include "/onboarding/allergenes"
And the URL should be the home page
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
And selecting the diet will succeed
And the household request returns no household
@ -87,8 +93,10 @@ Feature: Onboarding wizard
And updating allergies will succeed
And I have signed up
When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer"
When I fill in the "inviteCode" field with "abcd2345"
And I click the button "Rejoindre"
Then the household join request should have been made with invite code "ABCD2345"
And the URL should include "/onboarding/allergenes"
And the URL should be the home page

View file

@ -82,6 +82,10 @@ Feature: Recipe form — associating ingredients
Then the recipe creation request should have included the seasoning-only ingredient 1
And the URL should include "/recettes/44"
Scenario: The recipe form has no regime picker — regimes are computed from ingredients (issue #131)
When I visit "/recettes/nouvelle"
Then I should not see "Régime(s) associé(s)"
Scenario: The save/cancel bar stays pinned to the foot of a long form
When I visit "/recettes/nouvelle"
And I add a step

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage";
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
import { UsersPage as AdminUsersPage } from "./pages/admin/users/UsersPage";
import { LoginPage } from "./pages/auth/LoginPage";
import { SignupPage } from "./pages/auth/SignupPage";
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
@ -51,14 +52,16 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps
* 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
* exists — but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
* `/onboarding/sources` is conditional — only reached when the `foyer` step
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
* skipped otherwise, straight to `/onboarding/allergenes`.
* `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources`
* is conditional — only reached when the `foyer` step *created* a household
* (see `OnboardingHouseholdPage`'s `goToNextStep`), every other path ending
* the wizard straight to `/`. Step order and the "sur 3" / "4 sur 4"
* indicators live in `pages/onboarding/onboarding-steps.ts`.
*
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
@ -138,6 +141,14 @@ export function App() {
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/foyer"
element={
@ -154,14 +165,6 @@ export function App() {
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route
path="/login"
element={
@ -196,6 +199,7 @@ export function App() {
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="utilisateurs" element={<AdminUsersPage />} />
<Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} />

View file

@ -1,6 +1,9 @@
import {
type AdminCreateUserInput,
type AdminLoginInput,
type AdminTourScenarioView,
type AdminUserCreatedView,
type AdminUserListItemView,
type AdminUserView,
type ApiErrorResponse,
type CatalogPlaceholderGroupView,
@ -11,10 +14,12 @@ import {
ErrorCode,
type ForgejoIntegrationStatus,
type MarkPlaceholdersReviewedInput,
type MarkTechStepPlaceholdersReviewedInput,
type MetricsView,
type PruneOrphansResultView,
type RetrainRequestInput,
type RetrainResultView,
type TechStepPlaceholderView,
type TourTargetView,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
@ -129,6 +134,20 @@ export class AdminApiClient {
return this._request(`/metrics?days=${days}`);
}
/** End-user accounts for the `/admin/utilisateurs` list — newest first, capped server-side at 200. */
public getUsers(): Promise<AdminUserListItemView[]> {
return this._request("/users");
}
/**
* Provisions an end-user account by hand. Returns the created account plus
* the one-time temporary password the server generated — show it once,
* it's never retrievable again. Rejects with `EMAIL_ALREADY_IN_USE`.
*/
public createUser(input: AdminCreateUserInput): Promise<AdminUserCreatedView> {
return this._request("/users", { method: "POST", body: JSON.stringify(input) });
}
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
public getSuggestions(filters: {
status?: string;
@ -195,6 +214,21 @@ export class AdminApiClient {
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
}
/** Free-text techniques users proposed while correcting a step, when the catalog was missing one (issue #130). Same `reviewed` tri-state as {@link getPlaceholders}. */
public getTechStepPlaceholders(reviewed?: "true" | "false"): Promise<TechStepPlaceholderView[]> {
return this._request(`/catalog/tech-step-placeholders${query({ reviewed })}`);
}
/** Marks the given placeholder technique ids as triaged (`reviewedAt`). */
public markTechStepPlaceholdersReviewed(
body: MarkTechStepPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
return this._request("/catalog/tech-step-placeholders/mark-reviewed", {
method: "PATCH",
body: JSON.stringify(body),
});
}
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
return this._request("/tours");

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

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

View file

@ -61,7 +61,11 @@
}
// 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);
padding: 0.6rem;
font-family: var(--font-body);

View file

@ -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">
<input
type="search"
@ -461,6 +463,8 @@ export function RecipePickerDialog({
onChange={(e) => setSearch(e.target.value)}
/>
{activeSourceKey === null && (
<>
<div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")}
@ -513,8 +517,9 @@ export function RecipePickerDialog({
{t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption>
)}
</div>
</>
)}
</div>
<RecipeTabs
active={activeTab}
@ -526,6 +531,7 @@ export function RecipePickerDialog({
<RecipeSourcesPanel
key={activeSourceKey}
sourceKey={activeSourceKey}
debouncedSearch={debouncedSearch}
onSelectImportedRecipe={handleSelectImportedRecipe}
onDraftSelected={handleDraftSelected}
/>

View file

@ -360,13 +360,10 @@
}
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's
// actually new to this tab gets its own rules here.
.recipes-page__header--sources {
gap: var(--space-sm);
}
// Reuses .recipes-page__catalog and .recipe-table(-wrap) as-is (see
// RecipeSourcesPanel.tsx/SourceItemTable.tsx); the search field is the
// page header's own now (issue #133) — only what's actually new to this
// tab gets its own rules here.
.source-item-table__imported-badge {
padding: 0.1rem 0.5rem;
@ -889,6 +886,26 @@
font-weight: 600;
cursor: pointer;
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 {

View file

@ -1,7 +1,6 @@
import {
type CreateRecipeInput,
createRecipeSchema,
type DietView,
ErrorCode,
type IngredientView,
type Meal,
@ -16,7 +15,6 @@ import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { makeClientKey } from "../../../lib/client-key";
import { errorMessageService } from "../../../services/error-message.service";
import { DietTagSelect } from "../badges/DietTagSelect";
import { IngredientPicker } from "../ingredients/IngredientPicker";
import { IngredientRow } from "../ingredients/IngredientRow";
import {
@ -60,8 +58,8 @@ type LoadState = "loading" | "loaded" | "error";
*
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId`,
* structurally the same form as `RecipeFormPage` — same sub-components
* (`IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`),
* same `CreateRecipeInput` submit shape — plus one thing a manual creation
* (`IngredientRow`, `IngredientPicker`, `StepListEditor`), same
* `CreateRecipeInput` submit shape — plus one thing a manual creation
* never has to handle: ingredient lines the automatic matching
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à
* compléter" list, each needing a real ingredient picked (or the line
@ -95,7 +93,6 @@ export function RecipeImportForm({
const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState("");
@ -103,7 +100,6 @@ export function RecipeImportForm({
const [picture, setPicture] = useState("");
const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
[],
@ -123,14 +119,12 @@ export function RecipeImportForm({
Promise.all([
apiClient.getIngredients(),
apiClient.getDiets(),
apiClient.getUnits(),
apiClient.previewSourceItem(sourceKey, externalId),
])
.then(([ingredients, diets, units, draft]) => {
.then(([ingredients, units, draft]) => {
if (cancelled) return;
setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
setUnitsCatalog(units);
setName(draft.name);
@ -304,7 +298,6 @@ export function RecipeImportForm({
picture: picture.trim() || null,
portions: Number(portions),
visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({
// Free-text lines (nothing in the catalog matched) submit as
// `placeholderName` so the API creates the placeholder row — same
@ -436,8 +429,6 @@ export function RecipeImportForm({
))}
</select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list">

View file

@ -6,9 +6,6 @@ import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel"
import { SourceItemTable } from "./SourceItemTable";
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. */
const LOAD_MORE_PLACEHOLDER_COUNT = 4;
@ -64,15 +61,25 @@ type BrowseState =
* upward, and re-previews on mount/prop-change if handed one back.
* `RecipePickerDialog` leaves both unset: previewing inside that dialog
* 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({
sourceKey,
debouncedSearch,
onSelectImportedRecipe,
onDraftSelected,
initialSelection,
onItemSelected,
}: {
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. */
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. */
@ -82,8 +89,6 @@ export function RecipeSourcesPanel({
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
// The *next* page, fetched ahead of time as soon as the current one is on
@ -152,11 +157,6 @@ export function RecipeSourcesPanel({
};
}, [initialSelection?.externalId]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => {
let cancelled = false;
setBrowseState({ status: "loading" });
@ -294,17 +294,8 @@ export function RecipeSourcesPanel({
}
return (
<>
<div className="recipes-page__header recipes-page__header--sources">
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
// The search field lives in `RecipesPage`'s header now (issue #133) —
// this panel is just the source's list + preview.
<div className="recipes-page__catalog">
{browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
@ -340,6 +331,5 @@ export function RecipeSourcesPanel({
<RecipeDetailPanel state={previewState} showActions={false} />
</div>
</>
);
}

View file

@ -16,9 +16,10 @@ import {
* fall back to the full review screen (`ImportRecipePage`), pre-filled from
* this exact same draft, for a person to fill in what's missing.
*
* Default `visibility`/`dietIds` mirror what a person would otherwise leave
* untouched on that same form (`PERSONAL`, no diet tags) — nothing here is
* a guess about data the draft doesn't have an opinion on.
* Default `visibility` mirrors what a person would otherwise leave
* untouched on that same form (`PERSONAL`) — nothing here is a guess about
* 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
* the result is submittable (a positive-portions check, string lengths,
* 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,
portions: draft.portions,
visibility: "PERSONAL",
dietIds: [],
ingredients: draft.ingredients.map((line) => ({
// Every line is fully resolved by this point — guarded above.
ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id,

View file

@ -9,6 +9,7 @@ import { Tooltip } from "../../../components/ui/Tooltip";
import { useTour } from "../../tour/TourContext";
import { splitDescriptionSegments } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { techStepLabel } from "./tech-step-label";
import { temperatureLabel } from "./temperature-label";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
@ -318,11 +319,12 @@ export function StepDescription({
const techStep = segment.techStep;
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
? t("recipes.techStepCorrection.manualTooltip", {
technique: t(`catalog.techSteps.${techStep.key}`),
})
: t(`catalog.techSteps.${techStep.key}`);
? t("recipes.techStepCorrection.manualTooltip", { technique: techLabel })
: techLabel;
return (
<Tooltip key={key} content={tooltipLabel}>
{/* A real <button>, not a <mark>, so it's natively focusable

View file

@ -15,6 +15,7 @@ import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import { techStepLabel } from "./tech-step-label";
import { temperatureLabel } from "./temperature-label";
import type { TextSelectionRange } from "./use-text-selection";
@ -358,6 +359,13 @@ export function TechStepCorrectionPopover({
const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
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
// picked (see this component's own doc comment) — starts open only when
// there's nothing to show yet (a brand-new selection).
@ -506,7 +514,7 @@ export function TechStepCorrectionPopover({
}
async function confirm() {
if (selectedTechStepId === null) return;
if (selectedTechStepId === null && proposedTechStepName === null) return;
setIsSubmitting(true);
setError(null);
try {
@ -514,7 +522,11 @@ export function TechStepCorrectionPopover({
start: range.start,
end: range.end,
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
? {
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 unitById = new Map((catalogs?.units ?? []).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 =
selectedTechStepId !== null
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`)
proposedTechStepName !== null
? proposedTechStepName
: selectedTechStep
? techStepLabel(selectedTechStep, t)
: null;
return (
@ -652,7 +668,11 @@ export function TechStepCorrectionPopover({
<button
type="button"
className="tech-step-correction-popover__technique-change"
onClick={() => setIsChangingTechnique(true)}
onClick={() => {
setIsChangingTechnique(true);
setProposedTechStepName(null);
setIsNamingMissingTechnique(false);
}}
>
{t("recipes.techStepCorrection.changeTechnique")}
</button>
@ -670,19 +690,61 @@ export function TechStepCorrectionPopover({
</div>
{(currentTechniqueLabel === null || isChangingTechnique) && (
<>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
label: techStepLabel(techStep, t),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={(id) => {
setSelectedTechStepId(id);
setProposedTechStepName(null);
setIsNamingMissingTechnique(false);
setIsChangingTechnique(false);
}}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
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>
@ -993,7 +1055,9 @@ export function TechStepCorrectionPopover({
type="button"
className="tech-step-correction-popover__confirm-button"
onClick={confirm}
disabled={isSubmitting || selectedTechStepId === null}
disabled={
isSubmitting || (selectedTechStepId === null && proposedTechStepName === null)
}
>
{t("recipes.techStepCorrection.confirm")}
</button>

View 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}`);
}

View file

@ -5,6 +5,7 @@ import {
LayoutDashboard,
ListChecks,
PackageSearch,
Users,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -20,6 +21,7 @@ import "./AdminLayout.scss";
*/
const NAV_ITEMS = [
{ 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/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },

View file

@ -27,6 +27,10 @@
"vendredi": "Vendredi",
"samedi": "Samedi",
"dimanche": "Dimanche"
},
"passwordInput": {
"show": "Afficher le mot de passe",
"hide": "Masquer le mot de passe"
}
},
"errors": {
@ -219,6 +223,10 @@
"noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…",
"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",
"utensilsSection": "Ustensiles",
"temperatureSection": "Température",
@ -251,7 +259,6 @@
"diets": "Régime associé"
},
"sources": {
"searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.",
"alreadyImported": "Déjà importée",
"loading": "Chargement…",
@ -1227,6 +1234,7 @@
},
"nav": {
"dashboard": "Tableau de bord",
"users": "Utilisateurs",
"monitoring": "Monitoring",
"corrections": "Corrections",
"catalog": "Catalogue",
@ -1236,6 +1244,37 @@
"layout": {
"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": {
"title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.",
@ -1373,9 +1412,14 @@
}
},
"catalog": {
"title": "Ingrédients 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.",
"title": "Éléments hors-catalogue",
"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.",
"techStepEmpty": "Aucune technique proposée à revoir.",
"kind": {
"ingredients": "Ingrédients",
"techSteps": "Techniques"
},
"tab": {
"pending": "À traiter",
"reviewed": "Traités"

View file

@ -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 { useTranslation } from "react-i18next";
import { adminApiClient } from "../../../api/admin-client";
@ -6,25 +6,82 @@ import "../admin-page.scss";
import "./catalog-page.scss";
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
type CatalogState =
type IngredientState =
| { status: "loading" }
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
| { 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
* free text users typed when the seeded catalog fell short — see
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
* name, so a maintainer sees what the catalog is missing and how many
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
* as handled, or purge rows no recipe references any more. Actually adding
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
* locale files.
* Off-catalog review. Two lists a maintainer works through to see what the
* seeded catalog is missing: the free-text **ingredients** users typed on a
* recipe line (grouped by normalized name), and the free-text **techniques**
* users proposed while correcting a step (issue #130, a flat list — that's
* rarer). Actions are deliberately minimal: mark a gap as handled (and, for
* ingredients, purge rows no recipe references any more). Adding the real
* catalog entry stays a manual edit of `reference-seed-data.ts` + the locale
* files.
*/
export function CatalogPage() {
const { t } = useTranslation();
const [kind, setKind] = useState<CatalogKind>("ingredients");
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 [pruneMessage, setPruneMessage] = useState<string | null>(null);
const [isPruning, setIsPruning] = useState(false);
@ -78,25 +135,8 @@ export function CatalogPage() {
}
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-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}>
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
</button>
@ -124,7 +164,90 @@ export function CatalogPage() {
))}
</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>
{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>
);
}

View file

@ -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 {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-md);
button {
padding: var(--space-sm) var(--space-md);

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

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

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

View file

@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { PasswordInput } from "../../components/ui/PasswordInput";
import { useAuth } from "../../features/auth/AuthContext";
// Shared with SignupPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page.
@ -78,11 +79,10 @@ export function LoginPage() {
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">{t("auth.login.passwordLabel")}</label>
<input
<PasswordInput
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={setPassword}
autoComplete="current-password"
/>
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}

View file

@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { PasswordInput } from "../../components/ui/PasswordInput";
import { useAuth } from "../../features/auth/AuthContext";
// Shared with LoginPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page.
@ -37,7 +38,7 @@ export function SignupPage() {
const [formError, setFormError] = useState<string | null>(null);
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) {
e.preventDefault();
setFormError(null);
@ -98,11 +99,10 @@ export function SignupPage() {
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
<input
<PasswordInput
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={setPassword}
autoComplete="new-password"
/>
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}

View file

@ -6,22 +6,23 @@ import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { AllergySelect } from "../../features/profile/AllergySelect";
import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss";
/**
* Last step of the post-signup onboarding wizard, routed at
* `/onboarding/allergenes`. Starts from an empty selection — a freshly
* signed-up profile has none yet, so there's no need for the extra
* Second step of the post-signup onboarding wizard, routed at
* `/onboarding/allergenes` — the allergens half of "préférences" (régime
* 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
* would require (the `/parametres/preferences` settings page is the
* always-fetch source of truth for editing an existing selection later).
*
* Also fetches the current household on mount, only to know whether the
* `/onboarding/sources` step ran before this one (household step created
* one and it had a source to configure) — the step count shown reflects
* whichever path the visitor actually took, "3 sur 3" or "4 sur 4"
* (there's no shared wizard state to read this from — see
* `OnboardingHouseholdPage`'s doc comment on why each step is independent).
* No household fetch anymore: this step no longer needs to retroactively
* work out whether a sources step ran, since it now comes before the
* household step entirely — the "sur 3" vs "4 sur 4" split lives in
* {@link onboardingStepIndicator}.
*/
export function OnboardingAllergensPage() {
const { t } = useTranslation();
@ -29,27 +30,22 @@ export function OnboardingAllergensPage() {
const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [totalSteps, setTotalSteps] = useState(3);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
.then(([allergiesResult, house]) => {
if (cancelled) return;
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.
apiClient
.getAllergies()
.then((allergiesResult) => {
if (!cancelled) setAllergies(allergiesResult);
})
// 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(() => {
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) {
e.preventDefault();
setFormError(null);
setIsSubmitting(true);
try {
await apiClient.updateAllergyIds(allergyIds);
void navigate("/");
void navigate("/onboarding/foyer");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
@ -78,7 +74,7 @@ export function OnboardingAllergensPage() {
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">
{t("onboarding.step", { current: totalSteps, total: totalSteps })}
{t("onboarding.step", onboardingStepIndicator("allergens"))}
</p>
<h1>{t("onboarding.allergens.title")}</h1>
@ -104,7 +100,7 @@ export function OnboardingAllergensPage() {
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.finish")}
{t("onboarding.continue")}
</button>
</form>
</main>

View file

@ -7,6 +7,7 @@ import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { DietSelect } from "../../features/profile/DietSelect";
import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss";
/**
@ -15,6 +16,9 @@ import "./onboarding.scss";
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
* `null` — no extra fetch needed just to know the starting value, unlike
* 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() {
const { t } = useTranslation();
@ -53,7 +57,7 @@ export function OnboardingDietPage() {
setIsSubmitting(true);
try {
await apiClient.updateDiet(dietId);
void navigate("/onboarding/foyer");
void navigate("/onboarding/allergenes");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
@ -65,7 +69,7 @@ export function OnboardingDietPage() {
return (
<main className="onboarding-page">
<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>
{isLoading ? (

View file

@ -6,16 +6,24 @@ import { ApiError, apiClient } from "../../api/client";
import { HouseNameField } from "../../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss";
/**
* Second step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` — unlike the regime/allergens steps, this one is
* genuinely optional rather than just "skippable via an unedited default":
* no household is created at signup anymore (see the API's
* `auth.service.ts`), so there's nothing to prefill or implicitly keep by
* pressing "Continuer" — the visitor explicitly creates one, joins one by
* invite code, or skips the step outright via {@link SkipButton}.
* Third step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` — after the régime + allergènes "préférences" steps
* (see #129). Unlike those, this one is genuinely optional rather than
* just "skippable via an unedited default": no household is created at
* signup anymore (see the API's `auth.service.ts`), so there's nothing to
* prefill or implicitly keep by pressing "Continuer" — the visitor
* 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
* "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
* attach a preference to — created/joined/already-there goes on to it,
* skipped goes straight to `/onboarding/allergenes` instead (nothing to
* configure sources for without a household).
* `/onboarding/sources` only makes sense for a household the visitor just
* *created* — that's the sole caller passing `true`. Joining one, skipping
* the step, or finding one already there (browser back — can't tell
* 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) {
void navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
function goToNextStep(createdHousehold: boolean) {
void navigate(createdHousehold ? "/onboarding/sources" : "/");
}
return (
<main className="onboarding-page">
<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>
{isLoading ? (
@ -70,15 +81,15 @@ export function OnboardingHouseholdPage() {
) : house !== null ? (
<>
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
<button type="button" onClick={() => goToNextStep(true)}>
{t("onboarding.continue")}
<button type="button" onClick={() => goToNextStep(false)}>
{t("onboarding.finish")}
</button>
</>
) : (
<>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={() => goToNextStep(true)} />
<JoinHouseholdForm onDone={() => goToNextStep(true)} />
<JoinHouseholdForm onDone={() => goToNextStep(false)} />
<SkipButton onSkip={() => goToNextStep(false)} />
</>
)}

View file

@ -6,22 +6,23 @@ import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { SourceSelect } from "../../features/house/SourceSelect";
import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss";
/**
* Third step of the post-signup onboarding wizard, routed at
* `/onboarding/sources` — only reached when a household now exists (see
* `OnboardingHouseholdPage`'s `goToStep`, which skips straight to
* `/onboarding/allergenes` when the previous step was skipped): enabled
* sources are a per-household preference (`HouseSource`, schema.prisma),
* so there's nothing to attach a selection to without one.
* Final step of the post-signup onboarding wizard, routed at
* `/onboarding/sources` — reached only when the previous step *created* a
* household (see `OnboardingHouseholdPage`'s `goToNextStep`, which ends the
* wizard at `/` on every other path): enabled sources are a per-household
* preference (`HouseSource`, schema.prisma), so there's nothing to attach
* a selection to otherwise.
*
* Starts from an empty selection — a newly created/joined household has
* nothing enabled yet by design (opt-in, see `house.service.ts`'s
* Starts from an empty selection — a newly created household has nothing
* enabled yet by design (opt-in, see `house.service.ts`'s
* `getHouseSourceIds`). If no source is implemented yet (`getSources()`
* comes back empty — currently always the case), there's nothing to pick
* from either, so this step silently steps itself over rather than showing
* an empty screen.
* from either, so this step silently finishes the wizard (straight to `/`)
* rather than showing an empty screen.
*/
export function OnboardingSourcesPage() {
const { t } = useTranslation();
@ -40,7 +41,7 @@ export function OnboardingSourcesPage() {
.then((result) => {
if (cancelled) return;
if (result.length === 0) {
void navigate("/onboarding/allergenes", { replace: true });
void navigate("/", { replace: true });
return;
}
setSources(result);
@ -50,7 +51,7 @@ export function OnboardingSourcesPage() {
// Nothing to configure sources for if we can't even list them — the
// wizard shouldn't strand the visitor here over a transient failure
// fetching an optional step's own data.
if (!cancelled) void navigate("/onboarding/allergenes", { replace: true });
if (!cancelled) void navigate("/", { replace: true });
});
return () => {
cancelled = true;
@ -63,7 +64,7 @@ export function OnboardingSourcesPage() {
setIsSubmitting(true);
try {
await apiClient.updateHouseSourceIds(sourceIds);
void navigate("/onboarding/allergenes");
void navigate("/");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
@ -75,7 +76,9 @@ export function OnboardingSourcesPage() {
return (
<main className="onboarding-page">
<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>
<p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p>
@ -93,7 +96,7 @@ export function OnboardingSourcesPage() {
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")}
{t("onboarding.finish")}
</button>
</form>
</main>

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

View file

@ -1,7 +1,6 @@
import {
type CreateRecipeInput,
createRecipeSchema,
type DietView,
ErrorCode,
type IngredientView,
type RecipeVisibility,
@ -11,7 +10,6 @@ import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
import {
@ -68,7 +66,6 @@ export function RecipeFormPage() {
const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState("");
@ -80,7 +77,6 @@ export function RecipeFormPage() {
// get past the gate if 4 is already right for their recipe.
const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [steps, setSteps] = useState<StepDraft[]>([]);
@ -93,14 +89,12 @@ export function RecipeFormPage() {
Promise.all([
apiClient.getIngredients(),
apiClient.getDiets(),
apiClient.getUnits(),
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
])
.then(([ingredients, diets, units, recipe]) => {
.then(([ingredients, units, recipe]) => {
if (cancelled) return;
setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
setUnitsCatalog(units);
if (recipe) {
setName(recipe.name);
@ -108,7 +102,6 @@ export function RecipeFormPage() {
setPicture(recipe.picture ?? "");
setPortions(String(recipe.portions));
setVisibility(recipe.visibility);
setDietIds(recipe.diets.map((diet) => diet.id));
setIngredientLines(
recipe.ingredients.map((line) => ({
key: makeClientKey(),
@ -216,7 +209,6 @@ export function RecipeFormPage() {
picture: picture.trim() || null,
portions: Number(portions),
visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({
// A just-added free-text line has no catalog id yet — ask the API to
// create the placeholder row via `placeholderName`. An edit-loaded
@ -335,8 +327,6 @@ export function RecipeFormPage() {
))}
</select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list">

View file

@ -179,7 +179,9 @@ export function RecipesPage() {
<div className="recipes-page">
<div className="recipes-page__header">
<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
type="search"
className="recipes-page__search"
@ -187,7 +189,6 @@ export function RecipesPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")}
</Link>
@ -203,6 +204,7 @@ export function RecipesPage() {
<RecipeSourcesPanel
key={activeSourceKey}
sourceKey={activeSourceKey}
debouncedSearch={debouncedSearch}
// Only meaningful when it actually belongs to this source — e.g.
// clicking straight from TheMealDB's item "9999" to Marmiton's tab
// changes `activeSourceKey` before `selectedSourceItem` (URL-driven)

View file

@ -23,6 +23,13 @@ services:
# apps/api/Dockerfile) — no separate nginx/web container, no cross-origin
# CORS_ORIGIN to keep in sync between two ports.
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:
context: .
dockerfile: apps/api/Dockerfile
@ -54,6 +61,16 @@ services:
# has every /admin/* route 401. Must be a *different* secret than
# 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
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
# 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
# calls into it from outside.
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:
context: .
dockerfile: services/tech-step-intent-service/Dockerfile
@ -121,6 +141,9 @@ services:
# less deployment can omit this service entirely and `app` still runs
# fine, just without the offline audit/feedback-loop jobs.
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:
context: .
dockerfile: services/tech-step-llm-worker/Dockerfile

View file

@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js";
export * from "./logic/planning-portions.js";
export * from "./logic/recipe-diets.js";
export * from "./schemas/account.js";
export * from "./schemas/admin.js";
export * from "./schemas/auth.js";

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

View file

@ -17,6 +17,24 @@ export const adminLoginSchema = z.object({
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
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
* 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}. */
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) ----------
/** 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. */

View file

@ -68,8 +68,6 @@ export const createRecipeSchema = z
/** 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"),
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"),
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
})
@ -237,6 +235,13 @@ const manualStepTechStepTemperatureInputSchema = z
* `description` length to validate `start`/`end` against, which this shape
* 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
* the technique they're asserting (`correctedTechStepId`), same
* `source: "manual"` distinction the technique itself gets. The three
@ -262,6 +267,8 @@ export const submitTechStepCorrectionSchema = z
end: z.number().int().nonnegative(),
previousTechStepId: 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(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
@ -271,10 +278,20 @@ export const submitTechStepCorrectionSchema = z
path: ["end"],
})
.refine(
(input) =>
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
(input) => input.correctedTechStepId == null || input.correctedTechStepName === undefined,
{
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"],
},
)
@ -283,9 +300,10 @@ export const submitTechStepCorrectionSchema = z
(input.ingredients === undefined &&
input.utensils === 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"],
},
);

View file

@ -15,6 +15,37 @@ export interface AdminUserView {
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. */
export interface MetricsBreakdownRow {
key: string;
@ -228,3 +259,25 @@ export interface CatalogPlaceholderGroupView {
export interface PruneOrphansResultView {
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 }[];
}

View file

@ -161,10 +161,12 @@ export interface StepView {
* the same aggregation `GET /recipes/:id` performs for {@link RecipeView},
* 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), while
* `diets` is a property of the recipe itself (manually tagged by its
* author, not viewer-specific).
* `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma).
* `diets` is **computed from the recipe's ingredients**, not viewer-specific
* and no longer hand-tagged (issue #131, `RecipeDiet` dropped) — see
* {@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 {
id: number;

View file

@ -203,11 +203,24 @@ export interface UnitView {
*
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
* 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 {
id: number;
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;
}
/**

View file

@ -71,7 +71,7 @@ apps/web/src/
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
│ │ └── 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
│ └── onboarding.scss
├── styles/
@ -156,9 +156,11 @@ flowchart TB
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
un lien existant.
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `foyer` →
`sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un
foyer, sinon on saute directement à l'étape suivante) → `allergenes`.
sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `allergenes`
(les deux « préférences ») → `foyer` → `sources` (conditionnelle — seulement
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
flowchart LR
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
OB1 --> OB2["/onboarding/foyer"]
OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
OB3 --> OB4
OB4 --> HOME["/ (PlanningPage)"]
OB1 --> OB2["/onboarding/allergenes"]
OB2 --> OB3["/onboarding/foyer"]
OB3 -->|"foyer créé"| OB4["/onboarding/sources"]
OB3 -->|"rejoint / passé"| HOME["/ (PlanningPage)"]
OB4 --> HOME
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
SETTINGSMENU --> S2["/parametres/preferences"]
@ -240,18 +242,23 @@ flowchart LR
SETTINGSMENU --> S6["/parametres/credits"]
```
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
allergènes), lancé une seule fois juste après l'inscription. Routes
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein
écran sans sidebar (`onboarding.scss`, même langage visuel que
`/login`/`/signup`). Chaque étape a un unique bouton "Continuer" qui envoie
la valeur courante — pas de bouton "Passer" séparé, une valeur
"aucune"/vide *est* le skip. `/onboarding/sources` est **conditionnelle** :
seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer
(`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers
`/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en
silence vers l'étape suivante si `GET /reference/sources` revient vide ou en
erreur, plutôt que d'afficher une étape sans rien à choisir.
- **`pages/onboarding/`** — wizard de **3 écrans** (régime → allergènes →
foyer), + un **4ᵉ conditionnel** (sources), lancé une seule fois juste après
l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous
`AppLayout` : wizard plein écran sans sidebar (`onboarding.scss`, même
langage visuel que `/login`/`/signup`). Régime et allergènes forment les
deux « préférences » ; chacun a un unique bouton "Continuer" qui envoie la
valeur courante — une valeur "aucune"/vide *est* le skip. L'étape `foyer`,
elle, est explicitement optionnelle (créer / rejoindre / "Passer cette
étape"). `/onboarding/sources` est **conditionnelle** : seulement atteinte
si l'étape `foyer` vient de **créer** un foyer
(`OnboardingHouseholdPage`'s `goToNextStep`) — rejoindre ou passer termine
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
`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
@ -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
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
sentinelle invisible en fin de liste (`SourceItemTable`, un
`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
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
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`.
---