Compare commits

...

59 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
ba030ec18c feat(recipes): ingrédients d'assaisonnement « selon le goût » sans quantité (#128)
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 5m12s
CI / e2e (push) Successful in 11m1s
CI / test (push) Successful in 19m13s
CI / build-android (push) Has been skipped
`RecipeIngredient.quantity`/`unitId` deviennent nullable (migration purement
additive) : une ligne de recette peut n'avoir ni quantité ni unité — sel,
poivre… ajoutés selon le goût. Le schéma partagé impose « quantité et unité,
ou aucune des deux » ; `toRecipeView` renvoie `null`/`null`.

`aggregateShoppingList` fait apparaître une telle ligne une seule fois, sans
quantité ni unité, jamais sommée ni mise à l'échelle. `ShoppingListPage`
affiche « selon le goût » à la place de la quantité.

Formulaire : `IngredientRow` gagne une case « Selon le goût » qui masque
quantité/unité ; `RecipeFormPage` et `RecipeImportForm` n'exigent plus
quantité/unité pour ces lignes et les envoient sans. Un import dont la source
laisse une ligne résolue sans quantité (« salt to taste ») arrive
directement en « selon le goût ».

Le chemin clause-de-technique (session de cuisine) tolérait déjà `quantity`
null — inchangé.

Tests : Mocha (création/édition d'une ligne sans quantité, rejet
quantité-sans-unité, liste de courses non sommée) ; Cypress (case à cocher +
parcours), régression recette/liste de courses OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:15:28 +02:00
c3560eccec Merge pull request 'feat(recipes): barre Enregistrer/Annuler collée en pied du formulaire (#132)' (#135) from feat/recipe-form-sticky-actions into main
Some checks failed
CI / lint (push) Successful in 3m9s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 5m59s
CI / e2e (push) Successful in 11m7s
CI / test (push) Failing after 17m47s
CI / build-android (push) Has been skipped
2026-09-09 14:00:21 +02:00
8ae06f10c9 feat(recipes): barre Enregistrer/Annuler collée en pied du formulaire
All checks were successful
CI / lint (push) Successful in 56s
CI / build (push) Successful in 1m29s
CI / intent-service-test (push) Successful in 3m53s
CI / e2e (push) Successful in 8m26s
CI / test (push) Successful in 17m27s
CI / build-android (push) Has been skipped
`.recipe-form__actions` gagne un modificateur `--sticky`
(`position: sticky; bottom: 0`) : sur une recette longue (beaucoup
d'ingrédients/étapes) la barre reste visible sans redescendre. Ajoute un
bouton « Annuler » secondaire qui revient à la recette éditée
(`/recettes/:id`) ou au catalogue (`/recettes`) pour une création — sans
garde « abandonner les modifications ? », cohérent avec les autres
annulations de l'app (seule la suppression, irréversible, est confirmée).

Tests Cypress : la barre reste ancrée en bas après défilement vers le haut
d'un formulaire plus haut que la fenêtre ; « Annuler » navigue correctement
en création comme en édition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:42:14 +02:00
3dfed7e2e3 Merge pull request 'chore(release): prépare la v0.2.2' (#127) from chore/release-0.2.2 into main
Some checks failed
CI / lint (push) Successful in 4m49s
CI / build (push) Successful in 4m32s
CI / intent-service-test (push) Successful in 8m56s
Release / sanity-build (push) Successful in 11m8s
CI / e2e (push) Successful in 16m18s
Release / build-android-apk (push) Successful in 9m53s
Release / forgejo-release (push) Successful in 1m33s
Release / notify-portainer (push) Successful in 2s
CI / test (push) Successful in 47m42s
CI / build-android (push) Failing after 11m25s
2026-09-08 22:59:45 +02:00
46069727ad chore(release): prépare la v0.2.2
All checks were successful
CI / build (push) Successful in 2m38s
CI / lint (push) Successful in 2m38s
CI / intent-service-test (push) Successful in 4m19s
CI / e2e (push) Successful in 10m12s
CI / test (push) Successful in 18m11s
CI / build-android (push) Has been skipped
- `apps/web/package.json` 0.2.1 → 0.2.2 (alimente `__APP_VERSION__` : pied
  de sidebar + comparaison de `useAppUpdateCheck`), même convention que le
  commit `chore(web): version 0.2.1`.
- `release.yml` : la step « Crée la Release Forgejo » devient idempotente —
  si le tag est déjà connu de Forgejo (tag poussé à la main, ou ré-exécution
  du job), l'API répond 409 ; on récupère alors la release existante au lieu
  de faire échouer le job. C'est ce 409 qui avait fait tomber le run du tag
  v0.2.2 poussé en avance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:40:56 +02:00
b20bcca07e Merge pull request 'fix(sources): Manger Bouger sans filtre déroule tout le catalogue (#78)' (#126) from fix/manger-bouger-empty-filter into main
All checks were successful
CI / lint (push) Successful in 1m12s
CI / build (push) Successful in 1m18s
CI / intent-service-test (push) Successful in 4m6s
CI / e2e (push) Successful in 8m22s
CI / test (push) Successful in 17m39s
CI / build-android (push) Successful in 5m18s
2026-09-08 21:14:36 +02:00
7d20404b6f fix(sources): Manger Bouger sans filtre déroule tout le catalogue (#78)
All checks were successful
CI / lint (push) Successful in 2m48s
CI / build (push) Successful in 2m53s
CI / intent-service-test (push) Successful in 4m29s
CI / e2e (push) Successful in 9m49s
CI / test (push) Successful in 18m54s
CI / build-android (push) Has been skipped
Le parcours « tout voir » (filtre texte vide) de la source Manger Bouger
s'arrêtait après la 1re page (9 recettes) sur le serveur déployé.

Cause : `list()` ajoutait toujours un `&query=` final, même vide. Sur le
serveur déployé, `…/recettes?diet=ALL&page=N&query=` renvoyait la page 1
pour tout `N`, et le garde-fou anti-boucle de #38 (« 0 nouvel externalId
⇒ fin de liste ») coupait le défilement. La requête catalogue nue
`?diet=ALL&page=N` pagine correctement.

- `manger-bouger.ts` `list()` : n'ajoute `&query=` que si le terme de
  recherche est non vide (après `trim`), comme les adaptateurs voisins
  (marmiton / quitoque / ohmonbento). Le chemin filtré ne change pas.
- Commentaires `LIST_URL` / `hasMorePages` corrigés : la pagination
  « vérifiée en direct » ne valait que pour le chemin avec `query` texte.
- Tests `manger-bouger.test.ts` : `list({})` construit une URL sans
  paramètre `query` ; un `query` blanc = pas de filtre ; le catalogue nu
  pagine quand même (`nextCursor`) ; `list({ query: salade })` ajoute
  toujours `&query=salade` (non-régression).

Closes #78.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 20:30:25 +02:00
46c23c24ab Merge pull request 'feat(admin): crée un ticket Forgejo depuis un message de contact (#81)' (#125) from feat/forgejo-issue-from-contact-message into main
All checks were successful
CI / lint (push) Successful in 4m32s
CI / build (push) Successful in 4m40s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 10m33s
CI / test (push) Successful in 18m53s
CI / build-android (push) Successful in 5m8s
2026-09-08 20:30:08 +02:00
30f975d29a feat(admin): crée un ticket Forgejo depuis un message de contact (#81)
All checks were successful
CI / lint (push) Successful in 3m16s
CI / build (push) Successful in 3m29s
CI / intent-service-test (push) Successful in 4m39s
CI / e2e (push) Successful in 10m24s
CI / test (push) Successful in 18m48s
CI / build-android (push) Has been skipped
Depuis /admin/messages, un admin peut créer une issue Forgejo à partir
d'un message utilisateur : bouton « Créer un ticket » → modale pré-remplie
éditable → l'issue est créée via l'API Forgejo et le lien (#N) est
mémorisé sur le message. Première intégration Forgejo d'apps/api.

- Modèle : `forgejoIssueNumber` / `forgejoIssueUrl` sur `ContactMessage`
  (migration `20260908130000_contact_message_forgejo_link`, 2 colonnes
  nullables, aucun backfill).
- Config : `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO`, toutes
  `.optional()` — **fail-closed** (même posture que `ADMIN_JWT_SECRET`) :
  bouton masqué + endpoint `503` tant que les trois ne sont pas là.
  Documentées dans `.env.example` ; valeurs de test dans `ci.yml`.
- `ForgejoService` (`lib/forgejo/forgejo.service.ts`, singleton `forgejo`) :
  `isConfigured()` + `createIssue({ title, body })` → `POST .../issues`,
  en-tête `Authorization: token <pat>`, lit `{ number, html_url }` ;
  erreur réseau / non-2xx / JSON invalide ⇒ `HttpError 502
  FORGEJO_REQUEST_FAILED`.
- `admin-contact` : `POST /admin/api/contact/:id/forgejo-issue`
  (`createForgejoIssueFromMessage` — ordre 503 → 404 → 409 → Forgejo →
  écriture BDD ; un échec Forgejo ne touche pas la BDD) et
  `GET /admin/api/contact/forgejo/status`.
- Contrat partagé : `createForgejoIssueSchema` (title 1–255, body 1–60000),
  `ContactMessageAdminView` étendu, `ForgejoIntegrationStatus`, nouveaux
  `ErrorCode` (`FORGEJO_NOT_CONFIGURED`=5001, `FORGEJO_REQUEST_FAILED`=5002,
  `CONTACT_MESSAGE_ALREADY_LINKED`=4026, `CONTACT_MESSAGE_NOT_FOUND`=4055)
  + labels i18n.
- Front : `MessagesPage` — sonde `forgejo/status` au montage, colonne
  « Ticket » (lien `#N` si lié, bouton sinon, « non configurée » sinon),
  `CreateForgejoIssueModal` + helpers purs `messages.ts`
  (`buildIssueTitle` / `buildIssueBody`).
- Tests : `forgejo.service.test.ts` (stub `globalThis.fetch`),
  `admin-contact.test.ts` étendu (nominal / 503 / 404 / 409 / 502 +
  ligne intacte / 400), `admin-messages.cy.ts` étendu (modale pré-remplie,
  lien après création, masquage si non configuré).
- Docs : `backend-architecture.md`, `batch-cooking-modele.md`.

Closes #81.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 20:10:54 +02:00
71758e6d25 Merge pull request 'feat(admin): consultation des messages de contact (#80)' (#124) from feat/admin-contact-messages into main
Some checks failed
CI / lint (push) Successful in 2m30s
CI / build (push) Successful in 2m34s
CI / intent-service-test (push) Successful in 5m31s
Release / sanity-build (push) Successful in 6m39s
Release / build-android-apk (push) Successful in 8m47s
CI / e2e (push) Successful in 12m20s
CI / test (push) Successful in 20m26s
CI / build-android (push) Has been skipped
Release / forgejo-release (push) Failing after 33s
Release / notify-portainer (push) Has been skipped
2026-09-08 09:51:45 +02:00
0c8cda7f3e feat(admin): consultation des messages de contact (#80)
All checks were successful
CI / lint (push) Successful in 4m17s
CI / build (push) Successful in 5m11s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 11m26s
CI / test (push) Successful in 29m58s
CI / build-android (push) Has been skipped
PR 2/2 de l'issue #80 — la vue admin en lecture seule des messages
remontés via le formulaire de contact (PR 1 = #123).

- Contrat partagé : `listContactMessagesQuerySchema` (filtre `type`
  optionnel) + `types/contact.ts` `ContactMessageAdminView` (`author` =
  `{ id, firstName, lastName, email } | null`, `null` = compte supprimé).
- API : `admin-contact.{routes,service}.ts` — `GET /admin/api/contact?type=`
  (`requireAdmin`), tri `createdAt desc`, plafond `CONTACT_MESSAGES_PAGE_SIZE
  = 200` (même esprit que `CORRECTIONS_PAGE_SIZE`). Branché dans
  `admin.routes.ts` (`adminRouter.use(/contact, …)`). Aucune route
  d'écriture.
- Front : page `/admin/messages` (`MessagesPage` + `.scss`) — tableau
  lecture seule (date, expéditeur ou « Compte supprimé », badge de type,
  message, page, user-agent tronqué + titre au survol), un filtre par type.
  Entrée de nav « Messages » (icône `Inbox`) dans `AdminLayout` +
  `adminApiClient.getContactMessages` + i18n `admin.nav.messages` /
  `admin.messages.*`.
- Tests : `apps/api/test/admin-contact.test.ts` (Mocha — 401 sans session
  admin, tri récent d'abord + `author` résolu, filtre `type`, type inconnu →
  400, plafond 200, `author: null` après suppression du compte),
  `apps/web/cypress/e2e/admin-messages.cy.ts` (rendu lecture seule, filtre
  par type via le query param).
- Docs : `backend-architecture.md` (section `admin` + event
  `contact.submitted` dans la liste).

Closes #80.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:17:45 +02:00
9b6cee9950 Merge pull request 'feat(contact): modèle + POST /contact + formulaire utilisateur (#80)' (#123) from feat/contact-message-model-and-form into main
Some checks failed
CI / lint (push) Successful in 4m7s
CI / build (push) Successful in 5m5s
CI / intent-service-test (push) Successful in 5m14s
CI / e2e (push) Successful in 12m42s
CI / test (push) Failing after 19m31s
CI / build-android (push) Has been skipped
2026-09-08 09:17:19 +02:00
b873b70946 chore: relance la CI (#123)
All checks were successful
CI / lint (push) Successful in 3m40s
CI / build (push) Successful in 3m43s
CI / intent-service-test (push) Successful in 4m35s
CI / e2e (push) Successful in 9m39s
CI / test (push) Successful in 18m14s
CI / build-android (push) Has been skipped
Le job `test` a échoué sur `a252640` par contention Postgres avec la CI de
#124 lancée en parallèle (15 timeouts de hook « before each » + quelques
PrismaClientKnownRequestError, tous hors périmètre — les 7 tests `Contact`
passent). #124 (surensemble de cette branche) est vert. Relance isolée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:57:37 +02:00
a252640fe6 feat(contact): modèle + POST /contact + formulaire utilisateur (#80)
Some checks failed
CI / build (push) Successful in 2m22s
CI / lint (push) Successful in 2m26s
CI / intent-service-test (push) Successful in 4m8s
CI / e2e (push) Successful in 12m40s
CI / test (push) Failing after 15m4s
CI / build-android (push) Has been skipped
Formulaire in-app de remontée de bug / suggestion pour les utilisateurs
connectés (PR 1/2 de l'issue #80).

- Modèle Prisma `ContactMessage` (+ relation inverse sur `UserProfile`) et
  migration `20260908120000_contact_message` — table neuve, aucun backfill.
  `authorId` nullable + `onDelete: SetNull` : un signalement survit à la
  suppression de son compte.
- Contrat partagé `schemas/contact.ts` : `CONTACT_MESSAGE_TYPES`,
  `submitContactMessageSchema` (type enum, message 10-5000 trim, pageUrl /
  appVersion optionnels — jamais le user-agent).
- Module API `contact` : `POST /contact` (`requireAuth`, monté `/contact`),
  `204` sans corps. Le user-agent est lu de l'en-tête de requête, jamais du
  corps. Émet `analytics.recordEvent(contact.submitted, …)`.
- Page `/parametres/contact` (`ContactPage` + `contact.ts` + `.scss`) :
  select de type, textarea, validation cliente via le schéma partagé, états
  succès / erreur (`ErrorMessageService`, jamais `err.message`). Entrée de
  nav « Nous contacter » dans `SETTINGS_ITEMS` + clé i18n + icône.
  `apiClient.submitContactMessage(input)` ajoute `pageUrl` / `appVersion`.
- Tests : `apps/api/test/contact.test.ts` (Mocha — insertion, en-tête UA,
  champs nullables, message trop court / trop long, type inconnu),
  `contact.feature` + steps (Cypress e2e — envoi + confirmation + contexte),
  `contact-page.cy.ts` (rendu + garde `RequireAuth` + label d'erreur).
- Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
  `frontend-architecture.md`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:26:47 +02:00
206ab6fa0c Merge pull request 'fix(mobile): retire les doubles tirets des commentaires du values-night XML' (#122) from fix/android-values-night-xml-comment into main
All checks were successful
CI / lint (push) Successful in 2m57s
CI / build (push) Successful in 3m19s
CI / intent-service-test (push) Successful in 5m15s
CI / e2e (push) Successful in 10m0s
CI / test (push) Successful in 18m12s
CI / build-android (push) Successful in 5m8s
2026-09-08 07:51:25 +02:00
e9040f93f4 fix(mobile): retire les doubles tirets des commentaires du values-night XML
All checks were successful
CI / lint (push) Successful in 2m48s
CI / build (push) Successful in 2m52s
CI / intent-service-test (push) Successful in 4m37s
CI / e2e (push) Successful in 10m8s
CI / test (push) Successful in 17m18s
CI / build-android (push) Has been skipped
`:app:mergeReleaseResources` (job CI `build-android`, main uniquement)
échoue : AAPT interdit la chaîne « -- » à l'intérieur d'un commentaire XML,
et `res/values-night/colors.xml` (issue #104, PR #120) citait les tokens
`_theme.scss` avec leur préfixe de custom-property CSS (`--color-*`) dans
ses commentaires.

Commentaires réécrits sans aucun « -- » (tokens nommés « color-x »), même
convention que `res/values/colors.xml`. Valeurs et éléments `<color>`
inchangés. Non vérifiable en local (pas de JDK 21 sur cette machine) ; le
job `build-android` du prochain run sur `main` valide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 07:33:32 +02:00
22119adf07 Merge pull request 'feat(shopping-list): coche les articles « dans le panier » (#109)' (#121) from feat/shopping-list-check-items into main
Some checks failed
CI / lint (push) Successful in 2m45s
CI / build (push) Successful in 3m0s
CI / intent-service-test (push) Successful in 4m45s
CI / e2e (push) Successful in 10m12s
CI / test (push) Successful in 17m31s
CI / build-android (push) Failing after 4m19s
2026-09-08 07:08:47 +02:00
592933b1ac feat(shopping-list): coche les articles « dans le panier » (#109)
All checks were successful
CI / lint (push) Successful in 2m18s
CI / build (push) Successful in 3m34s
CI / intent-service-test (push) Successful in 6m36s
CI / e2e (push) Successful in 10m22s
CI / test (push) Successful in 33m30s
CI / build-android (push) Has been skipped
La liste de courses était volontairement en lecture seule. Elle gagne un
état « acheté » par article, utile en magasin (au doigt sur mobile).

- `shopping-list-progress.ts` (nouveau) : `ShoppingListProgressStore`,
  calqué sur `CookingProgressStore` — `localStorage` uniquement, par
  semaine (clé `batchcooking:shoppingListChecked:<weekStart>`), aucun état
  serveur. Une coche n'est jamais autoritaire (la liste est recalculée
  depuis le planning à chaque visite) et se perd sans dommage.
- `ShoppingListPage.tsx` : chaque ligne devient un `CheckboxOption` ;
  `<ShoppingListItems key={weekKey}>` remonte à chaque changement de
  semaine (checklist réinitialisée). Clé d'article = `ingredientId-unitId`
  (déjà la `key` React de la ligne).
- `shopping-list-page.scss` : override scopé du look « carte sélectionnable »
  global — un article coché est barré et estompé, pas surligné en primary.
  Cible tactile ≥ 44 px sous le breakpoint phone.
- `specs/frontend-architecture.md` : la note « délibérément en lecture
  seule » est remplacée.

Tests : `shopping-list.feature` — nouveau scénario « cocher un article »
(coché → barré, persiste au reload, n'affecte pas les autres).
`shopping-list-page.cy.ts` — la cible tactile de la ligne (le label
`CheckboxOption`) fait 16 px de padding + ≥ 44 px de haut sur téléphone.
Local : `pnpm lint` + `pnpm --filter web build` OK, `shopping-list.feature`
4/4, `shopping-list-page.cy.ts` 1/1.

Closes #109

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 06:31:37 +02:00
f68e8dbdc6 Merge pull request 'fix(mobile): la barre d'état Android suit le thème clair/sombre système (#104)' (#120) from fix/android-status-bar-dark-mode into main
Some checks failed
CI / lint (push) Successful in 4m5s
CI / build (push) Successful in 4m22s
CI / intent-service-test (push) Successful in 5m16s
CI / e2e (push) Successful in 14m5s
CI / test (push) Successful in 21m28s
CI / build-android (push) Failing after 5m18s
2026-09-08 06:31:24 +02:00
ef8dbe8388 fix(mobile): la barre d'état Android suit le thème clair/sombre système (#104)
All checks were successful
CI / lint (push) Successful in 2m37s
CI / build (push) Successful in 3m49s
CI / intent-service-test (push) Successful in 6m57s
CI / e2e (push) Successful in 11m18s
CI / test (push) Successful in 31m37s
CI / build-android (push) Has been skipped
`AppTheme.NoActionBar` a bien un parent `DayNight`, mais sans dossier
`res/values-night/` ses `@color/colorPrimaryDark` / `@color/colorBackground`
résolvaient aux valeurs claires même en mode sombre système : la bande de
la barre d'état et la fenêtre pré-WebView (flash de démarrage à froid +
fond du splash) restaient sur la palette « porcelaine » claire pendant que
le téléphone — et le contenu WebView de l'app — passaient en sombre.

- `android/app/src/main/res/values-night/colors.xml` (nouveau) : variantes
  sombres de `colorPrimary` / `colorPrimaryDark` / `colorAccent` /
  `colorBackground`, calées sur les tokens `--color-*` sombres de
  `_theme.scss`. En sombre, la bande de la barre d'état devient le
  `--color-background` sombre (`#14181A`), sans raccord avec l'app.
  `windowLightStatusBar=false` (icônes claires) reste correct dans les deux
  modes. Aucun changement de `styles.xml` : il référence déjà ces noms de
  couleur, le parent `DayNight` + `values-night/` fait le reste.
- `index.html` : `<meta name=theme-color>` (light/dark) pour teinter
  aussi le chrome du navigateur mobile.

Vérification : impossible sur appareil/émulateur dans ce contexte — le job
CI `build-android` (main uniquement) compilera au moins la ressource. La
synchro live de la barre d'état avec le sélecteur de thème *in-app* (via
`@capacitor/status-bar` + JS) reste un éventuel suivi ; ce correctif règle
le « ne suit pas les réglages système » du rapport.

Closes #104

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:55:34 +02:00
f8b224dc8c Merge pull request 'fix(layout): masque l'onglet « Paramètres d'application » hors de l'app native (#107)' (#119) from fix/hide-app-settings-tab-on-web into main
Some checks failed
CI / build (push) Successful in 4m0s
CI / lint (push) Successful in 4m28s
CI / intent-service-test (push) Successful in 4m36s
CI / e2e (push) Failing after 12m25s
CI / test (push) Successful in 21m29s
CI / build-android (push) Has been skipped
2026-09-08 05:55:22 +02:00
8bcea74b56 fix(layout): masque l'onglet « Paramètres d'application » hors de l'app native (#107)
All checks were successful
CI / lint (push) Successful in 4m25s
CI / build (push) Successful in 2m18s
CI / intent-service-test (push) Successful in 5m41s
CI / e2e (push) Successful in 8m25s
CI / test (push) Successful in 30m0s
CI / build-android (push) Has been skipped
Ses sections n'ont pas de sens sur le web (pas de « version installée », la
vérif de mise à jour y est un no-op) et son seul réglage web-pertinent — la
case « toujours rester sur le site web » — vit mieux dans le flux qu'il
pilote.

- `AppLayout.tsx` : `SETTINGS_ITEMS` gagne un `nativeOnly`, filtré une fois
  au chargement du module (`settingsNavItems`) via `Capacitor.isNativePlatform()`
  — l'entrée disparaît du menu Paramètres (sidebar + feuille « Plus »).
- `App.tsx` : la route `/parametres/application` redirige vers
  `/parametres/compte` sur le web, comme le précédent `/foyer`, pour qu'un
  bookmark ne tombe pas sur une page orpheline.
- `AppSettingsPage.tsx` : devient natif-only — la branche `webHint` et la
  section « rester sur le site web » sont retirées.
- La case « toujours rester sur le site web » déménage sur `/telecharger`
  (`DownloadPage`) : page publique qu'un visiteur non connecté sur
  navigateur téléphone peut atteindre, et c'est exactement le flux que la
  préférence concerne. `localStorage` sur l'origine web publique, là où
  `attemptOpenNativeApp` la lit. i18n `appSettings.redirect.*` →
  `download.stayOnWeb.*` ; `appSettings.version.webHint` supprimée.

Tests : `sidebar.cy.ts` / `app-settings-page.cy.ts` — l'entrée et la page
sont absentes sur le web, la route redirige. `download-page.cy.ts` — la
case « rester sur le site web » par défaut décochée, persistée au reload.
Local OK (sidebar 5/5, app-settings 2/2, download 3/3).

Closes #107

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:23:06 +02:00
1b96b891db Merge pull request 'fix(layout): barre de navigation mobile en icônes seules (#105)' (#118) from fix/mobile-bottom-nav-icons-only into main
Some checks failed
CI / lint (push) Successful in 2m10s
CI / build (push) Successful in 2m21s
CI / intent-service-test (push) Successful in 5m4s
CI / e2e (push) Successful in 11m30s
CI / test (push) Failing after 18m32s
CI / build-android (push) Has been skipped
2026-09-08 05:21:58 +02:00
e702e21296 fix(layout): barre de navigation mobile en icônes seules (#105)
All checks were successful
CI / lint (push) Successful in 4m37s
CI / build (push) Successful in 3m17s
CI / intent-service-test (push) Successful in 5m34s
CI / e2e (push) Successful in 8m59s
CI / test (push) Successful in 30m12s
CI / build-android (push) Has been skipped
Sur téléphone, l'onglet « Liste de courses » (16 caractères) passait sur 2
lignes dans un onglet à ~90px alors que « Planning »/« Recettes »/« Plus »
tenaient sur 1 ligne : son bloc icône+libellé centré était plus haut, donc
son icône visiblement plus haute que les autres.

`AppLayout.scss` `.app-bottom-nav` : le `.label` est masqué visuellement (à
la manière du rail desktop replié — `position: absolute; clip; …`), pas
retiré du DOM, donc chaque `<NavLink>`/bouton garde son nom accessible (un
lecteur d'écran annonce toujours « Planning », « Liste de courses »…) sans
`aria-label` qui dupliquerait la chaîne i18n. Icônes légèrement agrandies
(1.375rem → 1.5rem) puisqu'elles sont désormais seules.

Tests : `layout.cy.ts` — l'onglet expose son `span.label` dans le DOM mais
`not.be.visible`, l'icône `svg` visible (mise à jour de l'assertion qui
attendait le libellé visible). `sidebar.cy.ts` / `planning-page.cy.ts`
(nav desktop) inchangés. Suite layout/sidebar/planning revérifiée, 27/27.

Closes #105

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:56 +02:00
6828809081 Merge pull request 'fix(landing): masque « Ouvrir dans l'application » dans l'app native (#103)' (#117) from fix/landing-hide-open-app-in-native into main
Some checks failed
CI / lint (push) Successful in 2m8s
CI / build (push) Successful in 2m40s
CI / intent-service-test (push) Successful in 4m58s
CI / e2e (push) Successful in 11m23s
CI / test (push) Failing after 18m21s
CI / build-android (push) Has been skipped
2026-09-08 04:48:44 +02:00
126cd840f5 fix(landing): masque « Ouvrir dans l'application » dans l'app native (#103)
All checks were successful
CI / lint (push) Successful in 2m49s
CI / build (push) Successful in 4m5s
CI / intent-service-test (push) Successful in 6m9s
CI / e2e (push) Successful in 10m31s
CI / test (push) Successful in 29m20s
CI / build-android (push) Has been skipped
`isMobile = isAndroidPhoneBrowser()` est une détection d'user-agent : la
WebView Capacitor contient « Android »/« Mobile », donc le bouton
« Ouvrir dans l'application » s'affichait aussi *dans* l'app packagée — où
le taper tente de relancer l'app via le scheme, échoue, et retombe sur
`/telecharger`.

- Nouveau `canOfferApp = isMobile && !Capacitor.isNativePlatform()` — « un
  navigateur *téléphone* où pousser l'app native a du sens ». Gate le
  bouton et la rétrogradation de « Créer un compte » en secondaire.
- Le lien `/telecharger` (variante desktop) gagne aussi `&&
  !Capacitor.isNativePlatform()` : sur tablette la WebView Capacitor peut
  ne pas avoir « Mobile » dans son UA, donc `!isMobile` ne suffisait pas.

Aucun changement sur le web (`Capacitor.isNativePlatform()` y est toujours
`false`, donc `canOfferApp === isMobile`) — `landing.feature`
(navigateur téléphone + desktop) revérifiée, 7/7. Le chemin natif n'a pas
de couture de test depuis un run web (limitation déjà documentée, cf.
`app-settings-page.cy.ts`).

Closes #103

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:15:00 +02:00
3d9f93e204 Merge pull request 'fix(auth): déconnexion forcée quand une requête 401 en cours de session (#108)' (#116) from fix/client-force-logout-on-401 into main
Some checks failed
CI / lint (push) Successful in 3m51s
CI / build (push) Successful in 4m49s
CI / intent-service-test (push) Successful in 5m10s
CI / e2e (push) Successful in 12m10s
CI / test (push) Failing after 19m12s
CI / build-android (push) Has been skipped
2026-09-08 04:14:48 +02:00
a54cb85027 fix(auth): déconnexion forcée quand une requête 401 en cours de session (#108)
All checks were successful
CI / lint (push) Successful in 2m37s
CI / build (push) Successful in 3m37s
CI / intent-service-test (push) Successful in 5m20s
CI / e2e (push) Successful in 9m52s
CI / test (push) Successful in 28m39s
CI / build-android (push) Has been skipped
La session expire déjà (JWT 7 j), mais le client web n'y réagissait pas :
seul le `GET /auth/me` du montage de `AuthProvider` attrapait un 401 ; un
401 de n'importe quel autre endpoint ne faisait que remonter une erreur
locale, l'UI connectée restant affichée jusqu'au prochain rechargement
complet. Sur l'app native (rarement redémarrée, token persisté) la session
paraissait ne jamais finir.

- `client.ts` : sur un `401 NOT_AUTHENTICATED` pour toute requête autre que
  le probe `GET /auth/me`, `ApiClient._request` purge le token store et
  appelle un handler `onSessionExpired` (enregistré via
  `setSessionExpiredHandler`). L'`ApiError` continue d'être levée comme
  avant.
- `AuthContext.tsx` : `AuthProvider` enregistre ce handler au montage —
  `setUser(null)` + `navigate(/login, { replace: true })`, gardé par un
  `userRef` pour être un no-op si déjà déconnecté. `ApiClient` restant
  agnostique du framework, c'est bien lui qui délègue la navigation/état.

Pas de changement API : le back renvoie déjà 401 sur JWT expiré
(`require-auth.ts`), couvert par sa suite Mocha.

Tests : `apps/web/cypress/e2e/session-expiry.cy.ts` — un 401 en cours de
session redirige vers `/login` et vide l'état (un retour sur une route
gardée re-redirige) ; le 401 du `/auth/me` initial d'un visiteur non
connecté ne déclenche pas ce chemin (la landing reste affichée). Suite e2e
existante (auth / layout / account / recipes / onboarding / preferences)
revérifiée sans régression.

Closes #108

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:41:46 +02:00
c17b5b0060 Merge pull request 'fix(mobile): lance la vérification de mise à jour à chaque ouverture (#106)' (#114) from fix/mobile-update-check-on-open into main
Some checks failed
CI / lint (push) Successful in 3m35s
CI / build (push) Successful in 4m30s
CI / intent-service-test (push) Successful in 5m21s
CI / e2e (push) Successful in 13m6s
CI / test (push) Failing after 18m13s
CI / build-android (push) Has been skipped
2026-09-08 03:41:34 +02:00
3df24fe71c fix(mobile): lance la vérification de mise à jour à chaque ouverture (#106)
All checks were successful
CI / lint (push) Successful in 3m9s
CI / build (push) Successful in 3m50s
CI / intent-service-test (push) Successful in 7m32s
CI / e2e (push) Successful in 10m13s
CI / test (push) Successful in 33m6s
CI / build-android (push) Has been skipped
`useAppUpdateCheck` tournait au montage de `<UpdateBanner />`, mais celui-ci
n'était monté que dans `AppLayout`, derrière `RequireAuth` : un utilisateur
non connecté sur l'app native voit `LandingPage`, `AppLayout` n'est jamais
monté, donc aucune vérification tant qu'on n'est pas connecté — alors que
le back suppose l'inverse (`mobile.routes.ts` : « runs on every app start,
session or not »).

- `<UpdateBanner />` monté dans `App.tsx`, hors `<Routes>`, donc rendu pour
  toute route quel que soit l'état d'auth. Garde `!isNativePlatform()` et
  throttle 6 h inchangés ; rend toujours `null` sur le web.
- `update-banner.scss` : passe en `position: fixed` (bandeau haut,
  `env(safe-area-inset-top)`, `z-index: 100`) pour ne pas entrer dans la
  mise en page / le calcul de hauteur d'une page routée (`.app-layout` est
  `height: 100vh; overflow: hidden`).

Pas de test e2e : `Capacitor.isNativePlatform()` n'a pas de couture pour
simuler le natif depuis un run web (posture déjà assumée pour
`useAppUpdateCheck` / `UpdateBanner`, voir `app-settings-page.cy.ts`). Suite
e2e existante (layout / smoke / app-settings) revérifiée : aucun régression.

Closes #106

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:04:48 +02:00
99d7badddf Merge pull request 'fix(test): fige l'horloge dans le spec vue-mobile-jour de layout.cy.ts' (#115) from fix/layout-spec-freeze-clock into main
All checks were successful
CI / lint (push) Successful in 3m52s
CI / build (push) Successful in 3m52s
CI / intent-service-test (push) Successful in 5m4s
CI / e2e (push) Successful in 13m1s
CI / test (push) Successful in 21m6s
CI / build-android (push) Successful in 6m27s
2026-09-08 03:04:36 +02:00
b8cbf2d777 fix(test): fige l'horloge dans le spec vue-mobile-jour de layout.cy.ts
All checks were successful
CI / lint (push) Successful in 2m51s
CI / build (push) Successful in 3m3s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 9m47s
CI / test (push) Successful in 17m22s
CI / build-android (push) Has been skipped
`PlanningDayView` ouvre sur le jour *courant* de la semaine affichée
(`defaultDayIndexForWeek`, dérivé de `Date`). Le test « shows the one-day
mobile view » ne figeait pas l'horloge : il ne passait que quand la CI
tournait un lundi (UTC), et échouait les autres jours (`Curry de
lentilles`, moqué sur « lundi », hors du jour affiché).

`cy.clock(new Date(2026-08-17T09:00:00.000Z), [Date])` avant le
`cy.visit` — même lundi de référence que `planning-page.cy.ts` ; `[Date]`
seulement, les timers/debounces tournent toujours.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:46:44 +02:00
d4d1b9eb3a Merge pull request 'fix(planning): la grille remplit l'écran, une case ne grandit plus (#110)' (#113) from fix/planning-grid-fluid-height into main
Some checks failed
CI / lint (push) Successful in 3m26s
CI / build (push) Successful in 3m37s
CI / intent-service-test (push) Successful in 5m3s
CI / e2e (push) Failing after 12m5s
CI / test (push) Successful in 20m39s
CI / build-android (push) Has been skipped
2026-09-08 02:08:53 +02:00
c4c2c2ae39 fix(planning): la grille remplit l'écran, une case ne grandit plus (#110)
All checks were successful
CI / lint (push) Successful in 2m23s
CI / build (push) Successful in 5m41s
CI / intent-service-test (push) Successful in 7m13s
CI / e2e (push) Successful in 10m50s
CI / test (push) Successful in 29m0s
CI / build-android (push) Has been skipped
`table.planning-grid` a `height: 100%` mais des hauteurs de ligne pilotées
par le contenu : un créneau avec plusieurs recettes étirait son `<td>`,
donc toute la ligne (les autres cases gagnant la même hauteur morte sous
`vertical-align: top`), puis la table débordait de `.planning-grid-wrapper`
en scroll vertical.

- `tbody tr { height: 1px }` : les cinq lignes se partagent la hauteur du
  wrapper à parts égales (une hauteur de ligne est un minimum que le
  navigateur étire pour absorber la hauteur restante de la table), au lieu
  d'être aussi hautes que la colonne la plus chargée.
- `.meal-cell__recipes` : `max-height: 8.5rem; overflow-y: auto` — au-delà
  d'une poignée de recettes la liste scrolle dans la case. Cap en `rem` et
  pas en `%` : les hauteurs en pourcentage sur un `<td>` ne se résolvent
  pas de façon fiable selon le moteur. `.add-recipe-btn` (frère flex hors
  de cette boîte) reste épinglé juste en dessous.
- `min-width: 62rem` laissé tel quel : les 7 colonnes ont besoin d'un
  plancher pour rester lisibles, et le scroll horizontal dessous est
  couvert par `layout.cy.ts`. Le breakpoint phone (vue `PlanningDayView`)
  n'est pas concerné.

Closes #110

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:36:21 +02:00
b52586e076 Merge pull request 'fix(planning): affiche les recettes du picker sur mobile (#111)' (#112) from fix/recipe-picker-dialog-mobile-empty into main
Some checks failed
CI / lint (push) Successful in 3m32s
CI / build (push) Successful in 4m3s
CI / intent-service-test (push) Successful in 5m7s
CI / e2e (push) Successful in 11m42s
CI / test (push) Failing after 18m36s
CI / build-android (push) Has been skipped
2026-09-08 01:35:52 +02:00
139 changed files with 7314 additions and 1130 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. # (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000 # APP_PORT=3000
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
# building locally instead (`docker compose build`, or `up` with no image
# present) — see docker-compose.yml's `image:`/`build:` comment.
# IMAGE_TAG=latest
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) -------- # --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
# All optional: an instance that doesn't run the admin app needs none of # All optional: an instance that doesn't run the admin app needs none of
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so # these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
@ -32,6 +40,17 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# ADMIN_INITIAL_EMAIL=ops@example.com # ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops # ADMIN_INITIAL_NAME=Ops
#
# Optional — lets an admin open a Forgejo ticket from a contact message
# (apps/web's /admin/messages, issue #81). All three needed together: the
# button stays hidden and the endpoint answers 503 unless all are set to a
# non-empty value. docker-compose.yml forwards these into the "app"
# container, so setting them here is enough (a blank value counts as
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
# scope — generate it in Forgejo, never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo
# Optional — only set this to false if THIS deployment is served over # Optional — only set this to false if THIS deployment is served over
# plain HTTP (no TLS in front of it). Left unset, the session cookie # plain HTTP (no TLS in front of it). Left unset, the session cookie

View file

@ -39,6 +39,13 @@ env:
# services/tech-step-intent-service/Dockerfile from this checkout. # services/tech-step-intent-service/Dockerfile from this checkout.
INTENT_SERVICE_BASE_URL: "http://localhost:8000" INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+" INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
# in those tests, so no real request ever leaves CI; these values only need
# to parse (a valid URL and an `owner/repo`).
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
FORGEJO_REPO: "ci/batchcooking"
jobs: jobs:
# Five independent jobs, no needs: between them — each starts in parallel # Five independent jobs, no needs: between them — each starts in parallel

View file

@ -1,20 +1,34 @@
name: Release name: Release
# No image registry involved: Portainer is wired to this repo's Git remote # On a `vX.Y.Z` tag: build the three service images and push them to this
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks # Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
# that the image actually builds at the tagged commit, builds+attaches a # batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
# signed Android APK, publishes a Forgejo Release, then (best-effort) pings # and `latest`), build+attach a signed Android APK, publish a Forgejo
# Portainer's deploy webhook so it doesn't have to wait for its own polling # Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
# interval. # pull those prebuilt images (docker-compose.yml references them via
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
# keeps a `build:` fallback next to every `image:`.
on: on:
push: push:
tags: ["v*.*.*"] tags: ["v*.*.*"]
permissions: permissions:
contents: write contents: write
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
# (the `build-and-push-images` job below `docker login`s with it). If a
# future instance policy scopes the run token tighter, swap that login's
# password for a `write:package` PAT added as a repo secret.
packages: write
jobs: jobs:
sanity-build: # Build the three service images at the tagged commit and push them to the
# Forgejo container registry. A successful push also *is* the "does it
# still build" check the old `sanity-build` job did (which only covered
# apps/api). One sequential job, not a per-image matrix: three concurrent
# `docker build`s hammer this single persistent runner's Docker daemon
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
# for no real gain on a release, which is rare and usually runs alone.
build-and-push-images:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# This runner's job containers don't come with docker preinstalled — # This runner's job containers don't come with docker preinstalled —
@ -25,10 +39,36 @@ jobs:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
# Build only, no push, no registry — just confirms Portainer will be - name: Build et push des 3 images vers le registre Forgejo
# able to build this same Dockerfile successfully at this tag before env:
# anyone points it at this ref. # The per-run token Forgejo Actions mints — has `packages: write`
- run: docker build -f apps/api/Dockerfile . # via this workflow's `permissions:` block. `docker login` accepts
# it as the password for the actor who pushed the tag.
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
# registry namespace is the owner, lower-cased (Forgejo package
# names are case-insensitive but the CLI wants a stable form).
registry="${GITHUB_SERVER_URL#http*://}"
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
tag="$GITHUB_REF_NAME"
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
# Every Dockerfile's build context is the repo root (see
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
build_push() {
image="$registry/$owner/$1"
echo "::group::$1"
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
docker push "$image:$tag"
docker push "$image:latest"
echo "::endgroup::"
}
build_push batchcooking-app apps/api/Dockerfile
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
# Builds the same signed APK as ci.yml's build-android job (issue #64), # Builds the same signed APK as ci.yml's build-android job (issue #64),
# with one difference: the version baked into the APK is derived from the # with one difference: the version baked into the APK is derived from the
@ -124,11 +164,11 @@ jobs:
forgejo-release: forgejo-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [sanity-build, build-android-apk] needs: [build-and-push-images, build-android-apk]
steps: steps:
# Ce runner ne fournit ni docker ni curl par défaut dans ses # Ce runner ne fournit ni docker ni curl par défaut dans ses
# conteneurs de job — même constat déjà fait pour docker (voir # conteneurs de job — même constat déjà fait pour docker (voir
# sanity-build) et pour curl dans ci.yml's test job. # build-and-push-images) et pour curl dans ci.yml's test job.
- name: Install curl - name: Install curl
run: apt-get update && apt-get install -y curl run: apt-get update && apt-get install -y curl
@ -151,23 +191,39 @@ jobs:
# propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la # propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la
# release reste minimal, cohérent avec l'absence d'outillage de # release reste minimal, cohérent avec l'absence d'outillage de
# changelog ailleurs dans le repo. # changelog ailleurs dans le repo.
- name: Crée la Release Forgejo - name: Crée (ou récupère) la Release Forgejo
id: create-release id: create-release
env: env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases" api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"
body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")" body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")"
resp="$(curl -fsS -X POST "$api" \
# POST pour créer la release, code HTTP capturé à part. Si le tag est
# déjà connu de Forgejo (tag poussé à la main avant ce workflow, ou
# ré-exécution du job), l'API répond 409 : on réutilise alors la
# release existante au lieu d'échouer — idempotence, cf. l'incident
# du tag v0.2.2 poussé en avance qui a fait tomber ce job sur un 409.
code="$(curl -sS -o /tmp/release.json -w '%{http_code}' -X POST "$api" \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$body")" -d "$body")"
id="$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)"
if [ -z "$id" ]; then if [ "$code" = "409" ]; then
echo "Réponse inattendue de l'API Release, pas d'id trouvé : $resp" >&2 echo "Release $GITHUB_REF_NAME déjà existante (HTTP 409) — récupération de son id."
curl -fsS "$api/tags/$GITHUB_REF_NAME" \
-H "Authorization: token $FORGEJO_TOKEN" -o /tmp/release.json
elif [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
echo "Échec de création de la Release (HTTP $code) : $(cat /tmp/release.json)" >&2
exit 1 exit 1
fi fi
echo "Release $GITHUB_REF_NAME créée (id=$id)"
id="$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)"
if [ -z "$id" ]; then
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
exit 1
fi
echo "Release $GITHUB_REF_NAME prête (id=$id)"
echo "release-id=$id" >> "$GITHUB_OUTPUT" echo "release-id=$id" >> "$GITHUB_OUTPUT"
- name: Attache l'APK à la Release - name: Attache l'APK à la Release

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

View file

@ -35,3 +35,12 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# ADMIN_INITIAL_EMAIL=ops@example.com # ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops # ADMIN_INITIAL_NAME=Ops
# Only needed to create a Forgejo issue from a contact message
# (apps/web's /admin/messages, issue #81). The button is hidden and the
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
# personal access token with the write:issue scope — generate it in Forgejo,
# never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo

View file

@ -0,0 +1,23 @@
-- Contact-form messages (issue #80). New table only — nothing to backfill.
-- `author_id` is nullable with ON DELETE SET NULL so a report survives its
-- sender's account deletion (same as ingredients.created_by_id).
-- CreateTable
CREATE TABLE "contact_message" (
"id" SERIAL NOT NULL,
"author_id" INTEGER,
"type" TEXT NOT NULL,
"message" TEXT NOT NULL,
"page_url" TEXT,
"user_agent" TEXT,
"app_version" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "contact_message_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "contact_message_created_at_idx" ON "contact_message"("created_at");
-- AddForeignKey
ALTER TABLE "contact_message" ADD CONSTRAINT "contact_message_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,8 @@
-- Link a contact message to the Forgejo issue an admin created from it
-- (issue #81). Two nullable columns, no backfill — existing rows have no
-- linked issue.
-- AlterTable
ALTER TABLE "contact_message"
ADD COLUMN "forgejo_issue_number" INTEGER,
ADD COLUMN "forgejo_issue_url" TEXT;

View file

@ -0,0 +1,8 @@
-- Ingrédients d'assaisonnement « selon le goût » (issue #128, puce 1 de #3) :
-- une ligne de recette peut désormais n'avoir ni quantité ni unité. Purement
-- additif — toutes les lignes existantes gardent leurs valeurs, aucune n'est
-- réécrite.
-- AlterTable
ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL;
ALTER TABLE "recipe_ingredient" ALTER COLUMN "unit_id" DROP NOT NULL;

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 key String @unique
users UserProfile[] users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[] ingredients IngredientDiet[]
@@map("diet") @@map("diet")
@ -135,6 +134,11 @@ model UserProfile {
/// Placeholder `Ingredient` rows this profile created by typing a free-text /// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`. /// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator") createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
/// Placeholder `TechStep` rows this profile proposed while correcting a step
/// — see `TechStep.isPlaceholder`.
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[]
@@map("user_profiles") @@map("user_profiles")
} }
@ -373,7 +377,6 @@ model Recipe {
steps Step[] steps Step[]
planningItems PlanningItem[] planningItems PlanningItem[]
favoritedBy RecipeFavorite[] favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId]) @@unique([sourceId, externalId])
@@map("recipe") @@map("recipe")
@ -394,21 +397,6 @@ model RecipeFavorite {
@@map("recipe_favorite") @@map("recipe_favorite")
} }
/// Explicit join table for the recipe <-> diet "associated regime" tags
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
/// manual reminder set by whoever creates/edits the recipe, not computed
/// from its ingredients.
model RecipeDiet {
recipeId Int @map("recipe_id")
dietId Int @map("diet_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([recipeId, dietId])
@@map("recipe_diet")
}
/// `key` is `@unique` — not in the original spec doc, added so the seed /// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay /// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A /// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
@ -698,12 +686,17 @@ model Unit {
model RecipeIngredient { model RecipeIngredient {
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id") ingredientId Int @map("ingredient_id")
quantity Decimal @db.Decimal(10, 2) /// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
unitId Int @map("unit_id") /// salt, pepper… added to taste, with nothing to scale by portions or to
/// aggregate on a shopping list. The "both null or neither" invariant is
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
/// the schema — same posture as this file's other cross-field invariants.
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit @relation(fields: [unitId], references: [id]) unit Unit? @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId]) @@id([recipeId, ingredientId])
@@map("recipe_ingredient") @@map("recipe_ingredient")
@ -728,6 +721,33 @@ model TechStep {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
/// `true` = a "placeholder" technique: a free-text technique a user typed
/// while correcting a step, because nothing in the seeded catalog matched
/// (issue #131 for the ingredient precedent this mirrors — see
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
/// training data of its own, and is excluded from
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
/// like any other technique (the correction that created it applies
/// immediately), but a maintainer reviews it at
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// The exact text the user typed — `null` for a real catalog row (whose
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
/// `isPlaceholder`, enforced service-side.
displayName String? @map("display_name")
/// Profile that first proposed this technique — context for the admin
/// review. `onDelete: SetNull` so deleting an account never blocks on the
/// recipes still using its placeholder. `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was proposed. `null` for a real catalog row.
createdAt DateTime? @map("created_at")
/// Stamped once a maintainer has triaged this proposal
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
/// `null` while pending / for a real catalog row.
reviewedAt DateTime? @map("reviewed_at")
steps StepTechStep[] steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong) /// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`. /// match — see `StepTechStepCorrection.previousTechStepId`.
@ -738,7 +758,10 @@ model TechStep {
/// Training-corpus suggestions targeting this technique — see /// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`. /// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[] trainingSuggestions TechStepTrainingSuggestion[]
/// The profile that proposed this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("tech_step") @@map("tech_step")
} }
@ -994,7 +1017,7 @@ model StepTechStepCorrection {
/// ///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays /// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped /// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text /// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog /// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against. /// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion { model TechStepTrainingSuggestion {
@ -1189,3 +1212,47 @@ model TourProgress {
@@id([userProfileId, scenarioId]) @@id([userProfileId, scenarioId])
@@map("tour_progress") @@map("tour_progress")
} }
// -----------------------------------------------------------------------------
// Contact
// In-app "report a bug / suggestion" form — user-submitted, read-only for
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
// into a Forgejo issue).
// -----------------------------------------------------------------------------
/// A message sent via the app's contact form (bug, suggestion, other).
/// Written once by a signed-in user, never edited afterwards — consulted
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
/// even if the account that sent it is deleted in the meantime.
model ContactMessage {
id Int @id @default(autoincrement())
authorId Int? @map("author_id")
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
/// `CONTACT_MESSAGE_TYPES` const (same approach as
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
/// value is one line, no Postgres enum migration.
type String
message String
/// Technical context captured automatically at submit time, for triage —
/// all nullable (an older client, or a direct API call, can omit them).
pageUrl String? @map("page_url")
userAgent String? @map("user_agent")
appVersion String? @map("app_version")
createdAt DateTime @default(now()) @map("created_at")
/// Number of the Forgejo issue an admin created from this message via
/// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the
/// "Créer un ticket" button disappears (one message links to at most one
/// issue).
forgejoIssueNumber Int? @map("forgejo_issue_number")
/// The issue's html URL (`<base>/<owner>/<repo>/issues/<n>`) — stored
/// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays
/// correct even if that config changes later.
forgejoIssueUrl String? @map("forgejo_issue_url")
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@map("contact_message")
}

View file

@ -7,6 +7,7 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js"; import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js"; import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { contactRouter } from "./modules/contact/contact.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
@ -60,6 +61,10 @@ export function createServer(): ExpressServer {
// admin page path can ever collide with an admin API path again. // admin page path can ever collide with an admin API path again.
server.mountRouter("/admin/api", adminRouter); server.mountRouter("/admin/api", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// In-app "signaler un bug / suggestion" form (issue #80) — session
// required, one POST. Admins read these rows via `/admin/api/contact`,
// never this router.
server.mountRouter("/contact", contactRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by // Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own // `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers // doc comment), never `requireAuth`. Mounted alongside the other routers

View file

@ -13,12 +13,26 @@ import { z } from "zod";
// separate test database this now requires. // separate test database this now requires.
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
/**
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
* is `.dockerignore`d, so that passthrough is the container's only source
* of these. Fields that must *fail closed* on a blank value rather than run
* their own format check against `""` (and crash `parse()` at boot) wrap
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
* folds the same `"" -> undefined` step into its own `.transform`.
*/
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
/** /**
* Schema for every environment variable the API reads. Parsing (below) * Schema for every environment variable the API reads. Parsing (below)
* fails fast at startup if something required is missing/invalid, instead * fails fast at startup if something required is missing/invalid, instead
* of surfacing as a confusing runtime error later. * of surfacing as a confusing runtime error later. Exported so the parsing
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
* without the process-wide `process.env` the singleton below parses.
*/ */
const envSchema = z.object({ export const envSchema = z.object({
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */ /** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
/** Port the HTTP server listens on. */ /** Port the HTTP server listens on. */
@ -143,6 +157,26 @@ const envSchema = z.object({
ADMIN_INITIAL_EMAIL: z.string().optional(), ADMIN_INITIAL_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(), ADMIN_INITIAL_PASSWORD: z.string().optional(),
ADMIN_INITIAL_NAME: z.string().optional(), ADMIN_INITIAL_NAME: z.string().optional(),
/**
* Forgejo instance + credentials for "create an issue from a contact
* message" (`/admin/messages`, issue #81). All three `.optional()` and
* checked together by `ForgejoService.isConfigured()` — the feature
* **fails closed** when any is missing: the endpoint answers `503` and
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
* rest of the app never talks to Forgejo.
*/
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()),
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()),
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
FORGEJO_REPO: z.preprocess(
blankAsUnset,
z
.string()
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
.optional(),
),
}); });
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -0,0 +1,102 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
import { env } from "../../config/env.js";
import { logger } from "../logger.service.js";
/** The slice of Forgejo's `POST .../issues` response this integration reads. */
interface ForgejoIssueResponse {
number: number;
html_url: string;
}
/**
* The one place `apps/api` talks to Forgejo — creating an issue from a
* contact message (`/admin/messages`, issue #81). A class + singleton
* (`forgejo` below), same convention as `logger` / `analytics`.
*
* **Fail-closed**: every method is a no-op / typed error unless all three
* `FORGEJO_*` env vars are set (see `config/env.ts`). Callers check
* {@link isConfigured} first and answer `503` themselves; {@link createIssue}
* re-checks as a backstop.
*/
export class ForgejoService {
/** True once `FORGEJO_BASE_URL`, `FORGEJO_TOKEN` and `FORGEJO_REPO` are all set. */
public isConfigured(): boolean {
return (
env.FORGEJO_BASE_URL !== undefined &&
env.FORGEJO_TOKEN !== undefined &&
env.FORGEJO_REPO !== undefined
);
}
/**
* Creates an issue in `FORGEJO_REPO` with the given title/body (no labels
* — a deliberate #81 decision, the admin adds them in Forgejo). Returns
* the new issue's number and html URL.
*
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` if the env isn't set,
* `502 FORGEJO_REQUEST_FAILED` on a network error or any non-2xx / non-JSON
* response — the caller leaves the database untouched in that case.
*/
public async createIssue(input: {
title: string;
body: string;
}): Promise<{ number: number; htmlUrl: string }> {
if (!this.isConfigured()) {
throw new HttpError(
503,
ErrorCode.FORGEJO_NOT_CONFIGURED,
"Forgejo integration is not configured",
);
}
const url = `${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`;
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Forgejo/Gitea's PAT scheme — `token <pat>`, not `Bearer`.
Authorization: `token ${env.FORGEJO_TOKEN}`,
},
body: JSON.stringify({ title: input.title, body: input.body }),
});
} catch (err) {
logger.warn("Forgejo issue creation failed (network)", {
error: err instanceof Error ? err.message : String(err),
});
throw new HttpError(502, ErrorCode.FORGEJO_REQUEST_FAILED, "Forgejo request failed");
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
logger.warn("Forgejo issue creation failed", {
status: response.status,
detail: detail.slice(0, 500),
});
throw new HttpError(
502,
ErrorCode.FORGEJO_REQUEST_FAILED,
`Forgejo issues API responded ${response.status}`,
);
}
try {
const data = (await response.json()) as ForgejoIssueResponse;
return { number: data.number, htmlUrl: data.html_url };
} catch (err) {
logger.warn("Forgejo issue creation failed (bad response)", {
error: err instanceof Error ? err.message : String(err),
});
throw new HttpError(
502,
ErrorCode.FORGEJO_REQUEST_FAILED,
"Forgejo response was not valid JSON",
);
}
}
}
/** Single shared instance — stateless, same reasoning as `logger` / `analytics`. */
export const forgejo = new ForgejoService();

View file

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

View file

@ -0,0 +1,59 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createForgejoIssueSchema,
ErrorCode,
listContactMessagesQuerySchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
createForgejoIssueFromMessage,
getForgejoStatus,
listContactMessages,
} from "./admin-contact.service.js";
/**
* Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind
* {@link requireAdmin}. Backs the `/admin/messages` page: the read-only list
* of contact-form messages (issue #80), plus "create a Forgejo issue from
* this message" (issue #81). Still no message-mutation routes — the message
* itself stays read-only.
*/
export const adminContactRouter = Router();
adminContactRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query)));
}),
);
// Static path — declared before `/:id/...` so it's never parsed as an id.
adminContactRouter.get(
"/forgejo/status",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(getForgejoStatus());
}),
);
adminContactRouter.post(
"/:id/forgejo-issue",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a valid contact message id: ${req.params.id}`,
);
}
const input = createForgejoIssueSchema.parse(req.body);
res.status(200).json(await createForgejoIssueFromMessage(id, input));
}),
);

View file

@ -0,0 +1,133 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type ContactMessageAdminView,
type ContactMessageType,
type CreateForgejoIssueInput,
ErrorCode,
type ForgejoIntegrationStatus,
type ListContactMessagesQuery,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { forgejo } from "../../lib/forgejo/forgejo.service.js";
/** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */
const CONTACT_MESSAGES_PAGE_SIZE = 200;
const contactMessageInclude = {
author: { select: { id: true, firstName: true, lastName: true, email: true } },
} as const;
type ContactMessageRow = Awaited<
ReturnType<
typeof prisma.contactMessage.findFirstOrThrow<{ include: typeof contactMessageInclude }>
>
>;
/** Shapes one Prisma `ContactMessage` row (with {@link contactMessageInclude}) into its admin view. */
function toContactMessageAdminView(row: ContactMessageRow): ContactMessageAdminView {
return {
id: row.id,
// Safe cast: `POST /contact` validates `type` against
// `CONTACT_MESSAGE_TYPES` before insert, so the column only ever holds
// one of those values.
type: row.type as ContactMessageType,
message: row.message,
pageUrl: row.pageUrl,
userAgent: row.userAgent,
appVersion: row.appVersion,
createdAt: row.createdAt.toISOString(),
// `null` when the sender's account was deleted after sending
// (`authorId` is `onDelete: SetNull`).
author: row.author,
forgejoIssueNumber: row.forgejoIssueNumber,
forgejoIssueUrl: row.forgejoIssueUrl,
};
}
/**
* Every `ContactMessage` for the admin read view (`/admin/messages`, issue
* #80) — newest first, capped at {@link CONTACT_MESSAGES_PAGE_SIZE}, with an
* optional single-`type` filter. Read-only: this first version has no
* triage workflow (status / assignment / notes), so there's no update
* counterpart.
*/
export async function listContactMessages(
query: ListContactMessagesQuery,
): Promise<ContactMessageAdminView[]> {
try {
const rows = await prisma.contactMessage.findMany({
where: query.type ? { type: query.type } : {},
orderBy: { createdAt: "desc" },
take: CONTACT_MESSAGES_PAGE_SIZE,
include: contactMessageInclude,
});
return rows.map(toContactMessageAdminView);
} catch (err) {
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
throw err;
}
}
/** Whether the "create a Forgejo issue from a message" feature is usable (issue #81) — drives whether the admin UI shows the button. */
export function getForgejoStatus(): ForgejoIntegrationStatus {
return { enabled: forgejo.isConfigured() };
}
/**
* Creates a Forgejo issue from contact message `messageId` and records the
* link on the row (issue #81). The `title`/`body` come from the admin's
* pre-filled, edited modal.
*
* Order matters: every precondition is checked before Forgejo is called,
* and the row is only touched *after* Forgejo succeeds — a failed call
* leaves no partial state (no linked issue, button still shown).
*
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` (env not set),
* `404 CONTACT_MESSAGE_NOT_FOUND`, `409 CONTACT_MESSAGE_ALREADY_LINKED`
* (already has an issue), or `502 FORGEJO_REQUEST_FAILED` (the Forgejo
* call failed — DB untouched).
*/
export async function createForgejoIssueFromMessage(
messageId: number,
input: CreateForgejoIssueInput,
): Promise<ContactMessageAdminView> {
try {
if (!forgejo.isConfigured()) {
throw new HttpError(
503,
ErrorCode.FORGEJO_NOT_CONFIGURED,
"Forgejo integration is not configured",
);
}
const existing = await prisma.contactMessage.findUnique({ where: { id: messageId } });
if (!existing) {
throw new HttpError(
404,
ErrorCode.CONTACT_MESSAGE_NOT_FOUND,
`Contact message ${messageId} not found`,
);
}
if (existing.forgejoIssueNumber !== null) {
throw new HttpError(
409,
ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED,
`Contact message ${messageId} already links to Forgejo issue #${existing.forgejoIssueNumber}`,
);
}
// Throws `502 FORGEJO_REQUEST_FAILED` on any failure — propagated below,
// before the row is touched.
const issue = await forgejo.createIssue({ title: input.title, body: input.body });
const updated = await prisma.contactMessage.update({
where: { id: messageId },
data: { forgejoIssueNumber: issue.number, forgejoIssueUrl: issue.htmlUrl },
include: contactMessageInclude,
});
return toContactMessageAdminView(updated);
} catch (err) {
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
throw err;
}
}

View file

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

@ -1,9 +1,11 @@
import { Router } from "express"; import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js"; import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js"; import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminContactRouter } from "./admin-contact.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
import { adminToursRouter } from "./admin-tours.routes.js"; import { adminToursRouter } from "./admin-tours.routes.js";
import { adminUsersRouter } from "./admin-users.routes.js";
/** /**
* Aggregator for the admin application's API surface, mounted at * Aggregator for the admin application's API surface, mounted at
@ -26,3 +28,5 @@ adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter); adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

View file

@ -0,0 +1,34 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { submitContactMessageSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { submitContactMessage } from "./contact.service.js";
/**
* Router mounted at `/contact` in app.ts (issue #80). A single endpoint —
* `POST /` — for the in-app "signaler un bug / suggestion" form. Requires a
* session: only signed-in users can send a message, so every report is
* attributable (see `ContactMessage.authorId`). The admin-side read of
* these rows lives entirely in the admin router, not here.
*/
export const contactRouter = Router();
contactRouter.post(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = submitContactMessageSchema.parse(req.body);
// Read the UA from the request, never from the body — the schema omits
// it on purpose so it can't be spoofed. Header can be an array or
// absent; normalize to `string | null`.
const rawUserAgent = req.headers["user-agent"];
const userAgent =
typeof rawUserAgent === "string" && rawUserAgent.length > 0 ? rawUserAgent : null;
await submitContactMessage(res.locals.userProfile.id, input, userAgent);
// 204, not 201-with-a-body: the form has nothing to do with the stored
// row, and the shared `ApiClient._request` treats only 204 as bodyless
// (a 201 with an empty body makes its `response.json()` throw). Same
// "mutation returns nothing" shape as `POST /auth/logout`.
res.status(204).end();
}),
);

View file

@ -0,0 +1,50 @@
import type { SubmitContactMessageInput } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
/**
* Persists one contact-form submission (issue #80). Write-only from the
* user's side — there is no read/update/delete counterpart here: admins
* consult these rows through the admin app (`admin-contact.service.ts`, PR
* 2), never this module.
*
* `userAgent` comes straight from the request header, never the body — the
* shared `submitContactMessageSchema` deliberately omits it so the client
* can't spoof it. `pageUrl` / `appVersion` are client-supplied context and
* default to `null` when the (older, or non-web) client didn't send them.
*
* Returns nothing: the form only needs to know the write succeeded (a
* `201`), not the stored row.
*/
export async function submitContactMessage(
authorId: number,
input: SubmitContactMessageInput,
userAgent: string | null,
): Promise<void> {
try {
await prisma.contactMessage.create({
data: {
authorId,
type: input.type,
message: input.message,
pageUrl: input.pageUrl ?? null,
userAgent,
appVersion: input.appVersion ?? null,
},
});
// Fire-and-forget (see AnalyticsService's contract) — `admin-metrics`
// aggregates `AnalyticsEvent` by `type` generically, so this shows up in
// the dashboard series with no extra code; `context.type` lets it be
// broken down by bug/suggestion/other.
analytics.recordEvent("contact.submitted", {
actorType: "user",
actorId: authorId,
context: { type: input.type },
});
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler` / the error middleware (which
// already logs it) handles it; this service layer just isn't allowed a
// bare `await` per the repo's async/try-catch convention.
throw err;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -49,6 +49,7 @@ export async function resetDatabase() {
"recipe", "ingredients", "sources", "unit", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house", "user_profiles", "diet", "house",
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target", "tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
"contact_message",
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state" "admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);

View file

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

View file

@ -0,0 +1,346 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** Creates a `UserProfile` to attribute contact messages to. */
async function seedUser() {
return prisma.userProfile.create({
data: {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
}
/** One `ContactMessage`, `createdAt` overridable so ordering is deterministic. */
async function seedMessage(overrides: {
authorId: number | null;
type?: string;
message?: string;
createdAt?: Date;
}) {
return prisma.contactMessage.create({
data: {
authorId: overrides.authorId,
type: overrides.type ?? "bug",
message: overrides.message ?? faker.lorem.sentence(),
pageUrl: "/",
userAgent: "test-agent/1.0",
appVersion: "0.2.1",
...(overrides.createdAt ? { createdAt: overrides.createdAt } : {}),
},
});
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
const forgejoConfigured =
env.FORGEJO_BASE_URL !== undefined &&
env.FORGEJO_TOKEN !== undefined &&
env.FORGEJO_REPO !== undefined;
describe("Admin contact messages", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects the route without an admin session", async () => {
const res = await request(app).get("/admin/api/contact");
expect(res.status).to.equal(401);
});
it("returns messages newest first, with the author resolved", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await seedMessage({
authorId: user.id,
message: "le plus ancien",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
});
await seedMessage({
authorId: user.id,
message: "le plus récent",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
});
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact");
expect(res.status).to.equal(200);
expect(res.body.map((row: { message: string }) => row.message)).to.deep.equal([
"le plus récent",
"le plus ancien",
]);
expect(res.body[0].author).to.deep.equal({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
});
});
it("filters by type", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await seedMessage({ authorId: user.id, type: "bug" });
await seedMessage({ authorId: user.id, type: "suggestion" });
await seedMessage({ authorId: user.id, type: "suggestion" });
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact").query({ type: "suggestion" });
expect(res.status).to.equal(200);
expect(res.body).to.have.lengthOf(2);
expect(res.body.every((row: { type: string }) => row.type === "suggestion")).to.equal(true);
});
it("rejects an unknown type filter with 400", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact").query({ type: "complaint" });
expect(res.status).to.equal(400);
});
it("caps the page at 200 rows", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await prisma.contactMessage.createMany({
data: Array.from({ length: 205 }, () => ({
authorId: user.id,
type: "bug",
message: faker.lorem.sentence(),
})),
});
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact");
expect(res.status).to.equal(200);
expect(res.body).to.have.lengthOf(200);
});
it("reports author: null once the sender's account is deleted", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await seedMessage({ authorId: user.id, message: "signalé puis compte supprimé" });
await prisma.userProfile.delete({ where: { id: user.id } });
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact");
expect(res.status).to.equal(200);
expect(res.body).to.have.lengthOf(1);
expect(res.body[0].message).to.equal("signalé puis compte supprimé");
expect(res.body[0].author).to.equal(null);
});
describe("Forgejo issue creation (#81)", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
/** Makes `forgejo.createIssue`'s `fetch` return `body` with `status`. */
function stubForgejoFetch(body: unknown, status = 201) {
globalThis.fetch = (async () =>
new Response(JSON.stringify(body), { status })) as typeof fetch;
}
it("returns 401 without an admin session", async () => {
const res = await request(app).post("/admin/api/contact/1/forgejo-issue").send({
title: "t",
body: "b",
});
expect(res.status).to.equal(401);
});
it("exposes the integration status", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact/forgejo/status");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ enabled: forgejoConfigured });
});
it("responds 503 FORGEJO_NOT_CONFIGURED when the env isn't set", async function () {
if (!adminSecretConfigured || forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "Titre", body: "Corps du ticket" });
expect(res.status).to.equal(503);
expect(res.body.code).to.equal(ErrorCode.FORGEJO_NOT_CONFIGURED);
});
it("creates the issue and records the link on the message", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch({ number: 77, html_url: "https://forge.example/ci/repo/issues/77" });
const user = await seedUser();
const message = await seedMessage({ authorId: user.id, message: "un bug précis" });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "[Bug] un bug précis", body: "un bug précis\n\n---\ncontexte" });
expect(res.status).to.equal(200);
expect(res.body.forgejoIssueNumber).to.equal(77);
expect(res.body.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
expect(row.forgejoIssueNumber).to.equal(77);
expect(row.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
});
it("responds 404 for an unknown message id", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch({ number: 1, html_url: "x" });
const agent = await adminAgent();
const res = await agent
.post("/admin/api/contact/999999/forgejo-issue")
.send({ title: "Titre", body: "Corps" });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_NOT_FOUND);
});
it("responds 409 when the message already links to an issue", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch({ number: 2, html_url: "x" });
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
await prisma.contactMessage.update({
where: { id: message.id },
data: { forgejoIssueNumber: 5, forgejoIssueUrl: "https://forge.example/ci/repo/issues/5" },
});
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "Titre", body: "Corps" });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED);
});
it("leaves the message untouched when the Forgejo call fails (502)", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch("upstream boom", 500);
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "Titre", body: "Corps" });
expect(res.status).to.equal(502);
expect(res.body.code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
expect(row.forgejoIssueNumber).to.equal(null);
expect(row.forgejoIssueUrl).to.equal(null);
});
it("rejects an empty title with 400", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: " ", body: "Corps" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
});

View file

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

View file

@ -0,0 +1,143 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** A valid `POST /contact` body — message comfortably over the 10-char minimum. */
function buildContactPayload() {
return {
type: "bug" as const,
message: "Le bouton « Envoyer » ne réagit pas sur la page planning.",
pageUrl: "/",
appVersion: "0.2.1",
};
}
describe("Contact", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /contact", () => {
it("rejects requests without a session with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).post("/contact").send(buildContactPayload());
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("stores the message with its fields, attributed to the caller, and captures the user-agent from the header", async () => {
const agent = request.agent(app);
const signup = buildSignupPayload();
await agent.post("/auth/signup").send(signup);
const author = await prisma.userProfile.findFirstOrThrow({ where: { email: signup.email } });
const res = await agent
.post("/contact")
.set("User-Agent", "BatchCookingApp/0.2.1 (Android 14)")
.send(buildContactPayload());
expect(res.status).to.equal(204);
const rows = await prisma.contactMessage.findMany();
expect(rows).to.have.lengthOf(1);
const [row] = rows;
expect(row.authorId).to.equal(author.id);
expect(row.type).to.equal("bug");
expect(row.message).to.equal(buildContactPayload().message);
expect(row.pageUrl).to.equal("/");
expect(row.appVersion).to.equal("0.2.1");
// Read from the request header, never the body.
expect(row.userAgent).to.equal("BatchCookingApp/0.2.1 (Android 14)");
});
it("ignores any user-agent sent in the body — the header is the only source", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent
.post("/contact")
.set("User-Agent", "real-agent/1.0")
.send({ ...buildContactPayload(), userAgent: "spoofed-agent/9.9" });
expect(res.status).to.equal(204);
const row = await prisma.contactMessage.findFirstOrThrow();
expect(row.userAgent).to.equal("real-agent/1.0");
});
it("keeps pageUrl / appVersion null when the client omits them", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/contact").send({
type: "suggestion",
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
});
expect(res.status).to.equal(204);
const row = await prisma.contactMessage.findFirstOrThrow();
expect(row.type).to.equal("suggestion");
expect(row.pageUrl).to.equal(null);
expect(row.appVersion).to.equal(null);
});
it("rejects a message shorter than 10 characters with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
// 5 chars — and the schema trims before counting, so leading/trailing
// whitespace can't pad it to the minimum.
const res = await agent.post("/contact").send({ type: "bug", message: " court " });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("rejects a message longer than 5000 characters with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/contact").send({ type: "bug", message: "a".repeat(5001) });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("rejects an unknown type with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent
.post("/contact")
.send({ type: "complaint", message: "Ceci est un message de test suffisamment long." });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
});
});

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,113 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
import { expect } from "chai";
import { env } from "../src/config/env.js";
import { forgejo } from "../src/lib/forgejo/forgejo.service.js";
/**
* Stubs `globalThis.fetch` for one test — same approach as
* `recipe-sources/the-meal-db.test.ts` (no HTTP-mock library in this repo).
* `capture` collects the `(url, init)` each call was made with, so a test
* can assert the request shape. Restored by `afterEach`.
*/
function stubFetch(
responder: (url: string, init: RequestInit | undefined) => Response | Promise<Response>,
capture: { url: string; init: RequestInit | undefined }[],
) {
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
capture.push({ url, init });
return responder(url, init);
}) as typeof fetch;
}
const configured = forgejo.isConfigured();
describe("ForgejoService", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("isConfigured() reports whether all three FORGEJO_* vars are set", () => {
expect(forgejo.isConfigured()).to.equal(
env.FORGEJO_BASE_URL !== undefined &&
env.FORGEJO_TOKEN !== undefined &&
env.FORGEJO_REPO !== undefined,
);
});
describe("createIssue", () => {
it("POSTs to the repo's issues endpoint with a `token` auth header and { title, body }", async function () {
if (!configured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const calls: { url: string; init: RequestInit | undefined }[] = [];
stubFetch(
() =>
new Response(JSON.stringify({ number: 42, html_url: "https://forge/x/y/issues/42" }), {
status: 201,
}),
calls,
);
const result = await forgejo.createIssue({ title: "Un titre", body: "Un corps" });
expect(result).to.deep.equal({ number: 42, htmlUrl: "https://forge/x/y/issues/42" });
expect(calls).to.have.lengthOf(1);
const [call] = calls;
expect(call.url).to.equal(`${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`);
expect(call.init?.method).to.equal("POST");
const headers = call.init?.headers as Record<string, string>;
expect(headers.Authorization).to.equal(`token ${env.FORGEJO_TOKEN}`);
expect(JSON.parse(String(call.init?.body))).to.deep.equal({
title: "Un titre",
body: "Un corps",
});
});
it("throws 502 FORGEJO_REQUEST_FAILED on a non-2xx response", async function () {
if (!configured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubFetch(() => new Response("nope", { status: 403 }), []);
try {
await forgejo.createIssue({ title: "t", body: "b" });
expect.fail("expected createIssue to throw");
} catch (err) {
expect(err).to.be.instanceOf(HttpError);
expect((err as HttpError).status).to.equal(502);
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
}
});
it("throws 502 FORGEJO_REQUEST_FAILED on a network error", async function () {
if (!configured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubFetch(() => {
throw new Error("ECONNREFUSED");
}, []);
try {
await forgejo.createIssue({ title: "t", body: "b" });
expect.fail("expected createIssue to throw");
} catch (err) {
expect(err).to.be.instanceOf(HttpError);
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
}
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1,16 +1,14 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see // Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API // .github/workflows/ci.yml).
// behavior against a real database.
// //
// Cypress always runs as a plain web browser — `Capacitor.isNativePlatform()` // Issue #107 — "Paramètres d'application" is packaged-app only now. Its
// is never true here, so only this page's web-side content (the build // content (installed version + update check) is inert on the web, and the
// version + the "stay on web" preference) is reachable from this spec. The // one web-relevant control it carried (the "stay on web" redirect toggle)
// native-only half (App.getInfo()/checkNow's actual version-comparison // moved to `/telecharger` (see download-page.cy.ts). Cypress always runs as
// network call) has no seam to fake native from a web run — same // a plain web browser (`Capacitor.isNativePlatform()` is never true here),
// untested-on-purpose posture already accepted for `useAppUpdateCheck`'s // so the page itself has no seam to reach — same untested-on-purpose
// background check and `UpdateBanner` (neither has e2e coverage either); // posture already accepted for `useAppUpdateCheck` / `UpdateBanner`. This
// the underlying comparison logic itself is unit-tested in // spec now only covers the web-side gating.
// version-compare.cy.tsx, unchanged by this ticket.
const authenticatedProfile = { const authenticatedProfile = {
id: 1, id: 1,
@ -22,47 +20,21 @@ const authenticatedProfile = {
dietId: null, dietId: null,
}; };
const STAY_ON_WEB_KEY = "batchcooking:stayOnWeb"; describe("App settings — hidden on the web (#107)", () => {
describe("App settings (/parametres/application)", () => {
beforeEach(() => { beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
}); });
it("shows the web build version, and the update-check section only on native", () => { it("is absent from the Paramètres menu", () => {
cy.visit("/");
cy.contains("button", "Paramètres").click();
cy.contains("a", "Paramètres d'application").should("not.exist");
});
it("redirects /parametres/application to /parametres/compte instead of an orphaned page", () => {
cy.visit("/parametres/application"); cy.visit("/parametres/application");
cy.url().should("include", "/parametres/compte");
cy.contains("h1", "Paramètres d'application").should("be.visible"); cy.contains("h1", "Paramètres d'application").should("not.exist");
// The exact build number isn't asserted (would couple this test to
// package.json's version, bumped independently of this page) — just
// that a "Version X" line rendered at all, proving `useInstalledVersion`
// resolved something rather than being stuck empty.
cy.contains(".app-settings__version", "Version").should("be.visible");
cy.contains("button", "Vérifier les mises à jour").should("not.exist");
cy.contains(
"La vérification des mises à jour n'est disponible que dans l'application mobile",
).should("be.visible");
});
it("defaults 'stay on web' to unchecked, and persists a toggle across a reload", () => {
cy.visit("/parametres/application");
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("not.be.checked");
cy.contains("label", "Toujours rester sur le site web").click();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
cy.window().then((win) => {
expect(win.localStorage.getItem(STAY_ON_WEB_KEY)).to.equal("true");
});
cy.reload();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
}); });
}); });

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

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

View file

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

View file

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

View file

@ -23,4 +23,29 @@ describe("DownloadPage — the mobile-app-redirect fallback", () => {
cy.visit("/telecharger"); cy.visit("/telecharger");
cy.url().should("include", "/telecharger"); cy.url().should("include", "/telecharger");
}); });
// Issue #107 — the "toujours rester sur le site web" preference moved
// here from "Paramètres d'application" (packaged-app only now). Written to
// `localStorage` on the public web origin, which is where
// `attemptOpenNativeApp` reads it.
it("defaults 'stay on web' to unchecked, and persists a toggle across a reload", () => {
cy.visit("/telecharger");
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("not.be.checked");
cy.contains("label", "Toujours rester sur le site web").click();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
cy.window().then((win) => {
expect(win.localStorage.getItem("batchcooking:stayOnWeb")).to.equal("true");
});
cy.reload();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
});
}); });

View file

@ -186,7 +186,7 @@ describe("Responsive breakpoint — the sidebar becomes a bottom tab bar under 6
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
}); });
it("renders the bottom nav instead of the sidebar, with every route tab legible and a working Plus tab", () => { it("renders the bottom nav instead of the sidebar, icon-only, with a working Plus tab", () => {
cy.setViewport("phone"); cy.setViewport("phone");
cy.visit("/"); cy.visit("/");
@ -202,11 +202,15 @@ describe("Responsive breakpoint — the sidebar becomes a bottom tab bar under 6
expect(getComputedStyle($nav[0]).position).to.equal("fixed"); expect(getComputedStyle($nav[0]).position).to.equal("fixed");
}); });
// Every route tab keeps its icon *and* label legible — this bar never // Issue #105 — icon-only bar: each tab's `span.label` stays in the DOM
// falls back to icon-only or horizontal scroll, unlike the old // (accessible name for the link) but is visually hidden, only the icon
// squashed sidebar treatment it replaces (only 4 short tabs, always // shows. Same treatment as the desktop collapsed rail (sidebar.cy.ts).
// fits). cy.contains(".app-bottom-nav a", "Planning")
cy.contains(".app-bottom-nav a", "Planning").find("span.label").should("be.visible"); .should("have.attr", "href", "/")
.within(() => {
cy.get("span.label").should("exist").and("not.be.visible");
cy.get("svg").should("be.visible");
});
cy.contains(".app-bottom-nav a", "Recettes").should("have.attr", "href", "/recettes"); cy.contains(".app-bottom-nav a", "Recettes").should("have.attr", "href", "/recettes");
cy.contains(".app-bottom-nav a", "Liste de courses").should( cy.contains(".app-bottom-nav a", "Liste de courses").should(
"have.attr", "have.attr",
@ -283,6 +287,13 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
// *that* switch; the day view's own day-by-day content is // *that* switch; the day view's own day-by-day content is
// PlanningPage.tsx's own spec (planning-page.cy.ts). // PlanningPage.tsx's own spec (planning-page.cy.ts).
it("shows the one-day mobile view instead of the horizontally-scrolling grid, on a phone", () => { it("shows the one-day mobile view instead of the horizontally-scrolling grid, on a phone", () => {
// `PlanningDayView` opens on *today's* weekday within the shown week
// (`defaultDayIndexForWeek`, derived from `Date`). Freeze "today" to a
// Monday so it opens on "lundi" — where the mocked recipe below is —
// instead of drifting with the real date (this test used to pass only
// when CI happened to run on a Monday, UTC). `["Date"]` only, so
// timers/debounces still run.
cy.clock(new Date("2026-08-17T09:00:00.000Z"), ["Date"]);
cy.intercept("GET", /\/planning\?/, { cy.intercept("GET", /\/planning\?/, {
statusCode: 200, statusCode: 200,
body: { body: {

View file

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

View file

@ -182,6 +182,49 @@ describe("Planning grid", () => {
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible"); cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
}); });
// Issue #110 — a slot with many recipes used to stretch its `<td>`, and
// with it the whole row and the table, past `.planning-grid-wrapper`
// into a vertical scroll (the other cells of that row just gaining dead
// space). The chip list now scrolls inside its own cell instead.
it("keeps a heavy slot's recipes scrolling inside the cell, not stretching the grid", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: Array.from({ length: 14 }, (_, i) => ({
id: i + 1,
weekDay: "lundi",
meal: "dejeuner",
portions: 2,
recipe: { id: i + 1, name: `Recette ${i + 1}` },
})),
},
});
cy.visit("/");
cy.get(".recipe-chip").should("have.length", 14);
// The busy cell's own recipe list is now the scroller: it's capped and
// scrolls its own overflow instead of stretching the `<td>` (and with
// it the whole row, then the table past `.planning-grid-wrapper` into a
// tall vertical scroll).
cy.get(".meal-cell__recipes").should(($lists) => {
const list = [...$lists].find(
(el) => el.querySelectorAll(".recipe-chip").length === 14,
) as HTMLElement;
expect(list, "the 14-recipe cell's list").to.exist;
expect(getComputedStyle(list).overflowY).to.be.oneOf(["auto", "scroll"]);
expect(list.scrollHeight).to.be.greaterThan(list.clientHeight);
expect(list.clientHeight).to.be.lessThan(200);
});
cy.get(".planning-grid-wrapper").should(($w) => {
expect($w[0].scrollHeight).to.be.lessThan($w[0].clientHeight * 1.25);
});
});
it("jumps to an arbitrary week by picking a day in the calendar popover", () => { it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning"); cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,7 +41,7 @@ describe("Shopping list page (phone)", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
}); });
it("gives each row a taller, more legible padding than on desktop, with no horizontal body scroll", () => { it("gives each row a taller, more legible tap target than on desktop, with no horizontal body scroll", () => {
cy.intercept("GET", /\/shopping-list\?/, { cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200, statusCode: 200,
body: { body: {
@ -54,10 +54,12 @@ describe("Shopping list page (phone)", () => {
cy.visit("/liste-de-courses"); cy.visit("/liste-de-courses");
cy.wait("@getList"); cy.wait("@getList");
cy.get(".shopping-list__item").should(($el) => { // The interactive row is the `CheckboxOption` label (issue #109), not
// --space-md (16px) on phone vs. the desktop default --space-sm // the `<li>` wrapper. --space-md (16px) padding on phone vs. the desktop
// (8px) — see shopping-list-page.scss's phone block. // default --space-sm (8px), plus a ≥44px min-height tap target.
cy.get(".shopping-list__item-check").should(($el) => {
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px"); expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
expect($el[0].getBoundingClientRect().height).to.be.gte(44);
}); });
cy.document() cy.document()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import { Capacitor } from "@capacitor/core";
import { Navigate, Outlet, Route, Routes } from "react-router-dom"; import { Navigate, Outlet, Route, Routes } from "react-router-dom";
import { AdminAuthProvider } from "./features/admin/AdminAuthContext"; import { AdminAuthProvider } from "./features/admin/AdminAuthContext";
import { RequireAdmin } from "./features/admin/RequireAdmin"; import { RequireAdmin } from "./features/admin/RequireAdmin";
import { UpdateBanner } from "./features/app-update/UpdateBanner";
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
import { RequireAuth } from "./features/auth/RequireAuth"; import { RequireAuth } from "./features/auth/RequireAuth";
import { AdminLayout } from "./layouts/AdminLayout"; import { AdminLayout } from "./layouts/AdminLayout";
@ -9,8 +11,10 @@ import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPa
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage"; import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage"; import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage"; 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 { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage"; import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
import { UsersPage as AdminUsersPage } from "./pages/admin/users/UsersPage";
import { LoginPage } from "./pages/auth/LoginPage"; import { LoginPage } from "./pages/auth/LoginPage";
import { SignupPage } from "./pages/auth/SignupPage"; import { SignupPage } from "./pages/auth/SignupPage";
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
@ -25,6 +29,7 @@ import { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
import { RecipesPage } from "./pages/recipes/RecipesPage"; import { RecipesPage } from "./pages/recipes/RecipesPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { AppSettingsPage } from "./pages/settings/AppSettingsPage"; import { AppSettingsPage } from "./pages/settings/AppSettingsPage";
import { ContactPage } from "./pages/settings/ContactPage";
import { CreditsPage } from "./pages/settings/CreditsPage"; import { CreditsPage } from "./pages/settings/CreditsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
import { PreferencesPage } from "./pages/settings/PreferencesPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage";
@ -47,14 +52,16 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps * just redirects to `/parametres/foyer` so an existing bookmark/link keeps
* working. * working.
* *
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also * `/onboarding/*` (regime/allergenes/foyer/sources, in that order) is also
* `RequireAuth`-gated — reached right after signup, once a session already * `RequireAuth`-gated — reached right after signup, once a session already
* exists — but deliberately its own top-level route group, *not* nested * exists — but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar, * under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`). * same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
* `/onboarding/sources` is conditional — only reached when the `foyer` step * `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources`
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`); * is conditional — only reached when the `foyer` step *created* a household
* skipped otherwise, straight to `/onboarding/allergenes`. * (see `OnboardingHouseholdPage`'s `goToNextStep`), every other path ending
* the wizard straight to `/`. Step order and the "sur 3" / "4 sur 4"
* indicators live in `pages/onboarding/onboarding-steps.ts`.
* *
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot * `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like * (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
@ -71,9 +78,17 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* never nested under `RequireAuth`. `/admin/login` is the only * never nested under `RequireAuth`. `/admin/login` is the only
* unauthenticated admin route; everything else sits under `RequireAdmin` + * unauthenticated admin route; everything else sits under `RequireAdmin` +
* `AdminLayout`. * `AdminLayout`.
*
* {@link UpdateBanner} is mounted here, outside `<Routes>`, rather than in
* `AppLayout` — the native app-update check (issue #66) must run on every
* app start, session or not, and `AppLayout` only mounts once a visitor is
* past `RequireAuth`. It's a `position: fixed` strip and renders nothing on
* the web, so mounting it app-wide costs the routed pages no layout.
*/ */
export function App() { export function App() {
return ( return (
<>
<UpdateBanner />
<Routes> <Routes>
<Route <Route
element={ element={
@ -99,7 +114,22 @@ export function App() {
<Route path="/parametres/preferences" element={<PreferencesPage />} /> <Route path="/parametres/preferences" element={<PreferencesPage />} />
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} /> <Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} /> <Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
<Route path="/parametres/application" element={<AppSettingsPage />} /> {/* Native app only (issue #107): on the web its sections are inert
(no "installed version", the update check is a no-op) or have
moved to `/telecharger` (the stay-on-web toggle), and the nav
entry is hidden — so a stray bookmark redirects rather than
landing on an orphaned page (same pattern as `/foyer` below). */}
<Route
path="/parametres/application"
element={
Capacitor.isNativePlatform() ? (
<AppSettingsPage />
) : (
<Navigate to="/parametres/compte" replace />
)
}
/>
<Route path="/parametres/contact" element={<ContactPage />} />
<Route path="/parametres/credits" element={<CreditsPage />} /> <Route path="/parametres/credits" element={<CreditsPage />} />
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} /> <Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route> </Route>
@ -111,6 +141,14 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/onboarding/foyer" path="/onboarding/foyer"
element={ element={
@ -127,14 +165,6 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/login" path="/login"
element={ element={
@ -169,13 +199,16 @@ export function App() {
} }
> >
<Route index element={<AdminDashboardPage />} /> <Route index element={<AdminDashboardPage />} />
<Route path="utilisateurs" element={<AdminUsersPage />} />
<Route path="monitoring" element={<AdminMonitoringPage />} /> <Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} /> <Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} /> <Route path="catalogue" element={<AdminCatalogPage />} />
<Route path="didacticiels" element={<AdminToursPage />} /> <Route path="didacticiels" element={<AdminToursPage />} />
<Route path="messages" element={<AdminMessagesPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</>
); );
} }

View file

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

View file

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

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

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

View file

@ -1,6 +1,15 @@
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared"; import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; import {
import { apiClient } from "../../api/client"; createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useNavigate } from "react-router-dom";
import { apiClient, setSessionExpiredHandler } from "../../api/client";
/** Shape of the auth state/actions exposed via {@link useAuth}. */ /** Shape of the auth state/actions exposed via {@link useAuth}. */
interface AuthContextValue { interface AuthContextValue {
@ -37,6 +46,12 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SafeUserProfile | null>(null); const [user, setUser] = useState<SafeUserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const navigate = useNavigate();
// Read by the session-expiry handler below without making it a dependency
// (it registers once): a stray call while already logged out is a no-op.
const userRef = useRef(user);
userRef.current = user;
useEffect(() => { useEffect(() => {
apiClient apiClient
@ -48,6 +63,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, []); }, []);
useEffect(() => {
// `ApiClient` is framework-agnostic (see its own doc comment) so it
// can't navigate or touch React state — it just calls this when a live
// session 401s mid-use (the 7-day JWT expired, say). Without it an
// expired session only took effect on the next full reload; any other
// request just surfaced a local error while the authenticated UI stayed
// put (issue #108). The token store is already cleared client-side by
// the time this runs.
setSessionExpiredHandler(() => {
if (userRef.current === null) return;
setUser(null);
void navigate("/login", { replace: true });
});
return () => setSessionExpiredHandler(null);
}, [navigate]);
const signup = useCallback(async (input: SignupInput) => { const signup = useCallback(async (input: SignupInput) => {
try { try {
setUser(await apiClient.signup(input)); setUser(await apiClient.signup(input));

View file

@ -61,7 +61,11 @@
} }
// Submit button: full-width, visually separated from the fields above it. // Submit button: full-width, visually separated from the fields above it.
button { // Scoped to `[type="submit"]` specifically — the card also contains the
// PasswordInput's `type="button"` eye toggle, which must NOT inherit this
// full-width primary-coloured treatment (it styles itself in
// components/ui/password-input.scss).
button[type="submit"] {
margin-top: var(--space-md); margin-top: var(--space-md);
padding: 0.6rem; padding: 0.6rem;
font-family: var(--font-body); font-family: var(--font-body);

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

View file

@ -1,5 +1,6 @@
import type { IngredientView, UnitView } from "@batch-cooking/shared"; import type { IngredientView, UnitView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckboxOption } from "../../../components/ui/Checkbox";
import { AllergenBadges } from "../badges/AllergenBadges"; import { AllergenBadges } from "../badges/AllergenBadges";
import { DietBadges } from "../badges/DietBadges"; import { DietBadges } from "../badges/DietBadges";
import { ReproducibleBadge } from "../badges/ReproducibleBadge"; import { ReproducibleBadge } from "../badges/ReproducibleBadge";
@ -7,25 +8,30 @@ import { IngredientTypeIcon } from "./ingredient-icons";
import { ingredientLabel } from "./ingredient-label"; import { ingredientLabel } from "./ingredient-label";
import "../recipes.scss"; import "../recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */ /** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. A line marked `toTaste` ("selon le goût") carries no quantity/unit at all — see {@link toTaste}. */
export function IngredientRow({ export function IngredientRow({
ingredient, ingredient,
quantity, quantity,
unitId, unitId,
unitsCatalog, unitsCatalog,
toTaste,
duplicate = false, duplicate = false,
onQuantityChange, onQuantityChange,
onUnitChange, onUnitChange,
onToTasteChange,
onRemove, onRemove,
}: { }: {
ingredient: IngredientView; ingredient: IngredientView;
quantity: string; quantity: string;
unitId: number | null; unitId: number | null;
unitsCatalog: UnitView[]; unitsCatalog: UnitView[];
/** `true` = a "selon le goût" seasoning line (salt, pepper…): the quantity/unit controls are hidden and the line submits with neither, stored as `null`/`null` on `RecipeIngredient` (schema.prisma). Never `--incomplete` for a missing unit while this is set. */
toTaste: boolean;
/** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */ /** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */
duplicate?: boolean; duplicate?: boolean;
onQuantityChange: (quantity: string) => void; onQuantityChange: (quantity: string) => void;
onUnitChange: (unitId: number) => void; onUnitChange: (unitId: number) => void;
onToTasteChange: (toTaste: boolean) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -34,8 +40,9 @@ export function IngredientRow({
// (see issue #53: an import pre-filled from a source can land here with // (see issue #53: an import pre-filled from a source can land here with
// an ingredient resolved but no unit — e.g. a bare count like "4 Egg // an ingredient resolved but no unit — e.g. a bare count like "4 Egg
// Yolks" — and nothing used to tell the user which line was blocking // Yolks" — and nothing used to tell the user which line was blocking
// them, or why). // them, or why). A "selon le goût" line has no unit *by design*, so it's
const unitMissing = unitId === null; // never treated as incomplete.
const unitMissing = !toTaste && unitId === null;
const needsAttention = unitMissing || duplicate; const needsAttention = unitMissing || duplicate;
// A free-text placeholder line (the catalog had nothing matching) — shown // A free-text placeholder line (the catalog had nothing matching) — shown
// with an "à compléter" badge and none of the allergen/diet/reproducible // with an "à compléter" badge and none of the allergen/diet/reproducible
@ -56,6 +63,8 @@ export function IngredientRow({
</span> </span>
)} )}
</span> </span>
{!toTaste && (
<>
<input <input
type="number" type="number"
min="0" min="0"
@ -81,6 +90,18 @@ export function IngredientRow({
</option> </option>
))} ))}
</select> </select>
</>
)}
{/* "Selon le goût" — a seasoning line with nothing to measure. Toggling
it on hides the quantity/unit controls above and submits the line
with neither. */}
<CheckboxOption
className="ingredient-row__to-taste"
checked={toTaste}
onChange={onToTasteChange}
>
{t("recipes.form.toTasteToggle")}
</CheckboxOption>
{!isPlaceholder && ( {!isPlaceholder && (
<> <>
<AllergenBadges allergens={ingredient.allergens} /> <AllergenBadges allergens={ingredient.allergens} />

View file

@ -360,13 +360,10 @@
} }
// --- Sources tab (RecipeSourcesPanel) --------------------------------------- // --- Sources tab (RecipeSourcesPanel) ---------------------------------------
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap) // Reuses .recipes-page__catalog and .recipe-table(-wrap) as-is (see
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's // RecipeSourcesPanel.tsx/SourceItemTable.tsx); the search field is the
// actually new to this tab gets its own rules here. // page header's own now (issue #133) — only what's actually new to this
// tab gets its own rules here.
.recipes-page__header--sources {
gap: var(--space-sm);
}
.source-item-table__imported-badge { .source-item-table__imported-badge {
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
@ -889,6 +886,26 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
align-self: flex-start;
}
// "Ma technique n'est pas dans la liste" sub-form (issue #130) — a name
// field + a small confirm, shown in place of the "not in list" link once
// it's clicked.
&__missing-technique {
display: flex;
gap: var(--space-xs);
input {
flex: 1;
min-width: 0;
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
} }
&__remove-match { &__remove-match {
@ -1384,6 +1401,25 @@
margin-top: var(--space-xl); margin-top: var(--space-xl);
} }
// Pins the Enregistrer/Annuler pair to the bottom of `.app-content` (the
// one scroll container — AppLayout.scss) so a long recipe never needs
// scrolling back down to save or leave. `bottom: 0` lands it a hair above
// the true viewport edge because `.app-content` carries its own padding —
// a small, acceptable gap, and correct on every breakpoint without a
// per-viewport override. The opaque `background` + top border keep the
// form content readable as it scrolls underneath.
&__actions--sticky {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding: var(--space-md) 0;
background: var(--color-background);
border-top: 1px solid var(--color-border);
}
button[type="submit"] { button[type="submit"] {
padding: 0.6rem var(--space-lg); padding: 0.6rem var(--space-lg);
font-family: var(--font-body); font-family: var(--font-body);
@ -1404,6 +1440,25 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
// Secondary action next to the primary Enregistrer — outline/ghost, same
// language as `.recipe-detail-panel__delete-confirm`'s non-danger button,
// so "Annuler" never competes visually with "Enregistrer".
&__cancel {
padding: 0.6rem var(--space-lg);
font-family: var(--font-body);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
&:hover {
background: var(--color-surface-alt);
}
}
} }
// --- Unresolved-ingredient review (ImportRecipePage) ------------------------ // --- Unresolved-ingredient review (ImportRecipePage) ------------------------
@ -1535,6 +1590,16 @@
width: 11rem; width: 11rem;
} }
// "Selon le goût" toggle — the app's selectable-card checkbox
// (global.scss's `label:has(> input[type="checkbox"])`), shrunk to sit
// inline in the row without dominating it.
&__to-taste {
margin-top: 0;
font-size: var(--font-size-xs);
font-weight: 400;
white-space: nowrap;
}
&__remove { &__remove {
padding: var(--space-xs) var(--space-sm); padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);

View file

@ -1,7 +1,6 @@
import { import {
type CreateRecipeInput, type CreateRecipeInput,
createRecipeSchema, createRecipeSchema,
type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
type Meal, type Meal,
@ -16,7 +15,6 @@ import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client"; import { ApiError, apiClient } from "../../../api/client";
import { makeClientKey } from "../../../lib/client-key"; import { makeClientKey } from "../../../lib/client-key";
import { errorMessageService } from "../../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
import { DietTagSelect } from "../badges/DietTagSelect";
import { IngredientPicker } from "../ingredients/IngredientPicker"; import { IngredientPicker } from "../ingredients/IngredientPicker";
import { IngredientRow } from "../ingredients/IngredientRow"; import { IngredientRow } from "../ingredients/IngredientRow";
import { import {
@ -29,12 +27,13 @@ import "../recipes.scss";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */ /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). */ /** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). `toTaste` = a "selon le goût" seasoning line, submitted with no quantity/unit. */
interface IngredientLine { interface IngredientLine {
key: string; key: string;
ingredient: IngredientView; ingredient: IngredientView;
quantity: string; quantity: string;
unitId: number | null; unitId: number | null;
toTaste: boolean;
} }
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */ /** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */
@ -59,8 +58,8 @@ type LoadState = "loading" | "loaded" | "error";
* *
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId`, * Pre-filled from `GET /sources/:sourceKey/preview/:externalId`,
* structurally the same form as `RecipeFormPage` — same sub-components * structurally the same form as `RecipeFormPage` — same sub-components
* (`IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`), * (`IngredientRow`, `IngredientPicker`, `StepListEditor`), same
* same `CreateRecipeInput` submit shape — plus one thing a manual creation * `CreateRecipeInput` submit shape — plus one thing a manual creation
* never has to handle: ingredient lines the automatic matching * never has to handle: ingredient lines the automatic matching
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à * (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à
* compléter" list, each needing a real ingredient picked (or the line * compléter" list, each needing a real ingredient picked (or the line
@ -94,7 +93,6 @@ export function RecipeImportForm({
const [loadState, setLoadState] = useState<LoadState>("loading"); const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]); const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]); const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -102,7 +100,6 @@ export function RecipeImportForm({
const [picture, setPicture] = useState(""); const [picture, setPicture] = useState("");
const [portions, setPortions] = useState("4"); const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL"); const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]); const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>( const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
[], [],
@ -122,14 +119,12 @@ export function RecipeImportForm({
Promise.all([ Promise.all([
apiClient.getIngredients(), apiClient.getIngredients(),
apiClient.getDiets(),
apiClient.getUnits(), apiClient.getUnits(),
apiClient.previewSourceItem(sourceKey, externalId), apiClient.previewSourceItem(sourceKey, externalId),
]) ])
.then(([ingredients, diets, units, draft]) => { .then(([ingredients, units, draft]) => {
if (cancelled) return; if (cancelled) return;
setIngredientsCatalog(ingredients); setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
setUnitsCatalog(units); setUnitsCatalog(units);
setName(draft.name); setName(draft.name);
@ -146,6 +141,12 @@ export function RecipeImportForm({
ingredient: line.ingredient, ingredient: line.ingredient,
quantity: line.quantity !== null ? String(line.quantity) : "", quantity: line.quantity !== null ? String(line.quantity) : "",
unitId: line.unit?.id ?? null, unitId: line.unit?.id ?? null,
// A source line that resolved to a real ingredient but carried
// no quantity (e.g. "salt to taste") — see
// `translateRecipeIngredients`, which leaves `quantity`/`unitId`
// null in exactly that case. Land it as a "selon le goût" line
// rather than blocking submit on a missing quantity.
toTaste: line.quantity === null,
}); });
} else { } else {
unresolved.push({ unresolved.push({
@ -179,13 +180,13 @@ export function RecipeImportForm({
function addIngredient(ingredient: IngredientView) { function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [ setIngredientLines((lines) => [
...lines, ...lines,
{ key: makeClientKey(), ingredient, quantity: "", unitId: null }, { key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false },
]); ]);
} }
function updateIngredientLine( function updateIngredientLine(
key: string, key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>, patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
) { ) {
setIngredientLines((lines) => setIngredientLines((lines) =>
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
@ -203,7 +204,13 @@ export function RecipeImportForm({
if (line) { if (line) {
setIngredientLines((resolved) => [ setIngredientLines((resolved) => [
...resolved, ...resolved,
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null }, {
key: makeClientKey(),
ingredient,
quantity: line.quantity,
unitId: null,
toTaste: false,
},
]); ]);
} }
return lines.filter((l) => l.key !== unresolvedKey); return lines.filter((l) => l.key !== unresolvedKey);
@ -223,6 +230,7 @@ export function RecipeImportForm({
ingredient: makePlaceholderIngredientView(line.rawText.trim()), ingredient: makePlaceholderIngredientView(line.rawText.trim()),
quantity: line.quantity, quantity: line.quantity,
unitId: null, unitId: null,
toTaste: false,
}, },
]); ]);
} }
@ -241,7 +249,9 @@ export function RecipeImportForm({
// this, a pre-filled import whose auto-matched ingredients are otherwise // this, a pre-filled import whose auto-matched ingredients are otherwise
// complete could leave `canSubmit` false with nothing on the page saying // complete could leave `canSubmit` false with nothing on the page saying
// why (see issue #53). // why (see issue #53).
const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null); const hasIngredientMissingUnit = ingredientLines.some(
(line) => !line.toTaste && line.unitId === null,
);
// Two raw source lines can independently resolve to the same catalog // Two raw source lines can independently resolve to the same catalog
// ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") — // ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") —
@ -270,7 +280,9 @@ export function RecipeImportForm({
Number.isInteger(Number(portions)) && Number.isInteger(Number(portions)) &&
Number(portions) > 0 && Number(portions) > 0 &&
ingredientLines.length > 0 && ingredientLines.length > 0 &&
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) && ingredientLines.every(
(line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null),
) &&
!hasDuplicateIngredient && !hasDuplicateIngredient &&
unresolvedIngredients.length === 0 && unresolvedIngredients.length === 0 &&
steps.length > 0 && steps.length > 0 &&
@ -286,7 +298,6 @@ export function RecipeImportForm({
picture: picture.trim() || null, picture: picture.trim() || null,
portions: Number(portions), portions: Number(portions),
visibility, visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({ ingredients: ingredientLines.map((line) => ({
// Free-text lines (nothing in the catalog matched) submit as // Free-text lines (nothing in the catalog matched) submit as
// `placeholderName` so the API creates the placeholder row — same // `placeholderName` so the API creates the placeholder row — same
@ -294,11 +305,9 @@ export function RecipeImportForm({
...(isUnsavedPlaceholder(line.ingredient) ...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" } ? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }), : { ingredientId: line.ingredient.id }),
quantity: Number(line.quantity), // A "selon le goût" line submits with neither quantity nor unit —
// `canSubmit` already requires every line to have a unit picked — // same branch as RecipeFormPage's identical submit.
// same "?? 0, the schema rejects it if ever reached" reasoning as ...(line.toTaste ? {} : { quantity: Number(line.quantity), unitId: line.unitId ?? 0 }),
// RecipeFormPage's identical submit.
unitId: line.unitId ?? 0,
})), })),
steps: steps.map((step) => ({ steps: steps.map((step) => ({
description: step.description.trim(), description: step.description.trim(),
@ -420,8 +429,6 @@ export function RecipeImportForm({
))} ))}
</select> </select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section"> <section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2> <h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list"> <ul className="recipe-form__ingredient-list">
@ -432,9 +439,11 @@ export function RecipeImportForm({
quantity={line.quantity} quantity={line.quantity}
unitId={line.unitId} unitId={line.unitId}
unitsCatalog={unitsCatalog} unitsCatalog={unitsCatalog}
toTaste={line.toTaste}
duplicate={duplicateIngredientIds.has(line.ingredient.id)} duplicate={duplicateIngredientIds.has(line.ingredient.id)}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })}
onRemove={() => removeIngredientLine(line.key)} onRemove={() => removeIngredientLine(line.key)}
/> />
))} ))}

View file

@ -6,9 +6,6 @@ import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel"
import { SourceItemTable } from "./SourceItemTable"; import { SourceItemTable } from "./SourceItemTable";
import "../recipes.scss"; import "../recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */ /** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
const LOAD_MORE_PLACEHOLDER_COUNT = 4; const LOAD_MORE_PLACEHOLDER_COUNT = 4;
@ -64,15 +61,25 @@ type BrowseState =
* upward, and re-previews on mount/prop-change if handed one back. * upward, and re-previews on mount/prop-change if handed one back.
* `RecipePickerDialog` leaves both unset: previewing inside that dialog * `RecipePickerDialog` leaves both unset: previewing inside that dialog
* has no URL of its own to keep in sync. * has no URL of its own to keep in sync.
*
* `debouncedSearch` is owned by the caller (issue #133): one search field
* in `RecipesPage`'s header serves every tab — real ones and each
* source's — so the query survives switching between sources (this panel
* still remounts on `sourceKey` via its `key`, but the term lives above
* that). The debounce happens in the caller too; this panel just refetches
* whenever the prop changes.
*/ */
export function RecipeSourcesPanel({ export function RecipeSourcesPanel({
sourceKey, sourceKey,
debouncedSearch,
onSelectImportedRecipe, onSelectImportedRecipe,
onDraftSelected, onDraftSelected,
initialSelection, initialSelection,
onItemSelected, onItemSelected,
}: { }: {
sourceKey: string; sourceKey: string;
/** Already-debounced search term from `RecipesPage`'s shared field — drives `browseSource`'s `query`. */
debouncedSearch: string;
/** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */ /** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
onSelectImportedRecipe: (recipe: RecipeView) => void; onSelectImportedRecipe: (recipe: RecipeView) => void;
/** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */ /** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
@ -82,8 +89,6 @@ export function RecipeSourcesPanel({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" }); const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle"); const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
// The *next* page, fetched ahead of time as soon as the current one is on // The *next* page, fetched ahead of time as soon as the current one is on
@ -152,11 +157,6 @@ export function RecipeSourcesPanel({
}; };
}, [initialSelection?.externalId]); }, [initialSelection?.externalId]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setBrowseState({ status: "loading" }); setBrowseState({ status: "loading" });
@ -294,17 +294,8 @@ export function RecipeSourcesPanel({
} }
return ( return (
<> // The search field lives in `RecipesPage`'s header now (issue #133) —
<div className="recipes-page__header recipes-page__header--sources"> // this panel is just the source's list + preview.
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="recipes-page__catalog"> <div className="recipes-page__catalog">
{browseState.status === "loading" && ( {browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p> <p className="recipes-page__status">{t("recipes.sources.loading")}</p>
@ -340,6 +331,5 @@ export function RecipeSourcesPanel({
<RecipeDetailPanel state={previewState} showActions={false} /> <RecipeDetailPanel state={previewState} showActions={false} />
</div> </div>
</>
); );
} }

View file

@ -16,9 +16,10 @@ import {
* fall back to the full review screen (`ImportRecipePage`), pre-filled from * fall back to the full review screen (`ImportRecipePage`), pre-filled from
* this exact same draft, for a person to fill in what's missing. * this exact same draft, for a person to fill in what's missing.
* *
* Default `visibility`/`dietIds` mirror what a person would otherwise leave * Default `visibility` mirrors what a person would otherwise leave
* untouched on that same form (`PERSONAL`, no diet tags) — nothing here is * untouched on that same form (`PERSONAL`) — nothing here is a guess about
* a guess about data the draft doesn't have an opinion on. * data the draft doesn't have an opinion on. Regimes aren't part of the
* payload anymore (issue #131) — they're computed from the ingredients.
* `createRecipeSchema.safeParse` is still the actual authority on whether * `createRecipeSchema.safeParse` is still the actual authority on whether
* the result is submittable (a positive-portions check, string lengths, * the result is submittable (a positive-portions check, string lengths,
* etc.) — the checks above it exist only for what a schema alone can't * etc.) — the checks above it exist only for what a schema alone can't
@ -42,7 +43,6 @@ export function tryBuildCompleteImport(draft: RecipeImportDraftView): CreateReci
picture: draft.picture, picture: draft.picture,
portions: draft.portions, portions: draft.portions,
visibility: "PERSONAL", visibility: "PERSONAL",
dietIds: [],
ingredients: draft.ingredients.map((line) => ({ ingredients: draft.ingredients.map((line) => ({
// Every line is fully resolved by this point — guarded above. // Every line is fully resolved by this point — guarded above.
ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id, ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id,

View file

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

View file

@ -15,6 +15,7 @@ import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label"; import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker"; import { CatalogSearchPicker } from "./CatalogSearchPicker";
import { techStepLabel } from "./tech-step-label";
import { temperatureLabel } from "./temperature-label"; import { temperatureLabel } from "./temperature-label";
import type { TextSelectionRange } from "./use-text-selection"; import type { TextSelectionRange } from "./use-text-selection";
@ -358,6 +359,13 @@ export function TechStepCorrectionPopover({
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null); const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId); const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
// A free-text technique the catalog doesn't have (issue #130) — set from
// the "pas dans la liste" sub-form. Mutually exclusive with
// `selectedTechStepId`: whichever is set is what `confirm()` submits (as
// `correctedTechStepName` vs `correctedTechStepId`).
const [proposedTechStepName, setProposedTechStepName] = useState<string | null>(null);
const [isNamingMissingTechnique, setIsNamingMissingTechnique] = useState(false);
const [missingTechniqueDraft, setMissingTechniqueDraft] = useState("");
// The technique picker hides behind "Changer" once something's already // The technique picker hides behind "Changer" once something's already
// picked (see this component's own doc comment) — starts open only when // picked (see this component's own doc comment) — starts open only when
// there's nothing to show yet (a brand-new selection). // there's nothing to show yet (a brand-new selection).
@ -506,7 +514,7 @@ export function TechStepCorrectionPopover({
} }
async function confirm() { async function confirm() {
if (selectedTechStepId === null) return; if (selectedTechStepId === null && proposedTechStepName === null) return;
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
try { try {
@ -514,7 +522,11 @@ export function TechStepCorrectionPopover({
start: range.start, start: range.start,
end: range.end, end: range.end,
previousTechStepId, previousTechStepId,
correctedTechStepId: selectedTechStepId, // Exactly one of these — a catalog id, or free text for a technique
// the catalog is missing (issue #130).
...(proposedTechStepName !== null
? { correctedTechStepName: proposedTechStepName }
: { correctedTechStepId: selectedTechStepId ?? undefined }),
...(metadataTouched ...(metadataTouched
? { ? {
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest), ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
@ -616,9 +628,13 @@ export function TechStepCorrectionPopover({
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i])); const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u])); const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u])); const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
const selectedTechStep =
selectedTechStepId !== null ? techSteps?.find((ts) => ts.id === selectedTechStepId) : undefined;
const currentTechniqueLabel = const currentTechniqueLabel =
selectedTechStepId !== null proposedTechStepName !== null
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`) ? proposedTechStepName
: selectedTechStep
? techStepLabel(selectedTechStep, t)
: null; : null;
return ( return (
@ -652,7 +668,11 @@ export function TechStepCorrectionPopover({
<button <button
type="button" type="button"
className="tech-step-correction-popover__technique-change" className="tech-step-correction-popover__technique-change"
onClick={() => setIsChangingTechnique(true)} onClick={() => {
setIsChangingTechnique(true);
setProposedTechStepName(null);
setIsNamingMissingTechnique(false);
}}
> >
{t("recipes.techStepCorrection.changeTechnique")} {t("recipes.techStepCorrection.changeTechnique")}
</button> </button>
@ -670,19 +690,61 @@ export function TechStepCorrectionPopover({
</div> </div>
{(currentTechniqueLabel === null || isChangingTechnique) && ( {(currentTechniqueLabel === null || isChangingTechnique) && (
<>
<CatalogSearchPicker <CatalogSearchPicker
items={techSteps.map((techStep) => ({ items={techSteps.map((techStep) => ({
id: techStep.id, id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`), label: techStepLabel(techStep, t),
}))} }))}
selectedId={selectedTechStepId ?? undefined} selectedId={selectedTechStepId ?? undefined}
onSelect={(id) => { onSelect={(id) => {
setSelectedTechStepId(id); setSelectedTechStepId(id);
setProposedTechStepName(null);
setIsNamingMissingTechnique(false);
setIsChangingTechnique(false); setIsChangingTechnique(false);
}} }}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")} placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")} emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/> />
{/* Escape hatch for a technique the catalog is missing (issue
#130) — the correction still applies immediately, and the
proposed name lands in the admin catalogue-gap review. */}
{isNamingMissingTechnique ? (
<div className="tech-step-correction-popover__missing-technique">
<input
type="text"
value={missingTechniqueDraft}
onChange={(e) => setMissingTechniqueDraft(e.target.value)}
placeholder={t("recipes.techStepCorrection.missingTechniquePlaceholder")}
aria-label={t("recipes.techStepCorrection.missingTechniqueLabel")}
/>
<button
type="button"
className="tech-step-correction-popover__confirm-mini"
disabled={missingTechniqueDraft.trim().length === 0}
onClick={() => {
setProposedTechStepName(missingTechniqueDraft.trim());
setSelectedTechStepId(null);
setIsNamingMissingTechnique(false);
setIsChangingTechnique(false);
}}
>
{t("recipes.techStepCorrection.missingTechniqueAdd")}
</button>
</div>
) : (
<button
type="button"
className="tech-step-correction-popover__technique-change"
onClick={() => {
setMissingTechniqueDraft("");
setIsNamingMissingTechnique(true);
}}
>
{t("recipes.techStepCorrection.techniqueNotInList")}
</button>
)}
</>
)} )}
</section> </section>
@ -993,7 +1055,9 @@ export function TechStepCorrectionPopover({
type="button" type="button"
className="tech-step-correction-popover__confirm-button" className="tech-step-correction-popover__confirm-button"
onClick={confirm} onClick={confirm}
disabled={isSubmitting || selectedTechStepId === null} disabled={
isSubmitting || (selectedTechStepId === null && proposedTechStepName === null)
}
> >
{t("recipes.techStepCorrection.confirm")} {t("recipes.techStepCorrection.confirm")}
</button> </button>

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

@ -1,4 +1,12 @@
import { Activity, GraduationCap, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react"; import {
Activity,
GraduationCap,
Inbox,
LayoutDashboard,
ListChecks,
PackageSearch,
Users,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
@ -13,10 +21,12 @@ import "./AdminLayout.scss";
*/ */
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true }, { to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/admin/utilisateurs", key: "users", Icon: Users, end: false },
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
{ to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false }, { to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false },
{ to: "/admin/messages", key: "messages", Icon: Inbox, end: false },
] as const; ] as const;
/** /**

View file

@ -415,13 +415,28 @@
cursor: pointer; cursor: pointer;
svg { svg {
width: 1.375rem; width: 1.5rem;
height: 1.375rem; height: 1.5rem;
} }
// Icon-only bar (issue #105): the visible label is dropped. "Liste de
// courses" (16 chars) wrapped to two lines in a ~90px tab while the
// others stayed on one, so its icon+label block was taller and its
// icon sat visibly higher than the rest. Kept in the DOM, visually
// hidden the accessibility-safe way, so a screen reader still announces
// each tab by name and the `<NavLink>`/button keeps its accessible
// label (no `aria-label` to duplicate the i18n string).
.label { .label {
font-size: var(--font-size-xs); position: absolute;
font-weight: 600; width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
} }
// Applied by NavLink itself (see AppLayout.tsx's `className` prop) for // Applied by NavLink itself (see AppLayout.tsx's `className` prop) for

View file

@ -1,8 +1,9 @@
import { Capacitor } from "@capacitor/core";
import type { LucideIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { BottomSheet } from "../components/ui/BottomSheet"; import { BottomSheet } from "../components/ui/BottomSheet";
import { UpdateBanner } from "../features/app-update/UpdateBanner";
import { useAuth } from "../features/auth/AuthContext"; import { useAuth } from "../features/auth/AuthContext";
import { useIsMobileViewport } from "../lib/useIsMobileViewport"; import { useIsMobileViewport } from "../lib/useIsMobileViewport";
import "./AppLayout.scss"; import "./AppLayout.scss";
@ -10,6 +11,7 @@ import {
AccountIcon, AccountIcon,
AppSettingsIcon, AppSettingsIcon,
ChevronLeftIcon, ChevronLeftIcon,
ContactIcon,
CreditsIcon, CreditsIcon,
DietPreferencesIcon, DietPreferencesIcon,
HouseholdIcon, HouseholdIcon,
@ -42,19 +44,45 @@ const NAV_ITEMS = [
}, },
] as const; ] as const;
/** /** One entry in the "Paramètres" nav — see {@link SETTINGS_ITEMS}. */
* One entry in the "Paramètres" nav — revealed by {@link SettingsMenu} on interface SettingsNavItem {
* desktop, listed inside {@link MobileMoreSheet} on mobile. `key` maps to to: string;
* `layout.settings.nav.<key>`. /** Maps to `layout.settings.nav.<key>` in `locales/fr/translation.json`. */
key: string;
Icon: LucideIcon;
/**
* Hidden entirely outside the packaged app (issue #107): its page's
* options — the version/update check, and the "stay on the website"
* redirect toggle — are either inert on the web or have moved to
* `/telecharger`. The route itself redirects away on the web too (see
* `App.tsx`).
*/ */
const SETTINGS_ITEMS = [ nativeOnly?: boolean;
}
/**
* The "Paramètres" nav entries — revealed by {@link SettingsMenu} on
* desktop, listed inside {@link MobileMoreSheet} on mobile. Filtered to
* {@link settingsNavItems} for actual rendering.
*/
const SETTINGS_ITEMS: SettingsNavItem[] = [
{ to: "/parametres/compte", key: "account", Icon: AccountIcon }, { to: "/parametres/compte", key: "account", Icon: AccountIcon },
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon }, { to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon }, { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon }, { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
{ to: "/parametres/application", key: "application", Icon: AppSettingsIcon }, { to: "/parametres/application", key: "application", Icon: AppSettingsIcon, nativeOnly: true },
{ to: "/parametres/contact", key: "contact", Icon: ContactIcon },
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon }, { to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
] as const; ];
/**
* What's actually rendered — {@link SETTINGS_ITEMS} minus the `nativeOnly`
* ones on the web. `Capacitor.isNativePlatform()` is fixed for the life of
* the page, so this is settled once at module load rather than per render.
*/
const settingsNavItems: SettingsNavItem[] = SETTINGS_ITEMS.filter(
(item) => Capacitor.isNativePlatform() || !item.nativeOnly,
);
/** /**
* Shell for every authenticated page: on desktop/tablet, a sidebar (brand, * Shell for every authenticated page: on desktop/tablet, a sidebar (brand,
@ -148,9 +176,9 @@ export function AppLayout() {
)} )}
<main className={isMobile ? "app-content app-content--with-bottom-nav" : "app-content"}> <main className={isMobile ? "app-content app-content--with-bottom-nav" : "app-content"}>
{/* Renders nothing on the web build (native-only check) or once no {/* `UpdateBanner` used to sit here, but the native update check
newer version is known — see UpdateBanner's own doc comment. */} (issue #66) must run session or not — it's mounted app-wide in
<UpdateBanner /> `App.tsx` now, as a `position: fixed` strip. */}
<Outlet /> <Outlet />
</main> </main>
@ -221,7 +249,7 @@ function SettingsMenu() {
{isOpen && ( {isOpen && (
<nav className="app-sidebar__nav app-sidebar__settings-nav"> <nav className="app-sidebar__nav app-sidebar__settings-nav">
{SETTINGS_ITEMS.map(({ to, key, Icon }) => ( {settingsNavItems.map(({ to, key, Icon }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
@ -380,7 +408,7 @@ function MobileMoreSheet({ onClose }: { onClose: () => void }) {
</div> </div>
<nav className="mobile-more-sheet__nav"> <nav className="mobile-more-sheet__nav">
{SETTINGS_ITEMS.map(({ to, key, Icon }) => ( {settingsNavItems.map(({ to, key, Icon }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}

View file

@ -26,6 +26,10 @@ export {
// even in a bottom-tab position, unlike on desktop where it usually // even in a bottom-tab position, unlike on desktop where it usually
// means "open a side drawer". // means "open a side drawer".
Menu as MoreIcon, Menu as MoreIcon,
// "Contact" settings entry (issue #80) — the in-app bug/suggestion form.
// A speech bubble reads as "send us a message"; distinct from the Rss
// "Sources" feed glyph and the generic Settings cog.
MessageSquare as ContactIcon,
Palette as UserPreferencesIcon, Palette as UserPreferencesIcon,
Rss as SourcesIcon, Rss as SourcesIcon,
Settings as SettingsIcon, Settings as SettingsIcon,

View file

@ -27,6 +27,10 @@
"vendredi": "Vendredi", "vendredi": "Vendredi",
"samedi": "Samedi", "samedi": "Samedi",
"dimanche": "Dimanche" "dimanche": "Dimanche"
},
"passwordInput": {
"show": "Afficher le mot de passe",
"hide": "Masquer le mot de passe"
} }
}, },
"errors": { "errors": {
@ -57,7 +61,11 @@
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape", "INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard",
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours",
"LEFTOVER_QUANTITY_EXCEEDED": "Il ne reste plus assez de portions disponibles" "LEFTOVER_QUANTITY_EXCEEDED": "Il ne reste plus assez de portions disponibles",
"CONTACT_MESSAGE_NOT_FOUND": "Ce message n'existe pas",
"CONTACT_MESSAGE_ALREADY_LINKED": "Un ticket est déjà lié à ce message",
"FORGEJO_NOT_CONFIGURED": "L'intégration Forgejo n'est pas configurée",
"FORGEJO_REQUEST_FAILED": "La création du ticket Forgejo a échoué, réessayez plus tard"
}, },
"auth": { "auth": {
"login": { "login": {
@ -123,6 +131,7 @@
"household": "Foyer", "household": "Foyer",
"userPreferences": "Préférences utilisateur", "userPreferences": "Préférences utilisateur",
"application": "Paramètres d'application", "application": "Paramètres d'application",
"contact": "Nous contacter",
"credits": "Crédits" "credits": "Crédits"
} }
}, },
@ -214,6 +223,10 @@
"noTechniqueSelected": "Aucune technique sélectionnée.", "noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…", "searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.", "noTechniqueFound": "Aucune technique trouvée.",
"techniqueNotInList": "Ma technique n'est pas dans la liste",
"missingTechniquePlaceholder": "Nommez la technique manquante…",
"missingTechniqueLabel": "Technique manquante",
"missingTechniqueAdd": "Ajouter",
"ingredientsSection": "Ingrédients", "ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles", "utensilsSection": "Ustensiles",
"temperatureSection": "Température", "temperatureSection": "Température",
@ -246,7 +259,6 @@
"diets": "Régime associé" "diets": "Régime associé"
}, },
"sources": { "sources": {
"searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.", "empty": "Aucune recette trouvée.",
"alreadyImported": "Déjà importée", "alreadyImported": "Déjà importée",
"loading": "Chargement…", "loading": "Chargement…",
@ -351,13 +363,16 @@
"addStep": "Ajouter une étape", "addStep": "Ajouter une étape",
"submit": "Enregistrer", "submit": "Enregistrer",
"submitting": "Enregistrement…", "submitting": "Enregistrement…",
"cancel": "Annuler",
"toTasteToggle": "Selon le goût",
"genericError": "Le formulaire contient des erreurs" "genericError": "Le formulaire contient des erreurs"
} }
}, },
"shoppingList": { "shoppingList": {
"title": "Liste de courses", "title": "Liste de courses",
"loading": "Chargement de la liste de courses…", "loading": "Chargement de la liste de courses…",
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning." "empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning.",
"toTaste": "selon le goût"
}, },
"cookingSession": { "cookingSession": {
"title": "Cuisiner cette semaine", "title": "Cuisiner cette semaine",
@ -435,6 +450,23 @@
"SYSTEM": "Système" "SYSTEM": "Système"
} }
}, },
"contact": {
"title": "Nous contacter",
"intro": "Un bug, une idée d'amélioration ? Dites-nous tout. Votre message est transmis à l'équipe avec quelques informations techniques (page, version de l'app) pour nous aider à le comprendre.",
"typeLabel": "Type de message",
"type": {
"bug": "Bug",
"suggestion": "Suggestion",
"other": "Autre"
},
"messageLabel": "Votre message",
"messagePlaceholder": "Décrivez le problème ou votre suggestion le plus précisément possible…",
"charCount": "{{count}} / {{max}} caractères",
"submit": "Envoyer",
"submitting": "Envoi…",
"success": "Merci ! Votre message a bien été envoyé.",
"sendAnother": "Envoyer un autre message"
},
"credits": { "credits": {
"title": "Crédits", "title": "Crédits",
"icons": { "icons": {
@ -1202,14 +1234,47 @@
}, },
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"users": "Utilisateurs",
"monitoring": "Monitoring", "monitoring": "Monitoring",
"corrections": "Corrections", "corrections": "Corrections",
"catalog": "Catalogue", "catalog": "Catalogue",
"tours": "Didacticiels" "tours": "Didacticiels",
"messages": "Messages"
}, },
"layout": { "layout": {
"logout": "Se déconnecter" "logout": "Se déconnecter"
}, },
"users": {
"title": "Utilisateurs",
"lead": "Comptes utilisateurs de l'application.",
"newButton": "Nouvel utilisateur",
"empty": "Aucun compte pour le moment.",
"noHousehold": "—",
"col": {
"date": "Créé le",
"name": "Nom",
"email": "Email",
"household": "Foyer"
},
"form": {
"title": "Créer un compte",
"firstName": "Prénom",
"lastName": "Nom",
"email": "Email",
"submit": "Créer le compte",
"submitting": "Création…",
"cancel": "Annuler"
},
"created": {
"title": "Compte créé",
"intro": "Transmettez ce mot de passe temporaire à",
"passwordLabel": "Mot de passe temporaire",
"copy": "Copier",
"copied": "Copié",
"warning": "Notez-le maintenant : il ne sera plus affiché.",
"done": "Terminé"
}
},
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.", "lead": "Métriques d'utilisation de l'application.",
@ -1310,10 +1375,51 @@
"consumed": "Consommée" "consumed": "Consommée"
} }
}, },
"messages": {
"title": "Messages de contact",
"lead": "Bugs et suggestions remontés par les utilisateurs via le formulaire de contact. Lecture seule.",
"empty": "Aucun message pour ces filtres.",
"deletedAuthor": "Compte supprimé",
"filter": {
"type": "Type",
"any": "Tous"
},
"type": {
"bug": "Bug",
"suggestion": "Suggestion",
"other": "Autre"
},
"col": {
"date": "Date",
"sender": "Expéditeur",
"type": "Type",
"message": "Message",
"page": "Page",
"userAgent": "Client",
"ticket": "Ticket"
},
"forgejo": {
"notConfigured": "Intégration Forgejo non configurée",
"createButton": "Créer un ticket",
"linkTitle": "Ouvrir le ticket Forgejo",
"modalTitle": "Créer un ticket Forgejo",
"titleLabel": "Titre",
"bodyLabel": "Corps",
"submit": "Créer le ticket",
"submitting": "Création…",
"cancel": "Annuler",
"success": "Ticket #{{number}} créé"
}
},
"catalog": { "catalog": {
"title": "Ingrédients hors-catalogue", "title": "Éléments hors-catalogue",
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.", "lead": "Saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
"empty": "Aucun ingrédient hors-catalogue.", "empty": "Aucun ingrédient hors-catalogue.",
"techStepEmpty": "Aucune technique proposée à revoir.",
"kind": {
"ingredients": "Ingrédients",
"techSteps": "Techniques"
},
"tab": { "tab": {
"pending": "À traiter", "pending": "À traiter",
"reviewed": "Traités" "reviewed": "Traités"
@ -1378,7 +1484,11 @@
"lede": "Téléchargez le fichier .apk, ou réessayez d'ouvrir l'application si elle est déjà installée sur cet appareil.", "lede": "Téléchargez le fichier .apk, ou réessayez d'ouvrir l'application si elle est déjà installée sur cet appareil.",
"downloadButton": "Télécharger l'APK", "downloadButton": "Télécharger l'APK",
"retryButton": "Réessayer d'ouvrir l'application", "retryButton": "Réessayer d'ouvrir l'application",
"backLink": "Retour à l'accueil" "backLink": "Retour à l'accueil",
"stayOnWeb": {
"label": "Toujours rester sur le site web",
"hint": "Par défaut, ouvrir batchCooking sur votre téléphone propose de basculer vers l'application si elle est installée. Activez cette option pour rester sur le site web à chaque fois."
}
}, },
"tour": { "tour": {
"controls": { "controls": {
@ -1406,13 +1516,7 @@
"checkButton": "Vérifier les mises à jour", "checkButton": "Vérifier les mises à jour",
"checking": "Vérification…", "checking": "Vérification…",
"upToDate": "L'application est à jour.", "upToDate": "L'application est à jour.",
"checkError": "Impossible de vérifier les mises à jour pour le moment.", "checkError": "Impossible de vérifier les mises à jour pour le moment."
"webHint": "La vérification des mises à jour n'est disponible que dans l'application mobile — sur le web, actualiser la page charge toujours la dernière version."
},
"redirect": {
"title": "Ouverture de l'application",
"stayOnWebLabel": "Toujours rester sur le site web",
"stayOnWebHint": "Par défaut, ouvrir batchCooking sur votre téléphone propose de basculer vers l'application si elle est installée. Activez cette option pour rester sur le site web à chaque fois."
} }
} }
} }

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 { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../../api/admin-client"; import { adminApiClient } from "../../../api/admin-client";
@ -6,25 +6,82 @@ import "../admin-page.scss";
import "./catalog-page.scss"; import "./catalog-page.scss";
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog"; import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
type CatalogState = type IngredientState =
| { status: "loading" } | { status: "loading" }
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] } | { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
| { status: "error" }; | { status: "error" };
type TechStepState =
| { status: "loading" }
| { status: "loaded"; rows: TechStepPlaceholderView[] }
| { status: "error" };
/** Which catalogue gap the page is reviewing — off-catalog ingredients, or techniques users proposed while correcting a step (issue #130). */
type CatalogKind = "ingredients" | "techSteps";
/** /**
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the * Off-catalog review. Two lists a maintainer works through to see what the
* free text users typed when the seeded catalog fell short — see * seeded catalog is missing: the free-text **ingredients** users typed on a
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized * recipe line (grouped by normalized name), and the free-text **techniques**
* name, so a maintainer sees what the catalog is missing and how many * users proposed while correcting a step (issue #130, a flat list — that's
* recipes are waiting on it. Actions are deliberately minimal: mark a gap * rarer). Actions are deliberately minimal: mark a gap as handled (and, for
* as handled, or purge rows no recipe references any more. Actually adding * ingredients, purge rows no recipe references any more). Adding the real
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the * catalog entry stays a manual edit of `reference-seed-data.ts` + the locale
* locale files. * files.
*/ */
export function CatalogPage() { export function CatalogPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [kind, setKind] = useState<CatalogKind>("ingredients");
const [tab, setTab] = useState<CatalogTab>("pending"); const [tab, setTab] = useState<CatalogTab>("pending");
const [state, setState] = useState<CatalogState>({ status: "loading" });
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
<div className="catalog-kinds" role="tablist">
{(["ingredients", "techSteps"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={kind === value}
className={kind === value ? "active" : undefined}
onClick={() => setKind(value)}
>
{t(`admin.catalog.kind.${value}`)}
</button>
))}
</div>
<div className="catalog-tabs" role="tablist">
{(["pending", "reviewed"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
className={tab === value ? "active" : undefined}
onClick={() => setTab(value)}
>
{t(`admin.catalog.tab.${value}`)}
</button>
))}
</div>
{kind === "ingredients" ? (
<IngredientPlaceholders tab={tab} />
) : (
<TechStepPlaceholders tab={tab} />
)}
</div>
);
}
/** The off-catalog ingredient list — grouped by normalized name, with a prune-orphans action. */
function IngredientPlaceholders({ tab }: { tab: CatalogTab }) {
const { t } = useTranslation();
const [state, setState] = useState<IngredientState>({ status: "loading" });
const [pendingIds, setPendingIds] = useState<number[] | null>(null); const [pendingIds, setPendingIds] = useState<number[] | null>(null);
const [pruneMessage, setPruneMessage] = useState<string | null>(null); const [pruneMessage, setPruneMessage] = useState<string | null>(null);
const [isPruning, setIsPruning] = useState(false); const [isPruning, setIsPruning] = useState(false);
@ -78,25 +135,8 @@ export function CatalogPage() {
} }
return ( return (
<div className="admin-page"> <>
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
<div className="catalog-toolbar"> <div className="catalog-toolbar">
<div className="catalog-tabs" role="tablist">
{(["pending", "reviewed"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
className={tab === value ? "active" : undefined}
onClick={() => setTab(value)}
>
{t(`admin.catalog.tab.${value}`)}
</button>
))}
</div>
<button type="button" onClick={pruneOrphans} disabled={isPruning}> <button type="button" onClick={pruneOrphans} disabled={isPruning}>
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")} {isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
</button> </button>
@ -124,7 +164,90 @@ export function CatalogPage() {
))} ))}
</ul> </ul>
))} ))}
</>
);
}
/** The proposed-technique list (issue #130) — a flat list, each row markable as reviewed. */
function TechStepPlaceholders({ tab }: { tab: CatalogTab }) {
const { t } = useTranslation();
const [state, setState] = useState<TechStepState>({ status: "loading" });
const [busyId, setBusyId] = useState<number | null>(null);
const requestSeq = useRef(0);
const load = useCallback((forTab: CatalogTab) => {
const seq = ++requestSeq.current;
setState({ status: "loading" });
adminApiClient
.getTechStepPlaceholders(reviewedParam(forTab))
.then((rows) => {
if (seq === requestSeq.current) setState({ status: "loaded", rows });
})
.catch(() => {
if (seq === requestSeq.current) setState({ status: "error" });
});
}, []);
useEffect(() => {
load(tab);
}, [tab, load]);
function markReviewed(id: number) {
setBusyId(id);
adminApiClient
.markTechStepPlaceholdersReviewed({ techStepIds: [id] })
.then(() => load(tab))
.catch(() => load(tab))
.finally(() => setBusyId(null));
}
if (state.status === "loading") {
return <p className="admin-page__placeholder">{t("admin.common.loading")}</p>;
}
if (state.status === "error") {
return <p className="admin-page__placeholder">{t("admin.common.loadError")}</p>;
}
if (state.rows.length === 0) {
return <p className="admin-page__placeholder">{t("admin.catalog.techStepEmpty")}</p>;
}
return (
<ul className="catalog-list">
{state.rows.map((row) => {
const firstSeen = formatDate(row.createdAt);
return (
<li key={row.id} className="catalog-card">
<div className="catalog-card__head">
<h2 className="catalog-card__name">{row.displayName}</h2>
<span className="catalog-card__count">
{t("admin.catalog.recipeCount", { count: row.recipeCount })}
</span>
</div> </div>
{row.sampleRecipes.length > 0 && (
<p className="catalog-card__recipes">
{t("admin.catalog.seenIn")}{" "}
{row.sampleRecipes.map((recipe) => recipe.name).join(", ")}
</p>
)}
<div className="catalog-card__foot">
{firstSeen && (
<span className="catalog-card__seen">
{t("admin.catalog.firstSeen", { date: firstSeen })}
</span>
)}
{row.reviewedAt === null && (
<button
type="button"
onClick={() => markReviewed(row.id)}
disabled={busyId === row.id}
>
{busyId === row.id ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
</button>
)}
</div>
</li>
);
})}
</ul>
); );
} }

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

View file

@ -0,0 +1,94 @@
import { type ContactMessageAdminView, ErrorCode } from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApiError, adminApiClient } from "../../../api/admin-client";
import { Dialog } from "../../../components/ui/Dialog";
import { errorMessageService } from "../../../services/error-message.service";
import { buildIssueBody, buildIssueTitle } from "./messages";
/**
* Pre-filled, editable "create a Forgejo issue from this message" modal
* (issue #81). Title/body start from {@link buildIssueTitle} /
* {@link buildIssueBody}; the admin adjusts both, then confirms. On success
* the parent swaps the row for the returned {@link ContactMessageAdminView}
* (now carrying `forgejoIssueNumber` / `forgejoIssueUrl`) and closes this.
*/
export function CreateForgejoIssueModal({
message,
onClose,
onCreated,
}: {
message: ContactMessageAdminView;
onClose: () => void;
onCreated: (updated: ContactMessageAdminView) => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(() => buildIssueTitle(message));
const [body, setBody] = useState(() => buildIssueBody(message));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = title.trim().length > 0 && body.trim().length > 0 && !busy;
async function submit() {
setBusy(true);
setError(null);
try {
const updated = await adminApiClient.createForgejoIssue(message.id, {
title: title.trim(),
body: body.trim(),
});
// Parent unmounts this modal via `onCreated` — no need to clear `busy`.
onCreated(updated);
} catch (err) {
setError(
errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
);
setBusy(false);
}
}
return (
<Dialog
title={t("admin.messages.forgejo.modalTitle")}
onClose={onClose}
className="forgejo-issue-modal"
footer={
<>
<button type="button" onClick={onClose} disabled={busy}>
{t("admin.messages.forgejo.cancel")}
</button>
<button
type="button"
className="forgejo-issue-modal__submit"
onClick={() => void submit()}
disabled={!canSubmit}
>
{busy ? t("admin.messages.forgejo.submitting") : t("admin.messages.forgejo.submit")}
</button>
</>
}
>
<label htmlFor="forgejoIssueTitle">{t("admin.messages.forgejo.titleLabel")}</label>
<input
id="forgejoIssueTitle"
type="text"
value={title}
maxLength={255}
onChange={(event) => setTitle(event.target.value)}
/>
<label htmlFor="forgejoIssueBody">{t("admin.messages.forgejo.bodyLabel")}</label>
<textarea
id="forgejoIssueBody"
value={body}
rows={16}
onChange={(event) => setBody(event.target.value)}
/>
{error && <p className="field-error">{error}</p>}
</Dialog>
);
}

View file

@ -0,0 +1,189 @@
import { CONTACT_MESSAGE_TYPES, type ContactMessageAdminView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss";
import "./messages-page.scss";
import { CreateForgejoIssueModal } from "./CreateForgejoIssueModal";
type MessagesState =
| { status: "loading" }
| { status: "loaded"; rows: ContactMessageAdminView[] }
| { status: "error" };
/**
* Contact-form messages (issue #80) — a read-only list of the bug reports /
* suggestions users have sent from `/parametres/contact`, newest first,
* capped server-side at 200. One optional filter (by `type`).
*
* Per row (issue #81), when the `FORGEJO_*` integration is configured: a
* "Créer un ticket" button that opens a pre-filled modal and, on success,
* turns into a `#N` link to the created Forgejo issue. The message itself
* stays read-only — the link is the only state a creation adds.
*/
export function MessagesPage() {
const { t } = useTranslation();
const [type, setType] = useState("");
const [state, setState] = useState<MessagesState>({ status: "loading" });
// `null` until the status probe resolves — the ticket column shows nothing
// actionable in the meantime.
const [forgejoEnabled, setForgejoEnabled] = useState<boolean | null>(null);
const [modalFor, setModalFor] = useState<ContactMessageAdminView | null>(null);
useEffect(() => {
let cancelled = false;
adminApiClient
.getForgejoStatus()
.then((status) => {
if (!cancelled) setForgejoEnabled(status.enabled);
})
.catch(() => {
if (!cancelled) setForgejoEnabled(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
adminApiClient
.getContactMessages({ type: type || undefined })
.then((rows) => {
if (!cancelled) setState({ status: "loaded", rows });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [type]);
/** Swap one row in place after a Forgejo issue is created from it. */
function applyCreatedIssue(updated: ContactMessageAdminView) {
setState((prev) =>
prev.status === "loaded"
? {
status: "loaded",
rows: prev.rows.map((row) => (row.id === updated.id ? updated : row)),
}
: prev,
);
setModalFor(null);
}
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.messages.title")}</h1>
<p className="admin-page__lead">{t("admin.messages.lead")}</p>
<div className="messages-filters">
<label>
{t("admin.messages.filter.type")}
<select value={type} onChange={(event) => setType(event.target.value)}>
<option value="">{t("admin.messages.filter.any")}</option>
{CONTACT_MESSAGE_TYPES.map((option) => (
<option key={option} value={option}>
{t(`admin.messages.type.${option}`)}
</option>
))}
</select>
</label>
{forgejoEnabled === false && (
<p className="messages-forgejo-note">{t("admin.messages.forgejo.notConfigured")}</p>
)}
</div>
{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.messages.empty")}</p>
)}
{state.status === "loaded" && state.rows.length > 0 && (
<div className="messages-table-wrap">
<table className="messages-table">
<thead>
<tr>
<th>{t("admin.messages.col.date")}</th>
<th>{t("admin.messages.col.sender")}</th>
<th>{t("admin.messages.col.type")}</th>
<th>{t("admin.messages.col.message")}</th>
<th>{t("admin.messages.col.page")}</th>
<th>{t("admin.messages.col.userAgent")}</th>
<th>{t("admin.messages.col.ticket")}</th>
</tr>
</thead>
<tbody>
{state.rows.map((row) => (
<tr key={row.id}>
<td className="messages-table__date">
{new Date(row.createdAt).toLocaleString("fr-FR")}
</td>
<td>
{row.author ? (
<>
{row.author.firstName} {row.author.lastName}
<br />
<span className="messages-table__email">{row.author.email}</span>
</>
) : (
<span className="messages-table__deleted">
{t("admin.messages.deletedAuthor")}
</span>
)}
</td>
<td>
<span className={`messages-badge messages-badge--${row.type}`}>
{t(`admin.messages.type.${row.type}`)}
</span>
</td>
<td className="messages-table__message">{row.message}</td>
<td className="messages-table__page">{row.pageUrl ?? "—"}</td>
<td className="messages-table__ua" title={row.userAgent ?? undefined}>
{row.userAgent ?? "—"}
</td>
<td className="messages-table__ticket">
{row.forgejoIssueNumber !== null && row.forgejoIssueUrl !== null ? (
<a
href={row.forgejoIssueUrl}
target="_blank"
rel="noreferrer"
title={t("admin.messages.forgejo.linkTitle")}
>
#{row.forgejoIssueNumber}
</a>
) : forgejoEnabled ? (
<button
type="button"
className="messages-table__ticket-button"
onClick={() => setModalFor(row)}
>
{t("admin.messages.forgejo.createButton")}
</button>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{modalFor && (
<CreateForgejoIssueModal
message={modalFor}
onClose={() => setModalFor(null)}
onCreated={applyCreatedIssue}
/>
)}
</div>
);
}

View file

@ -0,0 +1,212 @@
// =============================================================================
// MessagesPage (issue #80) — one type filter and a read-only table of
// contact-form messages. Shares the visual language of CorrectionsPage's
// filter row + table (corrections-page.scss); kept as its own file since
// nothing outside this page uses these classes.
// =============================================================================
.messages-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-md);
label {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
select {
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
}
.messages-table-wrap {
overflow-x: auto;
}
.messages-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);
}
&__deleted {
color: var(--color-text-muted);
font-style: italic;
}
// The message is the point of the row — give it room, let it wrap.
&__message {
max-width: 32rem;
white-space: pre-wrap;
}
&__page {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
}
// User-agent strings are long and low-value at a glance — clamp to one
// line, full value on hover (see the cell's `title`).
&__ua {
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
&__ticket {
white-space: nowrap;
}
// "Créer un ticket" — a compact secondary action, same language as the
// corrections table's inline buttons.
&__ticket-button {
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-xs);
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);
}
}
}
// "Intégration Forgejo non configurée" — a quiet note next to the filter,
// shown only when the FORGEJO_* env isn't set.
.messages-forgejo-note {
margin: 0;
align-self: end;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
.messages-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-base);
font-size: var(--font-size-xs);
font-weight: 600;
background: var(--color-surface-alt);
color: var(--color-text);
border-left: 3px solid var(--color-border);
&--bug {
border-left-color: var(--color-error);
}
&--suggestion {
border-left-color: var(--color-accent);
}
&--other {
border-left-color: var(--color-border);
}
}
// Create-issue modal — the `Dialog` primitive supplies the panel/scroll;
// this only sizes the two fields and the footer actions.
.forgejo-issue-modal {
&.dialog-panel {
max-width: 44rem;
width: 100%;
}
label {
display: block;
margin: var(--space-sm) 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
}
input,
textarea {
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);
}
textarea {
font-family: var(--font-mono);
resize: vertical;
min-height: 12rem;
}
.field-error {
margin-top: var(--space-sm);
}
&__submit {
color: #fff;
background: var(--color-primary);
border-color: var(--color-primary);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
}
.messages-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-base);
font-size: var(--font-size-xs);
font-weight: 600;
background: var(--color-surface-alt);
color: var(--color-text);
border-left: 3px solid var(--color-border);
&--bug {
border-left-color: var(--color-error);
}
&--suggestion {
border-left-color: var(--color-accent);
}
&--other {
border-left-color: var(--color-border);
}
}

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