Compare commits

..

214 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
642b37ebec fix(planning): affiche les recettes du picker sur mobile (#111)
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build (push) Successful in 1m43s
CI / intent-service-test (push) Successful in 3m56s
CI / e2e (push) Successful in 8m37s
CI / test (push) Successful in 16m12s
CI / build-android (push) Has been skipped
Sur téléphone, le dialogue d'ajout d'une recette au planning devenait
plein écran mais `.recipe-picker-dialog` gardait un `height: 80vh` figé.
Dans cette colonne flex de hauteur bornée, le bloc `.recipe-picker__filters`
(rendu pour les 4 onglets non-source) consommait toute la place et
`.recipes-page__catalog` (`flex: 1`) s'effondrait à ~0 : la table des
recettes était dans le DOM mais dans un scrollport de hauteur nulle, donc
les onglets Favoris / Perso / Foyer / Publique paraissaient vides.

Sous le breakpoint phone : le dialogue passe vraiment plein écran (comme
tout autre `Dialog`), `.recipe-picker__filters` ne se laisse plus
compresser, et le catalogue prend sa hauteur de contenu — c'est
`.dialog-panel__body` qui scrolle.

Closes #111

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:18:17 +02:00
a87e3da025 Merge pull request 'chore(web): version 0.2.1' (#102) from chore/version-0.2.1 into main
All checks were successful
CI / lint (push) Successful in 2m41s
Release / build-android-apk (push) Successful in 7m46s
CI / intent-service-test (push) Successful in 3m56s
Release / sanity-build (push) Successful in 6m3s
Release / forgejo-release (push) Successful in 36s
CI / build-android (push) Has been skipped
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 9m41s
Release / notify-portainer (push) Successful in 2s
CI / test (push) Successful in 39m21s
2026-09-07 18:02:18 +02:00
e62430ee5f chore(web): version 0.2.1
Some checks failed
CI / e2e (push) Successful in 14m0s
CI / test (push) Failing after 18m35s
CI / build-android (push) Has been skipped
CI / build (push) Successful in 5m11s
CI / lint (push) Successful in 4m22s
CI / intent-service-test (push) Successful in 5m14s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 18:01:47 +02:00
4f553147ed Merge pull request '[UI mobile] La landing page met en avant le téléchargement sur mobile' (#101) from feat/mobile-landing-download into main
Some checks failed
CI / lint (push) Successful in 2m50s
CI / build (push) Successful in 2m53s
CI / intent-service-test (push) Successful in 4m37s
CI / e2e (push) Successful in 9m42s
CI / test (push) Successful in 17m4s
CI / build-android (push) Has been cancelled
2026-09-07 17:40:20 +02:00
c82f0969a8 Merge pull request '[UI mobile] Onglet "Paramètres d'application"' (#100) from feat/mobile-app-settings into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / build-android (push) Blocked by required conditions
2026-09-07 17:40:02 +02:00
8d1c4a7f0b Merge pull request '[UI mobile] Liste de courses et catalogue de recettes : renforcement mobile' (#99) from feat/mobile-shopping-recipes into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / build-android (push) Blocked by required conditions
2026-09-07 17:39:53 +02:00
f0f5e9f344 Merge pull request '[UI mobile] Session de cuisine : passe mobile' (#98) from feat/mobile-cooking-session into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / build-android (push) Blocked by required conditions
2026-09-07 17:39:42 +02:00
e21c3f8255 Merge pull request '[UI mobile] Grille planning : vue mobile dédiée' (#97) from feat/mobile-planning-view into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / build-android (push) Blocked by required conditions
2026-09-07 17:39:17 +02:00
8e3ec3ed12 chore: relance la CI (504 transitoire GitHub CDN sur le téléchargement du modèle spaCy)
Some checks failed
CI / e2e (push) Has been cancelled
CI / build (push) Has been cancelled
CI / build-android (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:38:04 +02:00
615e320be1 feat(landing): met en avant le téléchargement/ouverture de l'appli sur mobile (#85)
Some checks failed
CI / lint (push) Successful in 2m56s
CI / build (push) Successful in 3m6s
CI / intent-service-test (push) Successful in 4m42s
CI / build-android (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / test (push) Has been cancelled
Sur un vrai navigateur mobile Android (isAndroidPhoneBrowser(), déjà
utilisé par le ticket #84), la ligne de CTA de la landing page affiche
désormais "Ouvrir dans l'application" comme action PRIMAIRE — plus
utile qu'un compte web puisque l'expérience web n'est de toute façon
pas optimisée mobile (au cœur de ce chantier), et un visiteur avec
l'appli déjà installée peut s'inscrire/se connecter depuis l'appli
elle-même. "Créer un compte"/"Se connecter" sont rétrogradés en
actions secondaires plutôt que masqués : certains visiteurs préfèrent
sciemment le web.

Le clic déclenche attemptOpenNativeApp (tentative d'ouverture du
scheme, repli automatique après le délai) plutôt que d'afficher un
second lien séparé — remplace .landing__mobile-link, ne le duplique
pas. Sur desktop, comportement inchangé (CTA "Créer un compte" toujours
primaire) sauf le lien Android discret, qui pointe maintenant vers
/telecharger (page de repli du ticket #84) au lieu du lien direct
/mobile/download — un seul point d'entrée "obtenir l'appli" pour tout
le site plutôt que deux mécanismes légèrement différents à maintenir
en synchro.

Couverture Cypress : landing.feature étendu avec un nouveau scénario
mobile (UA Android spoofée via onBeforeLoad, même chaîne que le test
unitaire existant d'isAndroidPhoneBrowser dans
mobile-app-redirect.cy.tsx) — vérifie l'affichage du bouton et la
rétrogradation de Créer un compte/Se connecter, SANS jamais cliquer le
bouton réel (son onClick appellerait un vrai window.location.href vers
un scheme personnalisé, qui bloque indéfiniment le runner Cypress/
Electron — piège déjà documenté, la fonction elle-même est déjà
testée unitairement avec un stub injecté). Le scénario desktop existant
est mis à jour pour le nouveau href /telecharger. Suite complète :
biome check, tsc -b --force, build, 145/145 e2e, 71/71 composants —
tous verts.

Closes #85

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:34:44 +02:00
e1589e63d0 feat(settings): onglet "Paramètres d'application" (#86)
Some checks failed
CI / lint (push) Successful in 2m54s
CI / build (push) Successful in 2m56s
CI / intent-service-test (push) Successful in 4m44s
CI / e2e (push) Successful in 9m40s
CI / test (push) Failing after 17m34s
CI / build-android (push) Has been skipped
Nouvelle route /parametres/application + entrée dans SETTINGS_ITEMS
(desktop et sheet mobile, un seul tableau partagé). Thème et "rejouer
un tutoriel" restent inchangés dans /parametres/preferences-utilisateur
(décision explicite du chantier).

Le contenu affiché diffère réellement selon la plateforme, pas par
simple choix cosmétique — chaque moitié de la page est inerte sur
l'autre plateforme, pas juste hors-sujet :

- Version installée + "Vérifier les mises à jour" (natif seulement) :
  useAppUpdateCheck (déjà utilisé par la bannière silencieuse
  existante) gagne un checkNow() qui relance la même comparaison sans
  attendre le throttle de 6h, réutilisant la logique existante plutôt
  que de la dupliquer (fetchLatestVersionResult extraite en fonction
  partagée). Nouveau hook useInstalledVersion (App.getInfo() natif,
  __APP_VERSION__ web).

- "Toujours rester sur le site web" (web seulement, tout appareil) :
  pilote STAY_ON_WEB_PREFERENCE_KEY (déjà exporté par
  mobile-app-redirect.ts, ticket #84). Volontairement masqué côté natif
  : le WebView Capacitor tourne sur sa propre origine https://localhost,
  un localStorage totalement séparé de celui du vrai site web que lit
  attemptOpenNativeApp — modifier la préférence depuis l'app ne
  changerait donc jamais rien pour un visiteur mobile réel. Documenté
  explicitement dans le commentaire de la page pour ne pas réintroduire
  ce contrôle-fantôme plus tard.

Couverture Cypress : nouveau app-settings-page.cy.ts (contenu web,
persistance du toggle après reload) ; sidebar.cy.ts étendu pour couvrir
le nouveau lien. Le déclenchement natif réel (App.getInfo(), l'appel
réseau de checkNow) n'a pas de point d'entrée testable depuis un run
Cypress web — même posture déjà acceptée pour useAppUpdateCheck/
UpdateBanner, la logique de comparaison pure reste couverte par
version-compare.cy.tsx (inchangé). Suite complète : biome check,
tsc -b --force, build, 146/146 e2e, 71/71 composants — tous verts.

Closes #86

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:19:36 +02:00
7d1a69cf12 fix(mobile): renforce le catalogue de recettes et la liste de courses sur mobile (#90)
Some checks failed
CI / lint (push) Successful in 3m2s
CI / build (push) Successful in 3m5s
CI / intent-service-test (push) Successful in 5m14s
CI / e2e (push) Successful in 9m38s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
Deux correctifs, regroupés en un seul ticket comme prévu :

- Catalogue de recettes (`recipes.scss`) : `.recipe-table-wrap` n'avait
  aucun garde-fou de scroll horizontal, contrairement à la grille
  planning — sur un viewport étroit, les 4 colonnes (photo/nom/
  allergènes/régimes) se compressaient au lieu de scroller. Ajout
  d'`overflow-x: auto` sur le wrapper + `min-width: 36rem` sur la
  table (échelle réduite par rapport aux 62rem de la grille planning :
  4 colonnes plus étroites, pas 7). Partagé par le catalogue complet
  ET par `RecipePickerDialog`, qui hérite du même correctif au passage.

- Liste de courses (`shopping-list-page.scss`) : audit dédié — la page
  n'a en réalité aucune case à cocher ni bouton de suppression (elle
  est volontairement en lecture seule, voir le commentaire de
  ShoppingListPage.tsx), contrairement à ce que supposait le ticket
  d'origine ; rien à agrandir comme cible tactile de ce côté. La
  structure ne déborde déjà pas non plus (`&-name` a déjà flex:1/
  min-width:0). Amélioration retenue à la place : une ligne un peu
  plus aérée et une icône plus grande sous le breakpoint phone, pour
  une lecture plus confortable en faisant les courses (liste tenue
  d'une main, coups d'œil répétés).

Couverture Cypress : nouveau describe "Recipe catalog (phone)" dans
recipes.cy.ts (garde-fou de scroll horizontal effectivement actif) ;
nouveau fichier shopping-list-page.cy.ts, spec layout simple (pas
Gherkin, cette page n'a pas encore ce type de fichier) pour le padding
de ligne. Suite complète : biome check, tsc -b --force, build, 146/146
e2e, 71/71 composants (un skip ponctuel de TechStepCorrectionPopover
en run groupé, reconfirmé 10/10 isolément — flake local sans lien avec
ce ticket) — tous verts.

Closes #90

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:02:48 +02:00
2b5ae6722a fix(cooking-session): premier passage responsive dédié à l'écran de cuisine (#89)
Some checks failed
CI / build (push) Successful in 2m35s
CI / lint (push) Successful in 2m38s
CI / intent-service-test (push) Successful in 5m8s
CI / e2e (push) Successful in 9m17s
CI / test (push) Failing after 16m14s
CI / build-android (push) Has been skipped
cooking-session-page.scss n'avait encore aucune règle @media. Audit
dédié à cet écran (minuteur inexistant sur cette page — rien à faire
de ce côté, liste d'étapes, actions) :

- Bug d'overflow réel dans l'onglet Recettes : `.cooking-recipes__name`
  n'avait aucune borne dans sa ligne flex (contrairement au
  `.recipe-chip__name` équivalent côté planning) — un nom de recette
  long pouvait repousser le badge de portions et le chevron hors de la
  case (silencieusement coupés par le `overflow: hidden` de la carte
  parente). Corrigé avec le même traitement flex:1/min-width:0/
  ellipsis que son équivalent.

- Cibles tactiles sous le breakpoint phone : les cartes de tâches (`.
  cooking-task`, l'action principale et répétée de tout l'onglet Plan),
  les nœuds du stepper, les onglets, la ligne de l'onglet Recettes et
  les boutons Précédent/Suivant passent tous à --space-md pour dépasser
  confortablement les 48dp minimums d'Android (l'app est Android-only)
  même dans le pire cas (une tâche à une seule ligne, sans ustensiles).

- Piège rencontré : surcharger `.cooking-task` par un simple sélecteur
  de classe ne fonctionnait pas silencieusement — la règle globale
  partagée `label:has(> input[type="checkbox"])` (global.scss) a une
  spécificité CSS plus élevée qu'il n'y paraît (l'argument de :has()
  compte comme un élément + un attribut). Corrigé en réutilisant le
  même niveau d'imbrication que `.cooking-phase__tasks .cooking-task
  --done` juste au-dessus dans le même fichier, qui contournait déjà
  ce problème pour la même raison.

Couverture Cypress : nouveau describe "Cooking session page (phone)"
dans cooking-session-page.cy.ts (2 tests — padding effectivement
appliqué + troncature du nom de recette sans pousser le badge hors
champ). Suite complète : biome check, tsc -b --force, build, 146/146
e2e, 71/71 composants — tous verts.

Closes #89

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:49:08 +02:00
964269d3f3 feat(planning): vue mobile dédiée "un jour à la fois" pour la grille (#88)
Some checks failed
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 2m55s
CI / intent-service-test (push) Successful in 4m19s
CI / test (push) Failing after 4m23s
CI / e2e (push) Successful in 8m41s
CI / build-android (push) Has been skipped
La grille planning (7 colonnes × 5 repas, min-width: 62rem) était le
principal écran inaccessible sur téléphone : sous ce seuil elle ne se
réorganisait pas, elle scrollait juste horizontalement — un tableau
desktop rétréci, pas un vrai écran mobile.

Sous le breakpoint phone (useIsMobileViewport()), PlanningPage bascule
désormais entièrement sur PlanningDayView : un sélecteur de jour (jour
précédent/suivant, borné au lundi/dimanche de la semaine affichée, pas
de bouclage vers la semaine adjacente — ça reste le rôle des flèches du
WeekNavigator) suivi des 5 repas du jour sélectionné, empilés en
cartes.

Le contenu de chaque case (pastilles de recettes + bouton "+") est
mutualisé entre les deux layouts via une fonction non-hook partagée,
mealCellBody, appelée aussi bien par MealCell (<td>, grille desktop)
que par le nouveau MealCellRow (<div>, vue jour) — même logique de
drop-target @dnd-kit (useDroppable) des deux côtés, donc le glisser-
déposer d'un reste continue de fonctionner à l'identique sur mobile.

Mise à jour de layout.cy.ts : son test #39 "la grille reste scrollable
horizontalement sur phone" décrivait un comportement désormais remplacé
par ce ticket — réécrit pour vérifier la bascule vers la vue jour au
lieu du scroll horizontal.

Closes #88

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:27:46 +02:00
23bc19b482 Merge pull request 'test(cypress): convention de viewports téléphone/tablette' (#94) from feat/mobile-cypress-viewports into main
Some checks failed
CI / intent-service-test (push) Failing after 1m32s
CI / lint (push) Successful in 2m11s
CI / build (push) Successful in 2m18s
CI / e2e (push) Successful in 9m2s
CI / test (push) Successful in 16m40s
CI / build-android (push) Has been skipped
2026-09-07 16:02:24 +02:00
0ff94c61ca Merge pull request 'fix(planning): popovers flottants sûrs sur mobile' (#95) from feat/mobile-popovers-safe into main
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m26s
CI / build (push) Successful in 3m30s
CI / intent-service-test (push) Successful in 5m31s
CI / e2e (push) Successful in 9m45s
CI / test (push) Has been cancelled
2026-09-07 15:46:09 +02:00
c39fbaeb13 chore: relance la CI (residu mineur de contention, 460/468 passants)
All checks were successful
CI / lint (push) Successful in 2m2s
CI / build (push) Successful in 3m8s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Successful in 7m20s
CI / test (push) Successful in 43m9s
CI / build-android (push) Has been skipped
Le wait-step a fonctionne (~21 min d'attente puis "proceeding"), mais un
petit residu de contention subsiste : 460 passing / 8 failing, meme
signature connue (timeout 10s + unique constraint sur key). Nette
amelioration par rapport a avant le correctif (auparavant ~280-410
passing / 14-25 failing) - la file se vide au fur et a mesure que les
autres PR terminent (#93 et #96 deja mergees).
2026-09-07 15:18:50 +02:00
33ffa810e3 chore: relance la CI (attente epuisee, pas une collision)
All checks were successful
CI / lint (push) Successful in 2m29s
CI / build (push) Successful in 2m34s
CI / intent-service-test (push) Successful in 4m42s
CI / e2e (push) Successful in 9m43s
CI / test (push) Successful in 29m56s
CI / build-android (push) Has been skipped
Le nouveau step "Wait for any earlier test job" a fonctionne comme
prevu cette fois : il a attendu ~25 min un job anterieur toujours en
cours (task 1259) puis a abandonne proprement (exit 1, "a re-run
should get through") plutot que de risquer une collision - aucun
signe de corruption/collision Postgres dans le log. Simple embouteillage
du a plusieurs PR en test simultanement ; devrait passer maintenant que
la file s'est videe (#93 mergee entre temps).
2026-09-07 15:15:38 +02:00
e45d92b333 Merge pull request 'feat(mobile): redirection vers l'appli + page de téléchargement' (#93) from feat/mobile-app-redirect into main
Some checks failed
CI / lint (push) Successful in 3m9s
CI / build (push) Successful in 3m11s
CI / intent-service-test (push) Successful in 5m46s
CI / e2e (push) Successful in 7m17s
CI / test (push) Failing after 26m42s
CI / build-android (push) Has been skipped
2026-09-07 15:05:14 +02:00
c520cfca09 merge: rapatrie le correctif de serialisation CI (#96)
Some checks failed
CI / lint (push) Successful in 1m3s
CI / build (push) Successful in 2m37s
CI / intent-service-test (push) Successful in 5m14s
CI / e2e (push) Successful in 7m25s
CI / test (push) Failing after 26m49s
CI / build-android (push) Has been skipped
2026-09-07 14:32:48 +02:00
1a5731bb79 merge: rapatrie le correctif de serialisation CI (#96)
Some checks failed
CI / lint (push) Successful in 2m28s
CI / intent-service-test (push) Successful in 4m34s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 8m47s
CI / test (push) Failing after 37m10s
CI / build-android (push) Has been skipped
2026-09-07 14:32:31 +02:00
0447c68b69 merge: rapatrie le correctif de serialisation CI (#96)
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build (push) Successful in 4m3s
CI / intent-service-test (push) Successful in 6m48s
CI / e2e (push) Successful in 13m34s
CI / test (push) Successful in 30m44s
CI / build-android (push) Has been skipped
2026-09-07 14:31:05 +02:00
f484465bee Merge pull request 'ci(test): sérialise vraiment le job test entre runs concurrents' (#96) from fix/ci-test-job-serialization into main
Some checks failed
CI / lint (push) Successful in 3m20s
CI / build (push) Successful in 3m28s
CI / intent-service-test (push) Successful in 4m39s
CI / e2e (push) Successful in 12m5s
CI / test (push) Failing after 18m6s
CI / build-android (push) Has been skipped
2026-09-07 14:30:44 +02:00
f99bf46407 chore: relance la CI (Postgres tue par un job non protege pendant l'attente)
All checks were successful
CI / lint (push) Successful in 2m23s
CI / build (push) Successful in 2m24s
CI / intent-service-test (push) Successful in 4m31s
CI / e2e (push) Successful in 7m39s
CI / test (push) Successful in 25m37s
CI / build-android (push) Has been skipped
Le nouveau step "Wait for any earlier test job" a fonctionne comme
prevu (13 tentatives, ~4m30s a attendre le job 1217, puis
"proceeding") - mais Postgres a ete coupe quasi immediatement apres
(0 passing, Can't reach database server). Cause : d'autres jobs test
en cours sur d'autres PR (#93/#94/#95, sans le correctif) executent
encore `docker rm -f postgres` sans condition au demarrage de LEUR
propre run, y compris pendant que ce job-ci utilise deja le
conteneur. Le correctif protege un job qui demarre poliment, pas
contre un job non protege qui demarre en parallele. Je laisse #93/#94/
#95 tranquilles maintenant et je ne relance que celle-ci jusqu'a ce
qu'elle passe.
2026-09-07 14:02:08 +02:00
365e7b38b7 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / lint (push) Successful in 2m43s
CI / build (push) Successful in 3m54s
CI / intent-service-test (push) Successful in 6m10s
CI / e2e (push) Successful in 11m52s
CI / test (push) Failing after 18m30s
CI / build-android (push) Has been skipped
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:45:24 +02:00
8d75462066 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / lint (push) Successful in 2m50s
CI / test (push) Failing after 2m47s
CI / build (push) Successful in 3m24s
CI / intent-service-test (push) Successful in 5m51s
CI / e2e (push) Successful in 13m43s
CI / build-android (push) Has been skipped
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:45:22 +02:00
8f75a47b23 ci(test): serialise vraiment le job test entre runs concurrents
Some checks failed
CI / lint (push) Successful in 2m47s
CI / build (push) Successful in 4m42s
CI / intent-service-test (push) Successful in 4m58s
CI / test (push) Failing after 14m15s
CI / e2e (push) Successful in 13m33s
CI / build-android (push) Has been skipped
Deux jobs `test` qui se chevauchent partagent un seul conteneur
`--name postgres` sur le reseau docker du runner (pas de bloc
`services:`) : leurs `prisma migrate deploy` + `seedReferenceData`
(beforeAll racine Mocha) rentrent en collision - "Unique constraint
failed on (key)", "No pending migrations" alors qu'il en reste,
decalages d'id tech_step/diet cassant des assertions sans rapport ;
et meme en dehors de ca ils s'affament mutuellement en RAM/CPU
(chacun entraine aussi un modele spaCy) jusqu'a ce que
resetDatabase() depasse le timeout de hook Mocha (10s) et que ca
cascade. Touche a repetition ce mois-ci (PR #47, #53, #54, et de
nouveau aujourd'hui sur #93/#94/#95), a chaque fois resolu par une
relance a vide une fois le runner plus calme - jamais un vrai bug de
la PR concernee.

`concurrency:` (le vrai correctif GitHub) est silencieusement ignore
par ce Forgejo (9.0.3, arrive en v14/Gitea 1.26). Un objet de verrou
(reseau, conteneur, fichier) fuirait : ce runner (forgejo-runner
v3.5.0) n'execute pas les steps `if: always()` suivant un step en
echec, donc pas de liberation fiable en fin de job. A la place :
s'appuyer sur ce que le runner nettoie toujours lui-meme - le
conteneur du job (supprime en succes, echec et annulation) - et
attendre tant qu'un job `test` de task id inferieur tourne encore.
Le plus petit id part en premier, donc deux jobs demarres ensemble ne
peuvent pas se bloquer mutuellement ; rien a liberer explicitement.

Trois ajouts, purement additifs (aucune ligne existante modifiee) :
- nouveau step "Wait for any earlier test job" (poll docker ps,
  plafond ~25min) juste avant le demarrage de Postgres ;
- `timeout-minutes: 45` sur le job (couvre le temps d'attente
  eventuel + garde-fou si le job reste bloque, vu l'absence de
  --exit sur Mocha) ;
- le nettoyage `docker rm -f postgres` existant balaie aussi les
  conteneurs `postgres-<suffixe>` orphelins (residus d'un essai
  anterieur de nommage par run, deja abandonne sur cette meme
  branche, jamais nettoyes faute de teardown fiable).

Travail deja fait et documente par une session precedente (branche
claude/magical-stonebraker-5dc636, verifie fonctionnel en run 130 :
"12x waiting for earlier test job(s)... puis proceeding") mais jamais
fusionne - retrouve et applique aujourd'hui en diagnostiquant la
meme contention sur trois PR en cours (#93/#94/#95).

Verification : impossible de re-simuler la course en local (pas de
second runner ici), mais le diff est purement additif et applique
sans le moindre conflit sur l'etat actuel de main - les trois commits
de ci.yml survenus depuis (upload-artifact v3, bit gradlew,
build-android) touchent un job entierement different du fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:42:23 +02:00
022b8ed4f9 chore: relance la CI (6e collision - FK violee au seed sous contention)
Some checks failed
CI / lint (push) Successful in 3m43s
CI / intent-service-test (push) Successful in 5m52s
CI / build (push) Successful in 3m18s
CI / test (push) Failing after 13m37s
CI / e2e (push) Successful in 13m53s
CI / build-android (push) Has been skipped
Cette fois : PrismaClientKnownRequestError "Foreign key constraint
violated: tour_scenario_step_target_key_fkey" dans le beforeAll
global (seedTourScenarios). Nouvelle variante de la meme famille
connue (plusieurs process de test partageant une seule base Postgres
CI, seed concurrent) plutot qu'un nouveau bug reel - ce ticket ne
touche ni reference-seed-data.ts ni quoi que ce soit lie aux tours.
Log complet aussi montre un timeout Docker au nettoyage du conteneur
("context deadline exceeded"), signe supplementaire de charge severe
sur le runner en ce moment.
2026-09-07 13:39:09 +02:00
80c5b957d6 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m56s
CI / build (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 6m29s
CI / test (push) Failing after 9m56s
CI / e2e (push) Has been cancelled
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:27:46 +02:00
3cdc863774 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m20s
CI / intent-service-test (push) Successful in 4m46s
CI / build (push) Successful in 4m43s
CI / test (push) Failing after 10m16s
CI / e2e (push) Has been cancelled
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:24:46 +02:00
cb3dd297a1 chore: relance la CI (contention runner - timeouts Cypress sous charge)
Some checks failed
CI / build (push) Successful in 3m16s
CI / lint (push) Successful in 3m57s
CI / intent-service-test (push) Successful in 6m18s
CI / test (push) Failing after 14m41s
CI / e2e (push) Successful in 14m0s
CI / build-android (push) Has been skipped
e2e a echoue sur des timeouts de 4s (#firstName, #houseName,
.leftovers-basket__toggle introuvables) - reproduit et verifie en local
sur cette meme branche : planning-leftovers.feature passe proprement
en 4s (2/2), aucun probleme reel. Le run CI montrait des blocs
"passing" anormalement lents partout (jusqu'a 22s pour quelques
assertions simples), signe de contention severe sur le runner pendant
que plusieurs PR tournent en parallele, pas une regression de ce
ticket.
2026-09-07 13:22:35 +02:00
63615656cc chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 5m7s
CI / intent-service-test (push) Successful in 4m15s
CI / build (push) Successful in 3m16s
CI / test (push) Failing after 9m16s
CI / e2e (push) Has been cancelled
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:05:40 +02:00
952abe8dda chore: relance la CI (4e collision infra sur le runner partage)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m33s
CI / build (push) Successful in 4m10s
CI / intent-service-test (push) Successful in 9m43s
CI / test (push) Failing after 15m14s
CI / e2e (push) Successful in 14m51s
intent-service-test a echoue cette fois sur actions/checkout lui-meme
("Non-terminating error while running 'git clone': some refs were not
updated" puis "Cannot find module '.../actions-checkout@v4/dist/index.js'")
- meme classe de collision de cache d'actions que les echecs precedents,
juste une action differente touchee cette fois (voir
ci-forgejo-runner-constraints). Ce ticket ne touche aucun fichier lie a
ces actions.
2026-09-07 13:04:04 +02:00
8d3af76328 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / lint (push) Successful in 1m46s
CI / build (push) Successful in 3m46s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 14m51s
CI / test (push) Failing after 16m7s
CI / build-android (push) Has been skipped
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:02:26 +02:00
200e343cc7 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / intent-service-test (push) Failing after 36s
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / e2e (push) Has been cancelled
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:02:24 +02:00
a3701acae4 fix(planning): popovers flottants surs sur mobile
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 4m8s
CI / build (push) Successful in 3m42s
CI / test (push) Failing after 10m11s
CI / e2e (push) Has been cancelled
Ticket #87 du chantier UI mobile & tablette. LeftoversBasket et
WeekNavigator's CalendarPopover etaient tous les deux des popovers a
largeur fixe (18-20rem), ancres right: 0, sans aucune gestion de bord
d'ecran - sur un telephone etroit ils pouvaient deborder hors du
viewport. Les deux sont declenches depuis l'en-tete du planning, un
ecran central de l'usage quotidien - tres probablement la cause
concrete derriere le retour terrain "je n'ai pas acces a tous les
boutons" sur mobile qui a lance ce chantier.

Les deux basculent desormais sur BottomSheet (ticket #91) sous le
breakpoint phone via useIsMobileViewport() - meme contenu, juste une
autre enveloppe (le popover flottant + son ecouteur de clic exterieur
manuel restent inchanges sur desktop). Le clic-exterieur devient un
no-op propre sur mobile plutot qu'un ecouteur qui ne fait jamais rien
(le ref ne s'attache a rien dans la branche BottomSheet).

Nouvelle cle i18n common.calendar.title ("Choisir une semaine") pour
le titre de la feuille - pas de raccourci en decoupant une autre cle
interpolee.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (142/142, 2 nouveaux tests phone dans
leftovers-basket.cy.ts et planning-page.cy.ts) et composants (63/63).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:48:39 +02:00
d0d66bbda5 chore: relance la CI (contention runner partage sur Postgres)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m43s
CI / build (push) Successful in 3m38s
CI / intent-service-test (push) Successful in 5m52s
CI / e2e (push) Successful in 12m12s
CI / test (push) Failing after 17m13s
24 echecs melant timeouts 10s, "Unique constraint failed on the
fields: (key)" (race d'upsert concurrent au seed) et
PrismaClientInitializationError "Can't reach database server" -
combinaison connue de signatures de contention sur le Postgres partage
du runner (voir ci-forgejo-runner-constraints), pas une regression.
Ce ticket ne touche que des fichiers Cypress/apps-web, aucun code
touchant la base.
2026-09-07 12:47:11 +02:00
bb5fc825ae chore: relance la CI (collision de cache actions sur le runner)
Some checks failed
CI / lint (push) Successful in 3m22s
CI / build (push) Successful in 3m12s
CI / intent-service-test (push) Successful in 4m48s
CI / test (push) Failing after 9m15s
CI / e2e (push) Successful in 12m52s
CI / build-android (push) Has been skipped
Non-terminating error while running 'git clone' puis Cannot find
module '.../pnpm-action-setup@v4/dist/index.js' - signature connue de
collision entre deux jobs act demarrant en meme temps sur le cache
d'actions partage du runner (voir ci-forgejo-runner-constraints), pas
une regression.
2026-09-07 12:41:30 +02:00
14690134f7 chore: relance la CI (collision infra sur le Postgres partage)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / build (push) Failing after 48s
CI / e2e (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / test (push) Has been cancelled
PrismaClientInitializationError "Can't reach database server at
postgres:5432" pendant test, alors que ce ticket ne touche aucun
fichier apps/api - signature connue de contention sur le runner
partage (voir ci-forgejo-runner-constraints), pas une regression.
2026-09-07 12:38:33 +02:00
967be6df4a test(cypress): convention de viewports telephone/tablette
Some checks failed
CI / lint (push) Successful in 3m31s
CI / build (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m51s
CI / test (push) Failing after 12m11s
CI / e2e (push) Successful in 10m50s
CI / build-android (push) Has been skipped
Ticket #83 du chantier UI mobile & tablette. Le repo n'avait aucun
override de viewportWidth/Height ni pour e2e ni pour les tests de
composants - tout tournait a la taille par defaut, jamais un vrai
telephone/une vraie tablette.

- cypress/support/viewports.ts : PHONE_VIEWPORT (390x844) et
  TABLET_VIEWPORT (900x1200), tailles Android reelles plutot que les
  presets iPhone/iPad de Cypress (appli Android uniquement) - dans les
  bandes de _breakpoints.scss ($phone/$tablet du ticket #91), pas
  collees au pixel de bordure. Commande cy.setViewport("phone" |
  "tablet") (pas de surcharge du cy.viewport natif). Importe par
  support/e2e.ts et support/component.ts, disponible dans les deux
  modes.
- layout.cy.ts : les cy.viewport(...) en dur pour du telephone (deux
  tests ajoutes par #91, plus les deux plus anciens du passage #39) et
  le test "au-dessus du point de rupture telephone" migrent vers la
  commande partagee - ce dernier passe a un vrai viewport tablette
  (900px, dans la bande 641-1024) plutot qu'un 1000x700 arbitraire,
  prouvant au passage que le preset tablette fonctionne.
- Nouveau cypress/component/Dialog.cy.tsx : premier test de Dialog en
  isolation (jusqu'ici seulement couvert indirectement via
  RecipePickerDialog) - verifie le plein-ecran sur telephone du ticket
  #91, qui n'avait pas encore son propre test, et prouve au passage que
  cy.setViewport fonctionne en mode composant.

Trouvaille en verifiant : le mode composant de Cypress a son propre
viewport par defaut, plus petit que celui du mode e2e (1000x660) - deja
sous le seuil telephone. Le premier essai du test "desktop" de Dialog
echouait pour cette raison (le CSS plein-ecran s'appliquait sans meme
appeler setViewport). Corrige en fixant un viewport large explicite
pour ce cas plutot que de compter sur un defaut implicite - note ajoutee
a dev-environment-gotchas pour la prochaine fois.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (140/140) et composants (65/65, incluant les 2
nouveaux tests de Dialog.cy.tsx).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:32:36 +02:00
0ecd7c1c84 Merge pull request 'feat(layout): socle UI mobile - nav, feuille et dialogs plein écran' (#92) from feat/mobile-ui-shell into main
Some checks failed
CI / lint (push) Successful in 3m13s
CI / build (push) Successful in 3m9s
CI / intent-service-test (push) Successful in 6m5s
CI / test (push) Failing after 10m19s
CI / e2e (push) Successful in 13m9s
CI / build-android (push) Has been skipped
2026-09-07 12:21:27 +02:00
d6cf4aa63d feat(mobile): redirection vers l'appli + page de telechargement
Some checks failed
CI / lint (push) Successful in 3m35s
CI / build (push) Successful in 4m33s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 15m5s
CI / test (push) Failing after 15m28s
CI / build-android (push) Has been skipped
Ticket #84 du chantier UI mobile & tablette. Aucune infra de deep-link
n'existait (verifie en amont : pas de scheme personnalise, pas d'App
Links, pas d'assetlinks.json, pas de listener appUrlOpen).

- lib/mobile-app-redirect.ts : attemptOpenNativeApp() (pattern "smart
  app banner" classique - tente le scheme batchcooking://, bascule sur
  onFallback si la page est toujours visible passe un court delai) et
  isAndroidPhoneBrowser() (UA Android+Mobile, deliberement distinct de
  useIsMobileViewport() - une question de plateforme, pas de largeur).
  Respecte une preference "rester sur le web" (localStorage, cle
  exportee pour que l'onglet Parametres d'application - ticket #86 -
  la reutilise) sans jamais forcer la redirection.
- AndroidManifest.xml : nouvel intent-filter additif (scheme
  batchcooking, sans toucher au filtre MAIN/LAUNCHER existant).
- Nouvelle page /telecharger (publique, hors RequireAuth - un visiteur
  deja connecte sur le web mais sans l'appli sur cet appareil a une
  raison legitime d'y atterrir) : lien direct vers le proxy existant
  ${API_BASE_URL}/mobile/download (rien a changer cote API), bouton
  "reessayer d'ouvrir l'appli".

Deliberement pas de changement sur la landing page ici (lien discret
actuel laisse tel quel) - c'est le ticket #85, separe pour rester
petit/revuable.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (141/141 sur cette branche - #91 n'est pas encore
merge, layout.cy.ts y est donc encore dans son etat pre-#91) et
composants (69/69, incluant le nouveau mobile-app-redirect.cy.tsx).
Note technique : le premier essai de test du composant tentait un vrai
window.location.href vers le scheme personnalise depuis Cypress/
Electron, ce qui bloquait le runner indefiniment (protocole inconnu
mal gere par Electron) - corrige en rendant ce seul appel injectable
(openAppUrl, reserve aux tests) sans toucher au comportement reel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:20:16 +02:00
0fd4928efc feat(layout): socle UI mobile - nav, feuille et dialogs plein ecran
All checks were successful
CI / lint (push) Successful in 51s
CI / build (push) Successful in 2m10s
CI / intent-service-test (push) Successful in 3m58s
CI / e2e (push) Successful in 8m26s
CI / test (push) Successful in 16m33s
CI / build-android (push) Has been skipped
Fondation du chantier UI mobile & tablette (#91) : jusqu'ici, toute
adaptation mobile de ce repo etait du CSS pur sur le meme DOM (aucun
composant ne branchait son JSX selon la largeur/plateforme). Ce ticket
introduit le premier vrai branchement, et les primitifs que les 8
tickets suivants du chantier reutiliseront.

- useIsMobileViewport() (apps/web/src/lib/) : point de verite unique,
  base sur matchMedia, live (reagit a un resize/une rotation).
- _breakpoints.scss : $phone/$tablet Sass + mixins phone/phone-up/
  tablet-down, remplacant les nombres magiques 640/1024 repetes en dur
  dans 6 fichiers (AppLayout, landing, recipes, recipe-picker-dialog,
  planning-page, dialog, tours-page). Les deux occurrences de 860px
  (landing hero, tours-page) sont deliberement laissees telles quelles
  - un collapse de grille 2 colonnes qui n'a rien a voir avec le
  breakpoint tablette du catalogue recettes, les unifier aurait change
  un comportement visuel reel sans raison.
- useNativeDialogBehavior : mecanique (showModal/close event/clic
  exterieur) extraite de Dialog.tsx, partagee avec le nouveau
  BottomSheet.tsx (feuille mobile plein-largeur, meme fondation
  <dialog> natif que Dialog, juste ancree en bas).
- AppLayout : sur telephone, la sidebar n'est plus reflow-ee en CSS en
  barre horizontale (l'ancien traitement) - elle est remplacee par une
  vraie barre d'onglets basse (Planning/Recettes/Liste de courses +
  Plus), le "Plus" ouvrant une BottomSheet combinant Parametres et
  Compte (deux dropdowns flottants separes cote desktop -> un seul
  point d'entree mobile, IA plus simple, un seul emplacement d'onglet).
- dialog.scss : vrai plein-ecran (bord a bord, sans arrondi) sous le
  breakpoint phone au lieu d'une marge simplement reduite.

Verifie en local : pnpm --filter web exec tsc -b --force, biome check,
pnpm --filter web build, suite Cypress e2e complete (140/140) et
composants (63/63) - y compris layout.cy.ts, reecrit pour tester la
nouvelle barre basse (l'ancien test de la barre horizontale n'a plus
de sens, ce traitement n'existe plus).

Closes #91

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:03:43 +02:00
94ef8b4133 Merge pull request 'fix(api): autorise l'origine WebView Capacitor dans CORS_ORIGIN' (#82) from fix/cors-origin-capacitor-app into main
All checks were successful
CI / lint (push) Successful in 2m42s
CI / build (push) Successful in 3m11s
CI / intent-service-test (push) Successful in 4m14s
CI / e2e (push) Successful in 8m41s
CI / test (push) Successful in 17m14s
CI / build-android (push) Successful in 5m5s
2026-09-07 08:39:55 +02:00
5e478188a2 fix(api): autorise l'origine WebView Capacitor dans CORS_ORIGIN
All checks were successful
CI / lint (push) Successful in 1m44s
CI / build (push) Successful in 1m44s
CI / intent-service-test (push) Successful in 4m6s
CI / e2e (push) Successful in 7m5s
CI / test (push) Successful in 15m50s
CI / build-android (push) Has been skipped
L'app Android n'arrivait a se logger/inscrire nulle part (204 +
"erreur inattendue" cote client) : CORS_ORIGIN n'etait jamais defini en
prod (docker-compose.yml ne le passe pas a `app`), donc il retombait
sur son default `http://localhost:5173` (le serveur Vite de dev), qui
ne matche evidemment jamais l'origine reelle de l'appli packagee
(`https://localhost`, cf. capacitor.config.ts). Le site web n'est lui
jamais touche : il est servi depuis la meme origine que l'API en prod,
donc jamais de preflight CORS du tout sur /auth/*.

Le symptome trompeur vient du package `cors` : un preflight OPTIONS
recoit toujours 204, meme pour une origine rejetee — la difference
invisible sans inspecter les headers est l'absence de
Access-Control-Allow-Origin, qui fait ensuite bloquer le vrai POST
cote client sans jamais atteindre le serveur. Vu depuis les logs
serveur, la route a bien l'air "appelee" (le preflight matche le meme
chemin) alors que /auth/login lui-meme n'est jamais reellement recu.

CORS_ORIGIN devient une liste separee par des virgules (le type
`ExpressServerCoreOptions.corsOrigin` acceptait deja `string | string[]`)
et son default inclut desormais `https://localhost` — l'origine de
l'appli packagee est fixe et non secrete (contrairement au domaine du
site en prod, qui n'a de toute facon pas besoin d'etre dans cette
liste puisqu'il est same-origin), donc la coder en dur dans le default
couvre tout deploiement sans configuration supplementaire.

Verifie en local : script ad hoc qui demarre le vrai `createServer()`
et envoie un preflight OPTIONS /auth/login avec differentes origines —
`https://localhost` et `http://localhost:5173` recoivent maintenant
Access-Control-Allow-Origin, une origine arbitraire non listee
continue de ne pas le recevoir (pas de bypass en wildcard). Egalement
`pnpm --filter api exec tsc --noEmit` et `pnpm exec biome check` sur
les fichiers touches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 08:22:49 +02:00
8315f737d4 Merge pull request 'fix(auth): la déconnexion retombe sur / plutôt que /login' (#79) from fix/logout-redirect-landing into main
All checks were successful
CI / build (push) Successful in 3m4s
CI / lint (push) Successful in 3m8s
CI / intent-service-test (push) Successful in 4m54s
CI / e2e (push) Successful in 10m12s
CI / test (push) Successful in 17m13s
CI / build-android (push) Successful in 5m6s
2026-09-07 00:25:04 +02:00
21cd8f014d fix(auth): la deconnexion retombe sur / plutot que /login
All checks were successful
CI / lint (push) Successful in 3m1s
CI / build (push) Successful in 4m32s
CI / intent-service-test (push) Successful in 6m16s
CI / e2e (push) Successful in 11m28s
CI / test (push) Successful in 22m7s
CI / build-android (push) Has been skipped
RequireAuth.tsx affiche deja la LandingPage publique pour un visiteur
deconnecte exactement sur / (issue #41) — seul handleLogout naviguait
encore explicitement vers /login. Change juste la destination de
navigate() ; aucun autre changement necessaire.

Scenario Cypress mis a jour en consequence (le pas existant "the URL
should be the home page" suffit, pas besoin d'un nouveau pas).

Verifie en local, suite complete : pnpm lint, pnpm --filter web build,
pnpm run e2e (139/139, apres avoir nettoye un process vite zombie sur le
port 5173 laisse par une precedente invocation cypress dans cette meme
session — voir dev-environment-gotchas pour le detail).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:59:18 +02:00
6d25faa633 Merge pull request 'feat(mobile): proxie le téléchargement de l'APK via l'API (pas de CORS)' (#77) from feat/mobile-download-proxy into main
Some checks failed
CI / lint (push) Successful in 5m55s
CI / intent-service-test (push) Successful in 6m29s
CI / build (push) Successful in 6m36s
CI / test (push) Failing after 10m23s
CI / e2e (push) Successful in 13m7s
CI / build-android (push) Has been skipped
Reviewed-on: #77
2026-09-06 23:58:20 +02:00
3abb7217dd feat(mobile): proxie le telechargement de l'APK via l'API (pas de CORS)
All checks were successful
CI / lint (push) Successful in 2m59s
CI / build (push) Successful in 3m4s
CI / intent-service-test (push) Successful in 4m46s
CI / e2e (push) Successful in 9m33s
CI / test (push) Successful in 17m20s
CI / build-android (push) Has been skipped
Suite au fix de /releases/latest (precedent), l'utilisateur voulait un
lien qui declenche le telechargement du .apk directement, sans quitter
l'appli web. Verifie en direct : fetch("https://git.dev.kyuno.fr/api/...")
depuis le navigateur echoue avec "Failed to fetch" (confirme via un essai
en mode no-cors : la requete reseau aboutit bien, seule la reponse est
bloquee) — Forgejo n'envoie aucun header CORS sur son API Releases,
publique ou non. Ca ne concerne pas que la page d'accueil : le hook de
verification de mise a jour (#66, useAppUpdateCheck) faisait exactement le
meme fetch cross-origin depuis la WebView native, et echouait donc tout
aussi silencieusement en production sans que personne ne puisse s'en
rendre compte (le hook avale toute erreur reseau par design).

Ajoute un petit module apps/api/src/modules/mobile/ (2 routes publiques,
aucune ne touche la base de donnees) :
- GET /mobile/latest-release -> { version } (proxy JSON, remplace le fetch
  direct de useAppUpdateCheck)
- GET /mobile/download -> redirection 302 vers l'asset .apk reel de la
  derniere release (ou vers la page /releases/latest en repli si
  Forgejo est injoignable ou qu'aucun .apk n'est encore attache)

Cote frontend : LandingPage.tsx et UpdateBanner.tsx pointent maintenant
vers ce proxy (`${API_BASE_URL}/mobile/download`, target="_blank") au lieu
d'une URL Forgejo directe — un clic, telechargement immediat, jamais de
navigation hors de l'appli. useAppUpdateCheck.ts appelle desormais
apiClient.getLatestMobileVersion() au lieu d'un fetch direct.

Verifie en local (sans Postgres, ces routes n'en ont pas besoin) contre
les vraies donnees Forgejo :
  GET /mobile/latest-release -> {"version":"v0.2.0"}
  GET /mobile/download -> 302, Location vers le vrai .apk signe

pnpm lint / tsc (api + web) / pnpm --filter web build : tous verts. Le
scenario Cypress "offers the Android app download" echoue encore en local
(meme souci pre-existant deja documente sur la PR precedente, confirme
independant du code par CI reel) ; mis a jour pour asserter le nouveau
href relatif.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:40:06 +02:00
7cbd4944bd Merge pull request 'fix(mobile): corrige les liens vers /releases/latest/download' (#76) from fix/forgejo-latest-release-url into main
All checks were successful
CI / lint (push) Successful in 2m45s
CI / build (push) Successful in 2m48s
CI / intent-service-test (push) Successful in 4m36s
CI / e2e (push) Successful in 9m20s
CI / test (push) Successful in 17m21s
CI / build-android (push) Successful in 5m12s
2026-09-06 22:21:37 +02:00
29bd413de3 fix(mobile): corrige les liens vers /releases/latest/download
All checks were successful
CI / lint (push) Successful in 3m3s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 4m28s
CI / e2e (push) Successful in 9m31s
CI / test (push) Successful in 17m39s
CI / build-android (push) Has been skipped
Verifie en conditions reelles apres le premier vrai succes de bout en
bout de release.yml (v0.2.0, Release Forgejo #12 avec batchcooking.apk
attache) : Forgejo, contrairement a GitHub, n'implemente pas le
raccourci /releases/latest/download/<fichier>. Seul /releases/latest
lui-meme resout (redirige vers la page de la release) :

  /releases/latest/download/batchcooking.apk -> 404
  /releases/latest                            -> 303 vers /releases/tag/v0.2.0 (OK)
  /releases/download/v0.2.0/batchcooking.apk  -> 200 (OK, mais pas "latest")

Corrige les deux endroits qui presumaient le raccourci GitHub (issue #65
et #66, deja mergees) pour pointer vers /releases/latest tout court : un
clic de plus via la page de release Forgejo pour atteindre le fichier,
mais un lien qui resout toujours plutot qu'un raccourci qui 404 en
permanence. Met a jour le test Cypress de la page d'accueil et le
commentaire de release.yml en consequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:03:14 +02:00
da3d080124 Merge pull request 'fix(release): installe curl avant les appels API de forgejo-release' (#75) from fix/forgejo-release-curl-install into main
All checks were successful
CI / lint (push) Successful in 3m4s
CI / build (push) Successful in 4m19s
CI / intent-service-test (push) Successful in 8m12s
Release / sanity-build (push) Successful in 10m22s
CI / e2e (push) Successful in 14m30s
Release / build-android-apk (push) Successful in 9m15s
Release / forgejo-release (push) Successful in 28s
Release / notify-portainer (push) Successful in 2s
CI / test (push) Successful in 15m3s
CI / build-android (push) Successful in 4m55s
2026-09-06 21:24:02 +02:00
7d4e7efcce fix(release): installe curl avant les appels API de forgejo-release
All checks were successful
CI / lint (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m17s
CI / build (push) Successful in 4m28s
CI / e2e (push) Successful in 9m9s
CI / test (push) Successful in 17m36s
CI / build-android (push) Has been skipped
3e vrai declenchement de release.yml (tag v0.2.0) : forgejo-release
echoue en 25s avec 'curl: command not found' — meme classe de probleme
que le docker manquant deja corrige pour sanity-build (#73) : ce runner
ne fournit pas ces outils par defaut, ci.yml's test job avait deja du
installer curl explicitement pour la meme raison.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:05:48 +02:00
f6e501d7b5 Merge pull request 'fix(release): remplace softprops/action-gh-release par l'API Forgejo native' (#74) from fix/forgejo-release-native-api into main
Some checks failed
CI / lint (push) Successful in 4m13s
CI / build (push) Successful in 3m28s
CI / intent-service-test (push) Successful in 6m51s
Release / sanity-build (push) Successful in 11m18s
CI / e2e (push) Successful in 15m16s
Release / build-android-apk (push) Successful in 9m38s
CI / test (push) Failing after 21m38s
CI / build-android (push) Has been skipped
Release / forgejo-release (push) Failing after 25s
Release / notify-portainer (push) Has been skipped
2026-09-06 20:42:03 +02:00
8693498373 fix(release): remplace softprops/action-gh-release par l'API Forgejo native
All checks were successful
CI / lint (push) Successful in 2m57s
CI / build (push) Successful in 2m59s
CI / intent-service-test (push) Successful in 4m14s
CI / e2e (push) Successful in 9m19s
CI / test (push) Successful in 17m23s
CI / build-android (push) Has been skipped
2e vrai declenchement de release.yml (tag v0.2.0, apres le fix du docker
manquant sur sanity-build) : forgejo-release echoue en 35s. Log complet :

  Creating new GitHub release for tag v0.2.0...
  GitHub release failed with status: 405
  (x3, puis "Too many retries. Aborting...")

Pas un souci de ref bare comme les autres actions de ce fichier (deja
corrige) : la requete de creation de release elle-meme repond 405 cote
Forgejo, une vraie incompatibilite d'API entre les deux plateformes sans
contournement connu cote configuration de softprops/action-gh-release.

Remplace ce step par deux appels directs a l'API REST native de Forgejo
(POST /repos/.../releases puis POST .../releases/{id}/assets) avec le
GITHUB_TOKEN deja fourni par le runner (permissions: contents: write
deja declare en tete de fichier) — evite toute dependance a la
compatibilite GitHub-Forgejo d'une action tierce. Pas d'equivalent a
generate_release_notes ici (fonctionnalite propre a GitHub, non repliquee
par Forgejo) : le corps de la release reste minimal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:23:48 +02:00
aeea0810cc Merge pull request 'fix(release): installe docker avant le build de sanity-build' (#73) from fix/sanity-build-docker into main
Some checks failed
CI / lint (push) Successful in 7m2s
CI / intent-service-test (push) Successful in 4m19s
CI / build (push) Successful in 3m12s
Release / sanity-build (push) Successful in 9m26s
CI / e2e (push) Successful in 11m39s
Release / build-android-apk (push) Successful in 9m48s
Release / forgejo-release (push) Failing after 35s
Release / notify-portainer (push) Has been skipped
CI / test (push) Failing after 20m59s
CI / build-android (push) Has been skipped
2026-09-06 19:57:57 +02:00
6d868809ac fix(release): installe docker avant le build de sanity-build
All checks were successful
CI / lint (push) Successful in 4m30s
CI / intent-service-test (push) Successful in 6m22s
CI / build (push) Successful in 3m15s
CI / e2e (push) Successful in 8m54s
CI / test (push) Successful in 20m1s
CI / build-android (push) Has been skipped
Premier vrai declenchement de release.yml (tag v0.2.0) : sanity-build
echoue en 17s avec 'docker: command not found' — ce runner Forgejo
self-hoste ne fournit pas docker par defaut dans les conteneurs de job,
meme constat deja rencontre par le job test de ci.yml qui l'installe
explicitement avant ses propres appels docker. sanity-build ne l'avait
jamais fait, presque certainement jamais reellement execute sur ce runner
depuis la migration vers Forgejo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 19:36:38 +02:00
ce168a758c Merge pull request 'fix(release): corrige release.yml pour Forgejo et y attache un APK signé' (#72) from fix/release-yml-forgejo into main
Some checks failed
CI / lint (push) Successful in 2m42s
Release / sanity-build (push) Failing after 17s
CI / build (push) Successful in 2m52s
CI / intent-service-test (push) Successful in 4m57s
CI / test (push) Failing after 10m56s
Release / build-android-apk (push) Successful in 11m48s
CI / e2e (push) Successful in 12m3s
Release / forgejo-release (push) Has been skipped
Release / notify-portainer (push) Has been skipped
CI / build-android (push) Has been skipped
2026-09-06 19:15:55 +02:00
854d795c5d fix(release): corrige release.yml pour Forgejo et y attache un APK signe
All checks were successful
CI / lint (push) Successful in 2m55s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m48s
CI / e2e (push) Successful in 9m34s
CI / test (push) Successful in 17m24s
CI / build-android (push) Has been skipped
release.yml datait d'avant la migration vers Forgejo et n'avait jamais ete
reteste : refs d'action bares (actions/checkout@v4, softprops/action-gh-
release@v2) sans le prefixe URL complet qu'exige ce runner self-hosted
(meme contrainte deja rencontree et documentee dans ci.yml).

Ajoute aussi ce que #65 attendait reellement : un job build-android-apk
(meme recette que le job build-android de ci.yml, issue #64) qui construit
et signe l'APK, deduit versionName/versionCode du tag pousse (vX.Y.Z ->
versionName X.Y.Z, versionCode majeur*10000+mineur*100+correctif) et
l'attache a la Release Forgejo sous un nom de fichier stable
(batchcooking.apk) via softprops/action-gh-release. C'est ce nom stable qui
permet a /releases/latest/download/batchcooking.apk (page d'accueil, #65)
de toujours pointer vers la derniere release sans jamais changer de lien.

build.gradle : versionCode/versionName deviennent surchargeables via
-PandroidVersionCode/-PandroidVersionName (defaut inchange : 1 / "1.0"
pour tout build qui ne passe pas ces proprietes, comme le job
build-android existant de ci.yml sur un simple push main).

Duplique le job de build Android plutot que de factoriser en workflow_call
reutilisable — le support des workflows reutilisables sur ce runner
Forgejo/act self-hoste n'est pas verifie, et cette recette de ~15 lignes
est deja eprouvee telle quelle dans ci.yml ; pas la peine de risquer une
nouvelle surprise de compatibilite pour economiser un peu de duplication.

Non teste de bout en bout dans cette PR : release.yml ne se declenche que
sur un vrai tag pousse (jamais sur une simple branche/PR), et pousser un
tag ici declencherait potentiellement un vrai redeploiement Portainer en
production si PORTAINER_WEBHOOK_URL est deja configure — a tester
deliberement avec l'utilisateur, pas par un tag de test improvise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:57:33 +02:00
372988d734 Merge pull request 'fix(ci): retombe sur upload-artifact v3 (v4+ non supporté par ce Forgejo)' (#71) from fix/upload-artifact-ghes-compat 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 4m23s
CI / e2e (push) Successful in 9m20s
CI / test (push) Successful in 17m41s
CI / build-android (push) Successful in 5m14s
2026-09-06 18:19:15 +02:00
998f0ac6bc fix(ci): retombe sur upload-artifact v3 (v4+ non supporté par ce Forgejo)
All checks were successful
CI / lint (push) Successful in 2m9s
CI / build (push) Successful in 2m25s
CI / intent-service-test (push) Successful in 4m34s
CI / e2e (push) Successful in 7m22s
CI / test (push) Successful in 16m44s
CI / build-android (push) Has been skipped
Le tout premier build-android complet et signé (keystore enfin correct)
échouait quand même à la toute dernière étape :

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  et download-artifact@v4+ ne sont pas actuellement supportés sur GHES.

v4+ exige l'API "Results" propre à github.com — non implémentée par ce
runner Forgejo self-hosted. v3 utilise encore l'ancienne API Artifacts,
seule compatible ici ; l'interface name/path utilisée par ce job est
identique entre les deux versions, aucun autre changement nécessaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:01:34 +02:00
27d82b49ca chore: relance la CI (test du nouveau keystore Android)
Some checks failed
CI / lint (push) Successful in 2m54s
CI / build (push) Successful in 2m56s
CI / intent-service-test (push) Successful in 4m48s
CI / e2e (push) Successful in 9m33s
CI / test (push) Successful in 17m22s
CI / build-android (push) Failing after 4m56s
Commit vide (aucun fichier modifie) — declenche le pipeline push sur main
pour verifier le nouveau secret ANDROID_KEYSTORE_BASE64 fourni par
l'utilisateur, ci.yml n'ayant pas de declenchement manuel (workflow_dispatch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:56:53 +02:00
644164b25a Merge pull request 'feat(landing): lien de téléchargement de l'app Android (#65)' (#68) from feat/landing-apk-download into main
Some checks failed
CI / lint (push) Successful in 2m54s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 5m7s
CI / e2e (push) Successful in 9m32s
CI / test (push) Successful in 17m30s
CI / build-android (push) Failing after 5m6s
2026-09-06 16:18:09 +02:00
966a0afa02 chore: relance la CI (#68)
All checks were successful
CI / lint (push) Successful in 47s
CI / build (push) Successful in 1m56s
CI / intent-service-test (push) Successful in 3m46s
CI / e2e (push) Successful in 7m40s
CI / test (push) Successful in 17m12s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:00:32 +02:00
68216cf852 Merge pull request 'fix(ci): corrige le bit exécutable de gradlew et le fiabilise en CI' (#70) from fix/gradlew-executable-bit into main
Some checks failed
CI / lint (push) Successful in 2m11s
CI / build (push) Successful in 2m26s
CI / intent-service-test (push) Successful in 4m2s
CI / e2e (push) Successful in 7m29s
CI / test (push) Successful in 16m30s
CI / build-android (push) Failing after 4m54s
2026-09-06 15:27:32 +02:00
9c6feab064 fix(ci): corrige le bit exécutable de gradlew et le fiabilise en CI
All checks were successful
CI / build (push) Successful in 3m5s
CI / lint (push) Successful in 3m12s
CI / intent-service-test (push) Successful in 4m16s
CI / e2e (push) Successful in 9m3s
CI / test (push) Successful in 17m43s
CI / build-android (push) Has been skipped
Le tout premier run réel du job build-android (après le merge de #64)
échouait avec './gradlew: Permission denied' — gradlew était suivi par
git en 100644 (non exécutable) au lieu de 100755, probablement perdu au
commit initial du projet Android natif (#63) sur cette machine Windows.

Corrige le mode suivi par git (git update-index --chmod=+x) et ajoute un
chmod +x défensif juste avant l'exécution dans ci.yml, puisque ce bit
n'a aucun sens sous Windows et peut être reperdu silencieusement par un
futur commit sur cette machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:09:21 +02:00
c0a07e8adc Merge pull request 'feat(ci): build, teste et empaquette un APK Android signé sur chaque push main' (#67) from feat/mobile-ci-android-apk into main
Some checks failed
CI / lint (push) Successful in 2m48s
CI / build (push) Successful in 2m51s
CI / intent-service-test (push) Successful in 4m9s
CI / e2e (push) Successful in 9m14s
CI / test (push) Successful in 17m18s
CI / build-android (push) Failing after 4m15s
2026-09-06 14:44:35 +02:00
92f364bc1f chore: relance la CI (9e collision infra sur le Postgres partagé)
All checks were successful
CI / build (push) Successful in 3m9s
CI / lint (push) Successful in 3m40s
CI / intent-service-test (push) Successful in 5m11s
CI / e2e (push) Successful in 7m29s
CI / test (push) Successful in 17m34s
CI / build-android (push) Has been skipped
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 14:26:31 +02:00
c662d02da6 chore: relance la CI (8e collision infra sur le Postgres partagé)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 4m7s
CI / build (push) Successful in 4m13s
CI / test (push) Failing after 12m19s
CI / intent-service-test (push) Successful in 9m10s
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 14:12:46 +02:00
e9c0a61aaf Merge pull request 'feat(mobile): vérification automatique de mise à jour de version' (#69) from feat/mobile-update-check into main
Some checks failed
CI / lint (push) Successful in 1m23s
CI / build (push) Successful in 4m37s
CI / intent-service-test (push) Successful in 4m42s
CI / test (push) Failing after 18m10s
CI / e2e (push) Successful in 17m29s
2026-09-06 14:11:54 +02:00
7bafe39d72 chore: relance la CI (collision infra — violation de clé étrangère pendant le seed Postgres partagé)
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build (push) Successful in 3m22s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 10m15s
CI / test (push) Successful in 18m22s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 12:25:05 +02:00
907a68eb8a chore: relance la CI (run précédent annulé — cause inconnue, pas un échec de test)
Some checks failed
CI / lint (push) Successful in 4m25s
CI / intent-service-test (push) Successful in 4m43s
CI / build (push) Successful in 4m5s
CI / test (push) Failing after 11m5s
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 12:09:19 +02:00
b3581b2fb5 chore: relance la CI (7e collision infra sur le Postgres partagé)
Some checks failed
CI / build (push) Successful in 4m5s
CI / lint (push) Successful in 4m25s
CI / intent-service-test (push) Successful in 6m47s
CI / e2e (push) Successful in 13m15s
CI / test (push) Failing after 20m7s
CI / build-android (push) Has been skipped
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 12:06:53 +02:00
cf7e519883 chore: relance la CI (4e collision infra sur le Postgres partagé)
Some checks failed
CI / intent-service-test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 12:00:53 +02:00
85dd1b6255 chore: relance la CI (6e collision infra sur le Postgres partagé)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 4m33s
CI / build (push) Successful in 3m50s
CI / intent-service-test (push) Successful in 5m28s
CI / test (push) Failing after 10m28s
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:54:51 +02:00
59352563c4 chore: relance la CI (3e collision infra — course d'upsert concurrente sur le seed Postgres partagé)
Some checks failed
CI / lint (push) Successful in 4m38s
CI / build (push) Successful in 3m28s
CI / intent-service-test (push) Successful in 6m34s
CI / test (push) Failing after 10m22s
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:49:11 +02:00
4f7a452924 chore: relance la CI (5e collision infra — course d'upsert concurrente sur le seed Postgres partagé)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m34s
CI / build (push) Successful in 3m17s
CI / intent-service-test (push) Successful in 5m16s
CI / test (push) Failing after 8m39s
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:45:08 +02:00
f37a61266f chore: relance la CI (4e collision infra — cette fois sur le cache d'actions du runner)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 4m7s
CI / build (push) Successful in 4m43s
CI / intent-service-test (push) Successful in 7m56s
CI / test (push) Failing after 11m11s
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:31:39 +02:00
09f6d426d3 chore: relance la CI (2e collision infra sur le Postgres partagé)
Some checks failed
CI / lint (push) Successful in 2m46s
CI / intent-service-test (push) Successful in 4m47s
CI / build (push) Successful in 4m31s
CI / e2e (push) Successful in 13m54s
CI / test (push) Failing after 17m45s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:29:51 +02:00
275cc2d500 chore: relance la CI (3e collision infra sur le Postgres partagé)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 1m6s
CI / build (push) Failing after 35s
CI / e2e (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:27:25 +02:00
a3a949ce08 chore: relance la CI (job test cassé par la course d'infra connue sur le Postgres partagé)
Some checks failed
CI / lint (push) Successful in 5m32s
CI / intent-service-test (push) Successful in 5m49s
CI / build (push) Successful in 4m29s
CI / test (push) Failing after 15m13s
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:08:16 +02:00
bf994c396a chore: relance la CI (2e collision infra sur le Postgres partagé, connexion coupée en cours de run)
Some checks failed
CI / lint (push) Successful in 2m15s
CI / build (push) Successful in 3m54s
CI / intent-service-test (push) Successful in 6m41s
CI / e2e (push) Successful in 16m11s
CI / test (push) Failing after 17m33s
CI / build-android (push) Has been skipped
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:04:32 +02:00
e90cadcc41 chore: relance la CI (#68)
Some checks failed
CI / lint (push) Successful in 4m15s
CI / intent-service-test (push) Successful in 5m49s
CI / build (push) Successful in 4m1s
CI / test (push) Failing after 10m15s
CI / e2e (push) Successful in 12m4s
Le job `test` de la run précédente a échoué sur une saturation du runner
(P1002 Prisma + timeout Docker), pas sur le code — le même job échouait
au même moment sur toutes les branches, `main` compris. Diff 100 % frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:02:53 +02:00
5d4d6be2dc feat(mobile): vérification automatique de mise à jour de version
Some checks failed
CI / lint (push) Successful in 6m25s
CI / intent-service-test (push) Successful in 5m1s
CI / build (push) Successful in 2m38s
CI / test (push) Failing after 11m54s
CI / e2e (push) Has been cancelled
Réutilise l'API Releases de Forgejo (publique, sans authentification) comme
source de vérité pour "quelle est la dernière version" — zéro nouvel
endpoint backend : useAppUpdateCheck compare App.getInfo().version
(@capacitor/app, nouvelle dépendance) au tag_name de
GET .../releases/latest, et pointe "Mettre à jour" vers l'URL stable de
téléchargement (issue #65).

Comparaison de version en logique pure (version-compare.ts), séparée du
hook effectful — testée isolément via un test de composant Cypress
(version-compare.cy.tsx, même patron que highlight-tech-steps.cy.tsx :
aucun montage, juste describe/it/expect sur des fonctions pures).

Natif uniquement (Capacitor.isNativePlatform()) : @capacitor/app et
@capacitor/preferences restent en import dynamique dans le hook, jamais
dans le bundle web (même patron que capacitor-token-store.ts) — vérifié
dans le build (deux petits chunks séparés, pas dans le bundle principal).

Vérification au démarrage à froid + à la reprise depuis l'arrière-plan
(appStateChange), avec un throttle de 6h et une mémorisation de la version
déjà rejetée dans @capacitor/preferences pour ne jamais re-harceler sur la
même version. Bannière discrète (pas une Dialog modale), bouton "Mettre à
jour" ouvrant le lien de téléchargement dans le navigateur système plutôt
qu'un flux PackageInstaller in-app.

Vérifié : pnpm lint, pnpm --filter web build, le test de composant (9/9),
et layout/smoke/planning-page e2e (18/18, la bannière étant un no-op sur
le web ne devait rien casser — confirmé).

Closes #66

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:52:35 +02:00
548cee721f chore: relance la CI (job test cassé par la course d'infra connue sur le Postgres partagé)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 2m22s
CI / intent-service-test (push) Successful in 5m35s
CI / build (push) Successful in 4m17s
CI / test (push) Failing after 10m27s
CI / e2e (push) Has been cancelled
18 échecs éparpillés (timeouts 10s sur des suites sans rapport entre elles,
PrismaClientKnownRequestError, un mismatch d'ID façon ré-seed concurrent) sur
373 passing — signature exacte de la course documentée dans
ci-forgejo-runner-constraints (postgres partagé + contention RAM/CPU du
runner), pas un vrai échec de code : ce PR ne touche que ci.yml/le projet
Android/le README, rien que la suite Mocha exerce différemment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:45:05 +02:00
76a99f0e77 feat(ci): build, teste et empaquette un APK Android signé sur chaque push main
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / build (push) Successful in 3m21s
CI / lint (push) Successful in 3m31s
CI / intent-service-test (push) Successful in 4m51s
CI / e2e (push) Successful in 10m34s
CI / test (push) Failing after 14m37s
Nouveau job build-android dans ci.yml, gardé par needs: sur les 5 jobs
existants (lint/test/intent-service-test/build/e2e) et limité à main via
if: github.ref == 'refs/heads/main' — c'est ce qui garantit que l'APK n'est
empaqueté qu'une fois la suite de tests existante verte, pas juste construit
en parallèle d'elle.

APK signé avec un vrai keystore de release (jamais généré par la CI —
Android refuse une mise à jour signée par une clé différente de celle déjà
installée) : ajoute signingConfigs.release à build.gradle, lu depuis
l'environnement (ANDROID_KEYSTORE_PATH/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD),
jamais codé en dur. *.jks/*.keystore désormais réellement ignorés par git
(étaient commentés dans le .gitignore généré par Capacitor).

README : documente les 5 secrets Forgejo requis et la génération du
keystore. Aucune suite de tests instrumentés Android n'existe (zéro code
natif au-delà de la coquille Capacitor) — "testée" est satisfait par le
gate sur la suite existante, pas par une suite native à inventer.

Risque documenté, pas résolu par avance : aucun build Android n'a jamais
tourné sur ce runner Forgejo auto-hébergé, contrairement aux 4 autres jobs.

Closes #64

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:27:55 +02:00
503781c95f feat(landing): lien de téléchargement de l'app Android (#65)
Some checks failed
CI / lint (push) Successful in 5m4s
CI / build (push) Successful in 3m38s
CI / intent-service-test (push) Successful in 6m41s
CI / test (push) Failing after 11m29s
CI / e2e (push) Successful in 14m56s
Sur la page vitrine `/`, sous la rangée de CTA du hero (pas un 3e bouton :
un compte reste nécessaire quelle que soit la plateforme), un lien discret
vers l'APK Android + une ligne d'aide.

- Cible : l'asset `batchcooking.apk` de la dernière release taguée Forgejo
  (`/releases/latest/download/…`, nom de fichier stable) — pas un artefact
  de CI. 404 tant que #64 + la correction de release.yml ne sont pas faits :
  état transitoire assumé, ce ticket n'ajoute que le lien.
- `mobileHint` : rappel honnête que c'est un .apk à sideloader, pas une
  distribution Play Store.
- i18n `landing.hero.mobileDownload` / `mobileHint` ; style discret dans
  landing-page.scss ; scénario Cypress de plus (présence + href du lien).

Vérifs : `pnpm build` + `pnpm lint` OK. `landing.feature` non lancé en
local (le hoist `tslib` de `@cypress/vite-dev-server` dans ce node_modules
est cassé par un `pnpm install --force` interrompu + un verrou concurrent
sur `@esbuild/win32-x64` ; `pnpm-lock.yaml` inchangé, la CI n'est pas
affectée) → couvert par le job `e2e` de la CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:26:50 +02:00
dcfb86d6c9 Merge pull request 'feat(mobile): projet natif Android Capacitor (#39)' (#63) from feat/capacitor-android-native into main
Some checks failed
CI / lint (push) Successful in 1m15s
CI / build (push) Successful in 1m9s
CI / intent-service-test (push) Successful in 3m31s
CI / e2e (push) Successful in 7m2s
CI / test (push) Failing after 14m7s
2026-09-06 10:09:13 +02:00
fd5841d045 feat(mobile): ajoute le projet natif Android Capacitor (#39)
All checks were successful
CI / lint (push) Successful in 4m59s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 6m5s
CI / e2e (push) Successful in 11m41s
CI / test (push) Successful in 20m16s
Dernière étape du portage mobile #39 : génère et versionne le projet natif
Android (`npx cap add android`) que les phases 1 à 3 (jeton Bearer, passe
responsive, plomberie web) préparaient déjà.

- `apps/web/android/` — projet natif Capacitor 8.5. Permission `INTERNET`
  présente par défaut, `applicationId` = `fr.kyuno.batchcooking`.
- Thème natif aligné sur la palette « Mise en Place » (`_theme.scss`) :
  `colors.xml` redéfinit `colorPrimary`/`Dark`/`Accent` (basilic/vermillon)
  par-dessus les défauts Material de la lib capacitor-android ; barre de
  statut basilic foncé, fond de fenêtre « porcelaine » pour ne pas flasher
  blanc au démarrage à froid ; `postSplashScreenTheme` pour la transition.
- Icône adaptative + splash (clair et sombre, toutes densités) générés
  depuis `apps/web/assets/logo.svg` (monogramme « bC », géométrie seule, pas
  de `<text>`) via `@capacitor/assets` ; sources et commande de régénération
  documentées dans `apps/web/assets/README.md`. `@capacitor/assets` n'est pas
  ajouté aux dépendances (il tire `sharp`, hors `onlyBuiltDependencies`) —
  lancé via `npx`.
- `.gitignore` racine : ignore `/.idea/` (Android Studio ouvre désormais le
  dépôt comme projet). `biome.json` : exclut `apps/web/android` du lint.
- README : section « Application mobile (Android) » (prérequis + flux de
  build `build:capacitor` → `cap sync` → Android Studio).

Le bundle web copié sous `android/app/src/main/assets/public/` reste
git-ignoré (régénéré par `pnpm build:capacitor && npx cap sync android`).
Build de l'APK + test sur appareil : à finir dans Android Studio — la CI
self-hosted ne build pas Android.

Vérifs : `pnpm build` + `pnpm lint` OK ; `npx cap sync android` OK ; tous
les XML natifs bien formés.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 09:32:24 +02:00
ab1cf421d9 Merge pull request 'fix(recipes): rend StepDescription tolérant à un techSteps absent' (#62) from claude/practical-swartz-648594 into main
All checks were successful
CI / lint (push) Successful in 3m51s
CI / build (push) Successful in 3m55s
CI / intent-service-test (push) Successful in 5m9s
CI / e2e (push) Successful in 9m1s
CI / test (push) Successful in 17m55s
2026-09-06 09:24:28 +02:00
8e55f3d3f9 fix(recipes): rend StepDescription tolérant à un techSteps absent
All checks were successful
CI / lint (push) Successful in 4m3s
CI / build (push) Successful in 4m36s
CI / intent-service-test (push) Successful in 5m53s
CI / e2e (push) Successful in 8m56s
CI / test (push) Successful in 18m54s
StepDescription faisait `useState(techSteps)` puis `liveTechSteps.flatMap(...)` :
quand la prop `techSteps` arrivait `undefined`, `.flatMap` levait
`TypeError: Cannot read properties of undefined (reading 'flatMap')`, remontée
par Cypress comme une erreur applicative non capturée. Observé de façon
intermittente sur le parcours « modifier une recette » : après enregistrement,
`RecipeFormPage` navigue vers `/recettes/:id`, dont le détail se rendait parfois
avant que `techSteps` ne soit peuplé.

- `StepDescription` : `techSteps` devient optionnel et `liveTechSteps` retombe
  sur `[]` (dans le `useState` et le `useEffect` de resync), pour que tous les
  consommateurs (`.flatMap`, `splitDescriptionSegments`,
  `findTechStepForTemperature`, lookup `activeStepTechStep`) aient toujours un
  vrai tableau — même posture « tolérer la donnée manquante, ne pas planter »
  que le reste du composant.
- `recipe-form.ts` : le mock de la recette 7 envoyait une étape sans `techSteps`,
  contrairement au vrai `StepView` et à tous les autres mocks de la suite —
  corrigé en `techSteps: []`.
- `recipes.cy.ts` : nouveau test de non-régression déterministe — un payload
  d'étape sans `techSteps` doit rendre la description en texte brut. Vérifié
  qu'il échoue bien (`flatMap` undefined) sans le correctif du composant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 09:04:36 +02:00
713d6c520d Merge pull request 'feat(web): page admin de CRUD des didacticiels (#44)' (#61) from feat/admin-tour-page into main
All checks were successful
CI / lint (push) Successful in 1m17s
CI / build (push) Successful in 1m10s
CI / intent-service-test (push) Successful in 3m33s
CI / e2e (push) Successful in 6m57s
CI / test (push) Successful in 16m11s
2026-09-06 07:27:16 +02:00
b39105ac27 feat(web): page admin de CRUD des didacticiels (#44)
All checks were successful
CI / lint (push) Successful in 2m10s
CI / build (push) Successful in 2m19s
CI / intent-service-test (push) Successful in 4m41s
CI / e2e (push) Successful in 7m19s
CI / test (push) Successful in 16m20s
L'UI par-dessus l'API `/admin/api/tours*`. Nouvelle entrée
`/admin/didacticiels` dans `AdminLayout` (i18n `admin.nav.tours`), route
sous `RequireAdmin`+`AdminLayout` dans `App.tsx`.

`ToursPage.tsx` : liste des scénarios à gauche (nom, état activé/désactivé,
nombre d'étapes) ; à droite l'éditeur du scénario sélectionné —
- renommer, activer/désactiver ;
- éditer la liste d'étapes (route, cible via `<select>` alimenté par
  `GET /tours/targets`, titre, contenu), réordonner (↑/↓), ajouter,
  retirer ;
- « Enregistrer » → un seul `PATCH /:id` qui remplace tout (name/enabled +
  étapes rénumérotées `order` = index), erreurs API affichées telles quelles ;
- « Supprimer le scénario » (confirmation `window.confirm`) ;
- « Nouveau scénario » → formulaire clé + nom → `POST /`.

`admin-client.ts` gagne `getTourScenarios` / `getTourTargets` /
`createTourScenario` / `updateTourScenario` / `deleteTourScenario`.

Tests Cypress (`admin-tours.cy.ts`, 4) : liste (activés + désactivés,
nombre d'étapes) ; édition nom + réordonnancement d'étapes → un seul PATCH
avec la liste complète réordonnée ; création → POST `{key,name}` ;
suppression après confirmation → DELETE. 4/4 en local ; `pnpm build` +
`pnpm lint` verts.

Closes #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 06:53:38 +02:00
d5ac6c4661 Merge pull request 'feat(web): moteur de tutoriel guidé driver.js (#42)' (#60) from feat/tour-engine into main
All checks were successful
CI / lint (push) Successful in 2m23s
CI / build (push) Successful in 2m29s
CI / intent-service-test (push) Successful in 4m5s
CI / e2e (push) Successful in 7m56s
CI / test (push) Successful in 16m29s
2026-09-06 06:53:23 +02:00
38fcf03417 feat(web): moteur de tutoriel guidé driver.js (#42)
All checks were successful
CI / lint (push) Successful in 3m55s
CI / build (push) Successful in 4m53s
CI / intent-service-test (push) Successful in 5m52s
CI / e2e (push) Successful in 10m30s
CI / test (push) Successful in 18m43s
Consomme le socle backend de #42/#44 (`GET /reference/tours`,
`GET/POST /profile/tours`). Deux tours : orientation générale (auto sur
`/`) et correction des métadonnées de recette (auto à l'ouverture du
premier popover de correction).

- Dépendance `driver.js` (~5 ko, MIT, zéro dépendance).
- `features/tour/tour-engine.ts` : wrapper vanilla autour de `driver.js` —
  gère ce que la lib ne fait pas seule : la navigation React Router entre
  étapes situées sur des routes différentes, et un `onFinish` unique
  (`"completed"` seulement si on atteint la fin, `"skipped"` pour la croix
  / Échap / clic sur l'overlay).
- `features/tour/TourContext.tsx` : `TourProvider` (charge scénarios +
  progression, keyed sur `user?.id` comme `ThemeContext`) et `useTour()`
  (`startTour(key)` / `isTourCompleted(key)` / `tours` / `isReady`).
  `isReady` est **calculé au render** contre l'id utilisateur chargé — pas
  stocké — pour qu'un changement de `user` le repasse à `false`
  immédiatement, avant qu'un effet enfant (l'auto-déclenchement) ne tourne
  sur des données de complétion périmées.
- `main.tsx` : `<TourProvider>` sous `ThemeProvider`.
- Attributs `data-tour-*` : liens de nav (`AppLayout`), grille / bouton `+`
  du 1er créneau / bouton Cuisiner (`PlanningPage`), page liste de courses
  (`ShoppingListPage`), description d'étape + section technique du popover
  (`StepDescription` / `TechStepCorrectionPopover`).
- Déclenchement auto : tour `general` sur `/` (`PlanningPage`, ref-gardé),
  tour `techStepMetadata` à `activeCorrection` non-null (`StepDescription`).
- Section « Revoir un tutoriel » dans `/parametres/preferences-utilisateur`
  — un `map` sur `tours`, un bouton « Revoir » par scénario.
- `tour.scss` re-skinne le popover `driver.js` sur les tokens du thème.
- i18n `tour.*` (chrome uniquement : boutons du popover, section « Revoir » ;
  le contenu des étapes vient de la base).

Tests Cypress (`tour.feature`, 5 scénarios) : auto-démarrage + élément
surligné pour un nouveau venu ; « Terminé » → complétion enregistrée
`skipped:false` ; croix → `skipped:true` ; un tour déjà vu ne se relance
pas ; « Revoir » le relance depuis les paramètres. Vérifié 5/5 en local.
`pnpm build` + `pnpm lint` verts.

Refs #42
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 06:17:06 +02:00
c21adeda72 Merge pull request 'feat(api): CRUD admin des scénarios de didacticiel (#44 — API)' (#59) from feat/admin-tour-crud into main
All checks were successful
CI / lint (push) Successful in 1m15s
CI / build (push) Successful in 1m11s
CI / intent-service-test (push) Successful in 3m35s
CI / e2e (push) Successful in 6m30s
CI / test (push) Successful in 16m13s
2026-09-06 06:16:49 +02:00
2beff139a3 feat(api): CRUD admin des scénarios de didacticiel (#44)
All checks were successful
CI / lint (push) Successful in 2m57s
CI / build (push) Successful in 2m59s
CI / intent-service-test (push) Successful in 4m22s
CI / e2e (push) Successful in 9m4s
CI / test (push) Successful in 17m32s
Deuxième volet backend du tutoriel guidé, sur le socle de #42/#44 : permet
à un admin de créer/éditer/supprimer les scénarios que
`GET /reference/tours` sert au moteur front.

`/admin/api/tours` (agrégateur `admin.routes.ts`, tout derrière
`requireAdmin`) :
- `GET /` — tous les scénarios (activés **et** désactivés) + étapes
  ordonnées.
- `GET /targets` — le catalogue `TourTarget` pour le sélecteur de cible de
  l'éditeur.
- `POST /` — crée un scénario vide (`key` + `name`) ; 409
  `TOUR_KEY_ALREADY_IN_USE` (nouveau code 4025) sur une clé prise.
- `PATCH /:id` — édite `name`/`enabled` **et remplace toute la liste
  d'étapes en bloc** (delete-all puis recreate avec `order` = index), même
  convention que `updateRecipe` pour les étapes d'une recette. `key`
  immuable. 404 `TOUR_NOT_FOUND` (id inconnu), 404 `TOUR_TARGET_NOT_FOUND`
  (nouveau code 4054, `targetKey` d'une étape hors catalogue — vérifié
  avant d'écrire, les étapes réelles restent intactes si la requête est
  rejetée).
- `DELETE /:id` — 204 ; les étapes et les `TourProgress` des utilisateurs
  cascadent.

Tests Mocha (`admin-tours.test.ts`) : 401 sans session admin sur chaque
route ; list (activés+désactivés) ; targets ; create→patch pour remplir
les étapes ; remplacement en bloc ; 409 clé dupliquée ; 404 cible
inconnue (sans toucher aux étapes existantes) ; 404 id inconnu ; delete +
cascade vérifiée ; un scénario désactivé disparaît de `/reference/tours`
mais reste dans la liste admin.

Refs #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:39:37 +02:00
d1948954a6 Merge pull request 'feat(api): modèle de données + API des scénarios de didacticiel (#42/#44 — socle backend)' (#58) from feat/tour-scenarios-backend into main
All checks were successful
CI / lint (push) Successful in 2m52s
CI / build (push) Successful in 3m7s
CI / intent-service-test (push) Successful in 4m54s
CI / e2e (push) Successful in 8m31s
CI / test (push) Successful in 17m21s
2026-09-06 05:39:22 +02:00
cd8ce42df7 feat(api): modèle de données + API des scénarios de didacticiel (#42/#44)
All checks were successful
CI / lint (push) Successful in 1m0s
CI / build (push) Successful in 1m16s
CI / intent-service-test (push) Successful in 3m34s
CI / e2e (push) Successful in 6m38s
CI / test (push) Successful in 15m49s
Socle backend, entièrement testable en CI, pour le tutoriel guidé. Le
moteur front (driver.js), l'instrumentation `data-tour-*` et le CRUD admin
viendront ensuite.

Schéma (`schema.prisma` + migration `20260906120000_tour_scenarios`) — les
4 modèles de #44, qui révisent le registre statique prévu par #42 :
- `TourTarget` (`key` PK + `description`) : catalogue des cibles de
  surlignage valides, seedé, comme `TechStep`/`Unit`.
- `TourScenario` (`key` stable, `name`, `enabled`) : un scénario rejouable.
- `TourScenarioStep` (`order`, `route?`, `targetKey`, `title`, `content`) :
  `title`/`content` sont du **texte libre**, pas des clés i18n (#44 :
  contenu administrable → texte, comme le nom d'une recette).
- `TourProgress` (`userProfileId` + `scenarioId`, PK composite) : présence
  d'une ligne = scénario vu (terminé *ou* passé).

Seed (`reference-seed-data.ts`) : les 9 `TourTarget` correspondant aux
`data-tour-*` à poser côté front, + les 2 scénarios de #42 (`general`,
`techStepMetadata`) **créés seulement s'ils sont absents** (un admin en
devient propriétaire ensuite). `techStepMetadata` reste court pour
l'instant — #36 (popover qui se ferme en pleine sélection) est ouvert, v1
ne couvre que les gestes qui marchent déjà.

API :
- `GET /reference/tours` (public) → scénarios **activés** + étapes
  ordonnées, forme consommée directement par le futur `useTour()`.
- `GET /profile/tours` → `{ completedTourKeys: string[] }`.
- `POST /profile/tours/:tourKey/complete` → upsert idempotent ; corps vide
  = terminé, `{ skipped: true }` = passé. Émet `analytics.recordEvent`
  `tour.completed` / `tour.skipped` (le dashboard admin agrège déjà tout
  type d'`AnalyticsEvent` génériquement — analytics de #44 quasi gratuites).
  404 `TOUR_NOT_FOUND` (nouveau code 4053) sur une clé inconnue/désactivée.

`reset-db.ts` truncate les 4 nouvelles tables (reseedées juste après).

Tests Mocha : `GET /reference/tours` (scénarios + étapes ordonnées,
exclusion d'un scénario désactivé) ; `/profile/tours` GET+POST (upsert
idempotent, `skipped`, 404 clé inconnue, 404 tour désactivé, isolation
par profil). Pas de base Postgres locale → exécutés en CI.

Refs #42, #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:08:43 +02:00
9174f80282 Merge pull request 'feat(web): page vitrine publique à la racine (#41)' (#57) from feat/landing-page into main
All checks were successful
CI / lint (push) Successful in 3m6s
CI / build (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 4m29s
CI / e2e (push) Successful in 8m51s
CI / test (push) Successful in 16m12s
2026-09-06 05:06:18 +02:00
da7b077098 feat(web): page vitrine publique à la racine (#41)
All checks were successful
CI / lint (push) Successful in 2m1s
CI / build (push) Successful in 2m16s
CI / intent-service-test (push) Successful in 3m47s
CI / e2e (push) Successful in 7m45s
CI / test (push) Successful in 14m48s
Un visiteur déconnecté sur `/` voit désormais une vraie page de
présentation avant qu'on lui demande de se connecter, au lieu d'être
renvoyé directement vers `/login`.

- `RequireAuth.tsx` devient sensible au chemin : dans sa branche `!user`,
  `pathname === "/"` rend `<LandingPage />` ; toutes les autres routes
  protégées redirigent vers `/login` exactement comme avant. Aucun
  changement de la table de routes — `/` reste `<PlanningPage>` niché sous
  `RequireAuth`+`AppLayout`, et `AppLayout` (donc la sidebar) ne monte
  jamais pour la vitrine.
- `pages/landing/LandingPage.tsx` + `landing-page.scss` : hero (accroche +
  illustration SVG dessinée à la main dans le langage de `lucide-react`,
  zéro dépendance ni asset externe) + deux CTA, grille de 4
  fonctionnalités (icônes lucide), section « aperçu », CTA final. Copie
  entièrement sous le namespace i18n `landing.*`.
- `index.html` : favicon SVG (monogramme « bC ») + `<meta description>` —
  `/` est maintenant une page publique partageable.
- Les 3 captures d'écran réelles de la section « aperçu » sont servies
  statiquement depuis `public/landing/` (un fichier manquant retombe sur
  son `alt`, ne casse pas le build) ; elles restent à produire par un
  humain avec l'app lancée sur des données réelles — cf.
  `public/landing/README.md`.
- Tests Cypress `landing.feature` : vitrine visible déconnecté sur `/`,
  CTA vers `/login` et `/signup`, autre route protégée toujours redirigée,
  visiteur connecté voit bien le planning. `smoke.cy.ts` mis à jour pour
  tester la redirection depuis `/recettes` (puisque `/` ne redirige plus).

Closes #41
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:33:41 +02:00
961aa151d4 Merge pull request 'feat(web): plomberie Capacitor Android — dépendances, config, routeur, token store (portage mobile #39, phase 2)' (#56) from feat/capacitor-android-shell into main
All checks were successful
CI / lint (push) Successful in 2m41s
CI / build (push) Successful in 3m30s
CI / intent-service-test (push) Successful in 4m21s
CI / e2e (push) Successful in 7m44s
CI / test (push) Successful in 16m19s
2026-09-06 04:33:25 +02:00
0ba6b2f687 chore: relance la CI (run 109 : échec du checkout git côté runner, pas du code)
All checks were successful
CI / lint (push) Successful in 2m31s
CI / build (push) Successful in 2m40s
CI / intent-service-test (push) Successful in 4m18s
CI / e2e (push) Successful in 9m12s
CI / test (push) Successful in 16m48s
Les 5 jobs de la run 109 ont échoué tôt sur « git clone: some refs were not
updated » pendant l'étape de checkout, avant même `pnpm install`/`pnpm lint`.
Rien à voir avec cette branche : `pnpm install --frozen-lockfile`, `pnpm lint`
et `pnpm build` sont verts en local.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:15:50 +02:00
ff36cefcde feat(web): plomberie web pour la coquille Capacitor Android (#39 phase 2)
Some checks failed
CI / lint (push) Failing after 1m59s
CI / build (push) Successful in 3m55s
CI / test (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / e2e (push) Has been cancelled
Tout ce qui est vérifiable en CI de la phase 2 du portage mobile. La
génération du projet natif (`npx cap add android`), le build de l'APK et
le test sur appareil restent une étape manuelle (pas d'Android Studio ici)
— cf. le corps de l'issue #39.

- Dépendances : `@capacitor/core`, `@capacitor/android`,
  `@capacitor/preferences` (+ `@capacitor/cli` en dev), toutes en 8.5.x.
- `capacitor.config.ts` : `appId` `fr.kyuno.batchcooking`, `webDir: "dist"`.
- `apps/web/src/api/capacitor-token-store.ts` : implémentation `TokenStore`
  adossée à `@capacitor/preferences`, branchée via `setTokenStore()` (le
  point d'injection posé en phase 1). Importée dynamiquement dans
  `main.tsx` uniquement quand `Capacitor.isNativePlatform()` — le bundle
  web ne charge jamais `@capacitor/preferences` (vérifié : chunk séparé de
  0,5 ko jamais requis côté navigateur).
- `main.tsx` : `HashRouter` en natif (le bundle packagé sert `dist/`
  depuis l'origine locale de la WebView, sans réécriture des liens
  profonds vers `index.html`), `BrowserRouter` inchangé sur le web ;
  bootstrap async pour poser le token store avant le premier rendu.
- `build:capacitor` (`vite build --mode capacitor`) + `.env.capacitor.example` :
  le build packagé a besoin de l'URL absolue de l'API en dur (pas de
  "même origine que la page" possible).

Vérifié : `pnpm build` + `pnpm lint` verts ; Cypress smoke/layout/sidebar
14/14 en local (le refactor du bootstrap et le choix de routeur ne
cassent rien côté web).

Refs #39
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:56:15 +02:00
5ad9d644bd Merge pull request 'feat(web): passe responsive téléphone (portage mobile #39, phase 3)' (#55) from feat/mobile-responsive-pass into main
Some checks failed
CI / lint (push) Successful in 1m58s
CI / build (push) Successful in 2m15s
CI / intent-service-test (push) Successful in 4m20s
CI / e2e (push) Failing after 5m53s
CI / test (push) Successful in 14m59s
2026-09-06 03:56:00 +02:00
a8d86c7099 feat(web): passe responsive téléphone pour le contenu des pages (#39 phase 3)
All checks were successful
CI / lint (push) Successful in 2m28s
CI / build (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m27s
CI / e2e (push) Successful in 8m24s
CI / test (push) Successful in 15m28s
Réutilise la philosophie déjà en place pour la sidebar (défilement
horizontal plutôt que compression illisible) au lieu d'un nouveau patron
par page. Tout est strictement additif sous 640px — le rendu desktop est
inchangé.

- `AppLayout.scss` : `.app-content` passe de `--space-xl` (32px) à
  `--space-md` (16px) sous 640px — 64px de largeur utile récupérés sur un
  écran de ~375px.
- `planning-page.scss` : la grille 7 jours défile déjà horizontalement
  (`overflow: auto` + `min-width: 62rem`) ; ce qui casse au tactile, c'est
  le `✕` de suppression d'une recette (`opacity: 0` hors survol) — épinglé
  visible + cible tactile agrandie sous 640px, idem le bouton « + ».
- `dialog.scss` : `.dialog-panel` garde une gouttière latérale et gagne en
  hauteur utile sous 640px (le `--space-2xl` vertical est trop cher sur un
  écran court).
- `recipe-picker-dialog.scss` : le split 2 colonnes forcé du catalogue
  (plus spécifique que le `@media` de recipes.scss) repasse à une colonne
  sous 640px.

Tests : `layout.cy.ts` gagne un bloc « page content under 640px » —
padding de `.app-content`, grille sur son propre scroll sans déborder la
page, `✕` visible au tactile. 10/10 en local.

Refs #39
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:24:13 +02:00
b0ac42441f Merge pull request 'feat(auth): jeton Bearer en repli du cookie de session (portage mobile #39, phase 1)' (#54) from feat/mobile-bearer-auth into main
All checks were successful
CI / lint (push) Successful in 2m29s
CI / build (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m1s
CI / e2e (push) Successful in 8m25s
CI / test (push) Successful in 15m30s
2026-09-06 03:23:56 +02:00
5d7e4d6f6a chore: relance la CI (job test cassé par une course d'infra avec la run de main)
All checks were successful
CI / lint (push) Successful in 2m42s
CI / build (push) Successful in 2m45s
CI / intent-service-test (push) Successful in 4m22s
CI / e2e (push) Successful in 8m26s
CI / test (push) Successful in 15m35s
La run #104 a échoué au `beforeAll` racine (`seedReferenceData`, P2002 sur
`key`) parce que la run `main` #103 exécutait son propre job `test` au même
moment sur le conteneur Postgres `--name postgres` partagé. Runner idle,
aucune run concurrente cette fois. Code inchangé — build+lint verts en
local.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:07:17 +02:00
1d8c001035 feat(auth): accepte un jeton Bearer en repli du cookie de session
Some checks failed
CI / lint (push) Successful in 2m6s
CI / build (push) Successful in 4m0s
CI / intent-service-test (push) Successful in 6m47s
CI / test (push) Failing after 8m58s
CI / e2e (push) Successful in 9m28s
Phase 1 du portage mobile (#39) : purement additif côté navigateur, qui
continue d'utiliser le cookie `session` httpOnly inchangé.

- `POST /auth/login` et `POST /auth/signup` renvoient aussi le JWT dans le
  corps de la réponse (`AuthResponse` = `SafeUserProfile` + `token`), en
  plus de poser le cookie. Un client dont l'origine ne peut pas recevoir
  un cookie `SameSite=Lax` (WebView Capacitor) le stocke et le renvoie.
- `requireAuth` lit le cookie en priorité, puis se rabat sur un en-tête
  `Authorization: Bearer <jwt>` quand le cookie est absent. Les deux
  mécanismes coexistent, aucun n'est retiré.
- `apps/web` : `ApiClient` gagne une abstraction `TokenStore`
  (`get`/`set`/`clear`) ; l'implémentation par défaut est un no-op
  navigateur (le cookie fait tout). `_request` ajoute l'en-tête
  `Authorization` quand un jeton existe ; `login`/`signup` le stockent,
  `logout`/`deleteAccount` l'effacent. `setTokenStore` est le point
  d'injection de la version Capacitor (`@capacitor/preferences`), branchée
  en phase 2 quand la dépendance sera ajoutée.

Tests Mocha : `requireAuth` authentifie via l'en-tête `Authorization` sans
cookie, rejette un jeton Bearer invalide ou un schéma non-Bearer ;
`login`/`signup` exposent bien le jeton dans le corps.

Refs #39
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:47:57 +02:00
f8ec373691 Merge pull request 'feat(sources): ajoute ohmonbento.com comme source de recettes importable' (#53) from feat/ohmonbento-recipe-source into main
Some checks failed
CI / lint (push) Successful in 3m25s
CI / build (push) Successful in 3m24s
CI / intent-service-test (push) Successful in 5m12s
CI / e2e (push) Failing after 11m18s
CI / test (push) Successful in 19m0s
2026-09-06 02:47:32 +02:00
802f3d251b chore: relance la CI (job test de la run précédente bloqué par une course d'infra)
All checks were successful
CI / lint (push) Successful in 1m55s
CI / build (push) Successful in 1m51s
CI / intent-service-test (push) Successful in 3m52s
CI / e2e (push) Successful in 6m18s
CI / test (push) Successful in 14m43s
Le job `test` de la run #101 a lu un état de base incohérent pendant que
le `seedReferenceData` d'une run `main` concurrente (run #100, restée
bloquée) tournait encore — décalage de +5 sur les ids `tech_step`. Le code
de cette branche est inchangé et vérifié localement (9/9 Mocha, sans DB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:32:02 +02:00
2251aa93d7 feat(sources): ajoute ohmonbento.com comme source de recettes importable
Some checks failed
CI / lint (push) Successful in 4m8s
CI / build (push) Successful in 3m5s
CI / intent-service-test (push) Successful in 6m16s
CI / e2e (push) Successful in 8m15s
CI / test (push) Failing after 15m27s
6e adaptateur, le plus simple des scrapés : `fetchDetail`/`parse`
délèguent tels quels à `jsonLdRecipeAdapter` — une page recette porte un
`Recipe` WP Recipe Maker dans un `@graph` Yoast, déjà géré nativement par
`findRecipeNode`. `list()` : recherche WordPress `GET /?s={query}&paged={N}`
— **même URL que la requête soit vide (tout le catalogue) ou non**, aucun
branchement (contrairement à `750g.ts`). Scrape de cartes
`<div class="entry-thumb"><a href="/<slug>/"><img alt src>` — image dans
`src` (pas de lazy-load `data-src` ici), `href` relatif résolu contre
l'origine. Une page au-delà de la dernière renvoie une vraie 404 → fin de
liste propre (comme `marmiton.ts`). `rekeySourceError` dupliqué localement.

Enregistré dans `registerAllRecipeSources()`.

Tests : `ohmonbento.test.ts` (`stubFetchHtml`, pas de base) — scrape des
cartes, 404 = fin de liste, URL `/?s=` identique pour requête vide, query
+ page dans `/?s={q}&paged={n}`, erreurs re-labellisées `ohMonBento`,
`parse` sur un `@graph` Yoast + WPRM (`recipeYield` en tableau géré).
Vérifié localement, 9/9. + `sources-index.test.ts` couvre
l'enregistrement.

Docs : section `ohmonbento.ts` dans `backend-architecture.md`.

Closes #40

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 01:30:44 +02:00
8a29889175 Merge pull request 'feat(sources): ajoute quitoque.fr comme source de recettes importable' (#52) from feat/quitoque-recipe-source into main
Some checks failed
CI / lint (push) Successful in 2m40s
CI / build (push) Successful in 2m44s
CI / intent-service-test (push) Successful in 4m20s
CI / e2e (push) Successful in 12m15s
CI / test (push) Has been cancelled
2026-09-06 01:28:02 +02:00
4906accd85 feat(sources): ajoute quitoque.fr comme source de recettes importable
All checks were successful
CI / build (push) Successful in 3m28s
CI / lint (push) Successful in 3m33s
CI / intent-service-test (push) Successful in 4m54s
CI / e2e (push) Successful in 7m20s
CI / test (push) Successful in 15m33s
5e adaptateur, calqué sur `marmiton.ts` (le plus fin des wrappers
scrapés) : `fetchDetail`/`parse` délèguent tels quels à
`jsonLdRecipeAdapter` — une page recette quitoque.fr porte un `Recipe`
schema.org, seul élément d'un tableau JSON-LD de premier niveau, déjà géré
nativement par `findRecipeNode`. Seul `list()` est spécifique :
`GET /recettes?page=N` (page 1 implicite si curseur absent), scrape des
cartes `<a class="card__content_header" href="/recettes/…">` (image réelle
dans `data-src`, `src` n'étant qu'un placeholder lazy-load ; `href`
relatif résolu contre l'origine). Extraction agnostique à l'ordre des
attributs.

Pas de vraie recherche serveur sur le site → `query` est un filtre
sous-chaîne « best effort » sur les titres de la page courante (même
lacune assumée que `750g.ts`). Fin de liste = page sans aucune carte
(comme `marmiton.ts`). `rekeySourceError` dupliqué localement (750g /
manger-bouger en ont chacun une copie).

Enregistré dans `registerAllRecipeSources()`.

Tests : `quitoque.test.ts` (`stubFetchHtml`, pas de base) — scrape des
cartes, pagination `?page=N`, filtre par query, fin de liste, erreurs
re-labellisées `quitoque`, `parse` sur un JSON-LD tableau-de-premier-
niveau. Vérifié localement, 9/9. + `sources-index.test.ts` couvre
l'enregistrement.

Docs : section `quitoque.ts` dans `backend-architecture.md` (+ correction
de la ligne « n'enregistre que TheMealDB », devenue fausse).

Closes #37

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 01:11:48 +02:00
d1d68b0bf7 Merge pull request 'feat(planning): glisser-déposer un reste sur un autre créneau' (#51) from feat/leftovers-drag-drop into main
Some checks failed
CI / lint (push) Successful in 2m44s
CI / build (push) Successful in 2m48s
CI / intent-service-test (push) Successful in 4m26s
CI / test (push) Failing after 9m0s
CI / e2e (push) Successful in 9m16s
2026-09-06 01:06:10 +02:00
1bc9304cdc feat(planning): glisser-déposer un reste sur un autre créneau
All checks were successful
CI / lint (push) Successful in 4m35s
CI / build (push) Successful in 4m51s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 10m29s
CI / test (push) Successful in 17m43s
Dernière PR (4/4) du chantier « gestion des restes » (issue #34).

`@dnd-kit/core` (+ `@dnd-kit/utilities`) — première et seule utilisation
de DnD de l'app. `PlanningPage` monte un `<DndContext>` (Mouse + Touch +
Keyboard sensors, distance d'activation 5px pour ne pas voler le clic
« Jeter ») autour de la grille et du panier.

- `LeftoversBasket` : chaque ligne a une poignée `⠿` `useDraggable` — le
  nœud draggable est la poignée elle-même (petite), pas toute la `<li>`,
  pour qu'une détection de collision au pointeur (`pointerWithin`, repli
  `closestCenter` pour le clavier) résolve proprement vers la seule case
  sous le curseur. `data` porte `sourceItemId`/`recipeName`/`available`.
- `MealCell` : `useDroppable` (`data: { date, weekDay, meal }`),
  surbrillance `--drop-over` au survol. Un item « restes »
  (`sourceItem !== null`) porte un badge ♻️ sur sa pastille.
- Au drop, `onDragEnd` ouvre `LeftoverAssignDialog` (quantité, défaut =
  disponible, plafonné) → `POST /planning/items/leftover`
  (`apiClient.assignLeftover`) → nouvel item patché dans `planning.items`.

Tests : `planning-leftovers.feature` (Gherkin) — parcours complet drag →
confirmation quantité → pastille ♻️ visible → compteur du panier à jour ;
+ jet d'un reste depuis le panier. Le drag est piloté par événements
souris bruts (mousedown poignée → mousemove ×2 → mouseup cible).
Vérifié localement : 2/2, et aucune régression sur
`leftovers-basket.cy.ts` / `planning-page.cy.ts` / `planning.feature`.

i18n : `planning.leftovers.*` (badge, poignée, dialogue). Docs :
`frontend-architecture.md`.

Closes #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:47:49 +02:00
25b4fc97f0 Merge pull request 'feat(planning): panier des restes dans l'en-tête (lecture seule + jeter)' (#50) from feat/leftovers-basket into main
All checks were successful
CI / lint (push) Successful in 2m39s
CI / build (push) Successful in 2m44s
CI / intent-service-test (push) Successful in 4m27s
CI / e2e (push) Successful in 8m10s
CI / test (push) Successful in 18m49s
2026-09-06 00:32:41 +02:00
8278878898 feat(planning): panier des restes dans l'en-tête (lecture seule + jeter)
All checks were successful
CI / lint (push) Successful in 4m15s
CI / build (push) Successful in 4m12s
CI / intent-service-test (push) Successful in 4m55s
CI / e2e (push) Successful in 7m57s
CI / test (push) Successful in 16m14s
PR 3/4 du chantier « gestion des restes » (issue #34).

`LeftoversBasket` (`features/planning/`) : une icône 🧺 avec badge de
compte dans l'en-tête du planning, à côté de `WeekNavigator`. Ouvre un
popover listant les restes encore disponibles de la semaine affichée —
même patron montage conditionnel + fermeture au `mousedown` extérieur que
`CalendarPopover`.

Les lignes sont dérivées à chaque rendu de `planning.items` par
`collectLeftoverLines` (`leftovers-basket.ts`, pur) : chaque item « frais »
(`sourceItem === null`) dont `computeAvailableLeftover` est `> 0`, le
`leftoverUses` étant reconstitué depuis le tableau plat (les items
« restes » qui pointent vers lui) — aucun aller-retour API en plus.

Chaque ligne : recette, jour/repas d'origine, portions restantes, bouton
« Jeter » → `PATCH /planning/items/:id/discard-leftover` (`apiClient.
discardLeftover`), la réponse est patchée dans `planning.items` par id
(`handleLeftoverDiscarded`). État vide quand rien ne reste.

Le glisser-déposer d'un reste sur un autre créneau arrive en PR 4.

Tests : `leftovers-basket.cy.ts` (mode layout) — badge de compte, ouverture
/ fermeture au clic extérieur, contenu d'une ligne, état vide, « Jeter »
qui appelle le PATCH et retire la ligne. Vérifié localement, 3/3.

i18n : `planning.leftovers.*`. Docs : `frontend-architecture.md`.

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:16:00 +02:00
f146e65348 Merge pull request 'feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine' (#49) from feat/leftovers-assign-discard into main
Some checks failed
CI / build (push) Successful in 2m45s
CI / e2e (push) Successful in 8m13s
CI / lint (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m19s
CI / test (push) Failing after 11m10s
2026-09-06 00:08:08 +02:00
1c62625c47 feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine
All checks were successful
CI / lint (push) Successful in 52s
CI / build (push) Successful in 1m30s
CI / e2e (push) Successful in 5m38s
CI / intent-service-test (push) Successful in 3m32s
CI / test (push) Successful in 14m17s
PR 2/4 du chantier « gestion des restes » (issue #34).

Deux nouvelles routes sur `/planning` :
- `POST /planning/items/leftover` → `assignLeftoverPlanningItem`, 201.
  Crée un item « restes » (`sourcePlanningItemId` non nul) sur un créneau,
  même recette que la source (`recipeId` copié, jamais envoyé par le
  client). `portions` revérifié serveur contre le pool réellement
  disponible via `computeAvailableLeftover` → 409
  `LEFTOVER_QUANTITY_EXCEEDED` (nouveau code, 4024) si dépassé ; 404
  `PLANNING_ITEM_NOT_FOUND` si source inconnue / d'un autre foyer / elle-
  même un item « restes » (pas de restes-sur-restes).
- `PATCH /planning/items/:id/discard-leftover` → `discardLeftover`, 200.
  Jette tout le pool restant (`discardedPortions += disponible`) ;
  idempotent (rien à jeter → renvoyé inchangé, aucun évènement).

Exclusion : `shopping-list.service.ts` et `cooking-session.service.ts`
filtrent `items: { where: { sourcePlanningItemId: null } }` dans leur
`include` — un créneau qui ne fait que resservir un plat déjà cuisiné n'a
rien à acheter ni à cuisiner. `getPlanningForDate` ne filtre pas (la
grille affiche ces items).

Analytics : `planning.leftover_assigned` / `planning.leftover_discarded`.

Le mapping `PlanningItem` → `PlanningItemView` est factorisé
(`toPlanningItemView`) — 4 chemins le partagent désormais.

Tests (Mocha, DB) : `assignLeftoverPlanningItem` (succès + `recipeId`
copié, 409 sur dépassement, 404 source étrangère/inconnue/« restes »,
cascade à la suppression de la source) ; `discardLeftover` (jette le
disponible, idempotent, ne jette pas ce qui est déjà servi, 404) ;
non-régression liste de courses et session de cuisine ignorant les items
« restes ».

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:53:25 +02:00
5e34a250df Merge pull request 'feat(planning): sépare portions demandées et portions préparées' (#48) from feat/leftovers-portions-foundations into main
Some checks failed
CI / build (push) Successful in 2m43s
CI / e2e (push) Successful in 7m50s
CI / lint (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m0s
CI / test (push) Failing after 10m9s
2026-09-05 23:45:18 +02:00
eba0ea70bd feat(planning): sépare portions demandées et portions préparées
All checks were successful
CI / lint (push) Successful in 2m37s
CI / intent-service-test (push) Successful in 4m26s
CI / test (push) Successful in 15m8s
CI / build (push) Successful in 2m45s
CI / e2e (push) Successful in 8m6s
Première PR du chantier « gestion des restes » (issue #34) — les
fondations, sans encore d'UI de panier.

À l'ajout d'une recette au planning on ne saisit plus qu'un seul nombre,
`portionsRequested` (« combien ce créneau doit nourrir »). Le serveur en
dérive `portions` (ce qui est réellement préparé) via
`computeBatchedPortions` : arrondi au multiple supérieur du rendement de
la recette (on cuisine des lots entiers, jamais une fraction de plat).
L'écart est le futur pool de restes de l'item.

Modèle (`PlanningItem`, purement additif) :
- `portionsRequested` NOT NULL — migration nullable → backfill (`= portions`
  pour les lignes existantes, sans restes à l'époque) → verrou, même
  discipline que `ingredient_unit_catalog`.
- `sourcePlanningItemId` (auto-FK nullable, `onDelete: Cascade`) et
  `discardedPortions` (défaut 0) — posés maintenant, utilisés plus tard.

Partagé (`@batch-cooking/shared/logic/planning-portions.ts`) :
- `computeBatchedPortions(recipePortions, requested)` → `{ portions, leftover }`
- `computeAvailableLeftover(item)` → pool encore disponible (posé pour les
  PRs suivantes : validation d'écriture côté API, affichage du panier
  côté web — une seule formule, deux appelants)

API : `addPlanningItemSchema` prend `portionsRequested` ; `addPlanningItem`
calcule et stocke les deux valeurs + l'évènement analytics ; `PlanningItemView`
et `getPlanningForDate` exposent `portionsRequested`/`discardedPortions`/
`sourceItem`.

Front : `RecipePickerDialog` — champ « portions demandées » + indice en
direct « → N préparées, M en restes » quand l'arrondi crée un reste ;
envoie `portionsRequested`. `RecipeImportForm` — envoie `portionsRequested`
(un import atterrit à son propre rendement, donc reste nul).

Tests : `planning-portions.test.ts` (les deux exemples de #34 + bornes) ;
nouveau bloc `POST /planning/items` dans `planning.test.ts` (arrondi au
lot, plusieurs lots, borne exacte, 400 sur non-positif) ; fixtures
`planningItem.create` des suites existantes mises à jour ; scénario
Cypress planning ajusté (`portionsRequested` dans le corps).

Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
`frontend-architecture.md`.

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:29:58 +02:00
f01ac58fec Merge pull request 'fix(recipes): garde le popover de correction ouvert pendant la sélection d'un span' (#47) from fix/correction-popover-span-selection into main
All checks were successful
CI / lint (push) Successful in 2m30s
CI / intent-service-test (push) Successful in 4m14s
CI / test (push) Successful in 14m46s
CI / build (push) Successful in 2m37s
CI / e2e (push) Successful in 8m0s
2026-09-05 23:14:34 +02:00
e64bbc37e9 chore: relance la CI (job test cassé par une course d'infra)
All checks were successful
CI / lint (push) Successful in 2m56s
CI / build (push) Successful in 3m53s
CI / intent-service-test (push) Successful in 4m13s
CI / e2e (push) Successful in 7m57s
CI / test (push) Successful in 15m29s
Le job `test` a échoué dans le hook racine `beforeAll` sur un
`prisma.diet.upsert()` → P2002 « Unique constraint failed on (key) », un
symptôme classique de seed concurrent : deux jobs `test` (run 87 sur
`main` post-merge #46, run 88 sur cette branche) démarrés à 3 min
d'intervalle partagent le même conteneur Postgres `--name postgres` sur
l'hôte du runner. Aucun rapport avec ce correctif (100% frontend, vérifié
localement 10/10). `main` a son job `test` vert (run 87).
2026-09-05 22:58:38 +02:00
bc077b770e fix(recipes): garde le popover de correction ouvert pendant la sélection d'un span
Some checks failed
CI / lint (push) Successful in 2m12s
CI / build (push) Successful in 4m10s
CI / intent-service-test (push) Successful in 7m28s
CI / test (push) Failing after 9m18s
CI / e2e (push) Successful in 9m54s
Depuis la refonte #29-#32, attacher un ingrédient/ustensile/température à
une correction de technique demande de surligner le passage concerné dans
la description. Mais le `<p>` de la description est un frère du popover
dans le DOM, et le listener « clic extérieur » du popover (`mousedown`
hors de son propre DOM → `onClose`) fermait le popover dès le `mousedown`
qui démarre ce glisser-sélectionner — avant même le `mouseup`. Le
`mouseup` ouvrait alors un nouveau popover vierge pour le texte
sélectionné, traité comme une toute nouvelle proposition de technique (la
technique choisie et les métadonnées déjà ajoutées étaient perdues).

`TechStepCorrectionPopover` accepte désormais un prop optionnel
`descriptionContainerRef` (le `containerRef` que `StepDescription` a
déjà) : un `mousedown` à l'intérieur n'est plus un clic extérieur.
`StepDescription` pilote déjà explicitement chaque interaction dans la
description (`setActiveCorrection`/`setResolvedMetadataSpan`), donc retirer
cette fermeture concurrente ne perd rien ; un clic vraiment ailleurs ferme
toujours le popover.

Comble aussi le trou de couverture que `TechStepCorrectionPopover.cy.tsx`
prétendait déjà délégué à un scénario e2e inexistant : nouveau test dans
`recipes.cy.ts` avec une vraie sélection de texte (Range/Selection sur les
nœuds `data-offset`, séquence mousedown→mouseup réelle) — vérifié qu'il
échoue sans le correctif.

Closes #36

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 21:01:22 +02:00
ec26f32dd8 Merge pull request 'fix(recipes): stoppe le scroll infini des sources au lieu de boucler' (#46) from fix/source-scroll-loop into main
All checks were successful
CI / lint (push) Successful in 3m27s
CI / build (push) Successful in 4m22s
CI / intent-service-test (push) Successful in 4m58s
CI / e2e (push) Successful in 13m0s
CI / test (push) Successful in 19m55s
2026-09-05 21:00:22 +02:00
1cd886cb8a test(recipes): fiabilise le scénario 3 pages (scroll réel + log de curseurs)
All checks were successful
CI / lint (push) Successful in 2m7s
CI / build (push) Successful in 2m12s
CI / intent-service-test (push) Successful in 4m21s
CI / e2e (push) Successful in 6m11s
CI / test (push) Successful in 14m4s
Le scénario échouait en CI sur deux points de fragilité, pas sur le fond :

1. `IntersectionObserver` ne se redéclenche qu'à une *transition*
   d'intersection — avec des pages courtes la sentinelle restait visible en
   continu et seule la 2e page se chargeait. Les pages font maintenant 15
   éléments (la liste déborde son conteneur) et chaque page suivante est
   tirée par un `scrollTo("bottom")` explicite, répété 5 fois.
2. Une fois la liste débordante, `should("be.visible")` sur un élément
   sorti du conteneur `overflow:auto` échoue (même classe de bug que #1) —
   l'assertion "3e page chargée" fait maintenant `.scrollIntoView()` avant.

L'assertion anti-boucle porte sur le journal des curseurs demandés
(tolérance 2× pour le double-effet React StrictMode du serveur e2e), pas
sur un `Set.size` strict.

Vérifié localement : 7/7 avec le correctif, "Page 3 Item 1" jamais chargée
sans lui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:45:58 +02:00
c842fbb348 test(recipes): pilote le scroll dans le scénario 3 pages des sources
Some checks failed
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 2m39s
CI / intent-service-test (push) Successful in 4m18s
CI / e2e (push) Failing after 7m20s
CI / test (push) Has been cancelled
Le scénario ajouté supposait que l'IntersectionObserver se redéclenchait
tout seul avec une liste courte — faux : il ne se déclenche qu'à une
*transition* d'intersection. Les pages font maintenant 15 éléments (la
liste déborde son conteneur, la sentinelle démarre sous la ligne de
flottaison) et chaque page suivante est demandée via un
`scrollTo("bottom")` explicite, qui produit une vraie transition.

Avec l'ancien code bugué, la sentinelle stale rappelle « cursor-2 » au 2e
scroll : « Page 3 Item 1 » n'apparaît jamais et un curseur est redemandé
— les deux assertions échouent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:32:49 +02:00
c48625cb0c fix(recipes): stoppe le scroll infini des sources au lieu de boucler
Some checks failed
CI / lint (push) Successful in 3m0s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 7m41s
CI / test (push) Has been cancelled
Deux volets de l'issue #38.

**Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a
que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un
scroll normal (`hasMore` reste `true`). Il figeait la toute première
closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur
périmé — la liste s'allongeait en boucle avec les mêmes éléments et
`nextCursor` n'atteignait jamais son vrai `null`.
- `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à
  chaque rendu : l'observer appelle toujours la closure fraîche, sans
  réabonnement inutile (intention d'origine du `biome-ignore` conservée,
  le commentaire n'étant plus nécessaire).
- `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite
  « cette page n'a rien apporté de nouveau » comme une fin de liste même
  si la source renvoie encore un `nextCursor` — garde-fou contre une
  éventuelle heuristique de fin imparfaite côté adaptateur.

**Couverture recherche vide** — `theMealDbAdapter.list()` tapait
`/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes
(`nextCursor` toujours `null`). Une requête vide déroule désormais la
liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une
catégorie par pas de curseur, pour un vrai « tout parcourir ».

Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts
réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour
la marche par catégories (première page + curseur, suivi du curseur,
arrêt sur la dernière catégorie, page vide au-delà, requête blanche =
requête vide).

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:20:43 +02:00
41031b4474 Merge pull request 'feat(admin): pousse le monitoring en WebSocket au lieu du polling client' (#45) from feat/admin-monitoring-websocket into main
Some checks failed
CI / lint (push) Successful in 2m10s
CI / build (push) Successful in 2m16s
CI / intent-service-test (push) Successful in 4m24s
CI / e2e (push) Successful in 6m3s
CI / test (push) Failing after 11m50s
2026-09-05 20:11:50 +02:00
23be1e4d04 fix(admin): ferme le serveur socket.io en fin de suite de test monitoring
All checks were successful
CI / build (push) Successful in 2m18s
CI / lint (push) Successful in 2m21s
CI / intent-service-test (push) Successful in 3m55s
CI / e2e (push) Successful in 7m45s
CI / test (push) Successful in 14m30s
Le hook `after` de admin-monitoring-socket.test.ts appelait seulement
`broadcast.stop()` (qui ne fait que couper l'intervalle de sonde) et
`prisma.$disconnect()`, mais ne fermait jamais le serveur socket.io ni le
`http.Server` en écoute sur le port 0. socket.io garde alors vivants les
timers ping d'engine.io et la socket d'écoute ; comme Mocha tourne sans
`--exit` (.mocharc.json), ce seul handle en fuite empêche le process de se
terminer une fois le dernier test passé.

C'est ce qui bloquait le job `test` en CI : « N passing » affiché, puis
plusieurs heures d'attente jusqu'au kill du runner (runs #80/#81).

Hisse `io` au scope de la suite et le ferme dans `after` via `io.close()`
— ce qui déconnecte les sockets restantes, stoppe les timers d'engine.io
et ferme le `http.Server` attaché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 19:56:45 +02:00
13d92f8fa8 feat(admin): pousse le monitoring en WebSocket au lieu du polling client
Some checks failed
CI / lint (push) Successful in 2m1s
CI / build (push) Successful in 1m21s
CI / intent-service-test (push) Successful in 4m25s
CI / e2e (push) Successful in 6m12s
CI / test (push) Failing after 3h13m30s
Le tableau de bord /admin/monitoring fonctionnait en polling côté client
(15 s) déclenchant une sonde active côté serveur à chaque appel — deux
onglets admin ouverts = deux séries de sondes redondantes.

Inverse le mécanisme : le serveur sonde une fois par minute et pousse le
résultat aux clients connectés via WebSocket (socket.io, room
"monitoring"). Décisions actées avec l'utilisateur : l'endpoint REST
disparaît entièrement (plus de repli, 100% WebSocket) ; la sonde tourne
en continu dès le démarrage, sans lien avec la présence d'un admin
connecté (le plus simple des deux, coût des sondes négligeable).

Backend :
- ExpressServer (packages/express-tools) expose désormais son
  http.Server sous-jacent (créé explicitement, plus dans .listen()) —
  socket.io s'attache au serveur HTTP lui-même, pas à Express/un port.
- MonitoringBroadcastService (nouveau) : sonde immédiate au démarrage
  puis toutes les 60 s (réutilise getMonitoring() tel quel, aucune
  duplication), diffuse à la room "monitoring", rejoue le dernier
  résultat en cache à tout nouvel admin qui rejoint (pas d'attente
  jusqu'à une minute pour un onglet fraîchement ouvert).
- requireAdminSocket (nouveau) : middleware de handshake socket.io,
  équivalent WebSocket de requireAdmin — même vérification (cookie +
  JWT + tokenVersion), factorisée dans resolveAdminFromToken
  (require-admin.ts) puisque les deux ne diffèrent que par où ils lisent
  le cookie brut (un handshake socket.io ne passe pas par
  cookie-parser).
- GET /admin/api/monitoring supprimée (route + service de test associés).
- server.ts (pas app.ts) instancie socket.io — les tests supertest n'ont
  pas besoin d'un serveur WebSocket.

Frontend :
- MonitoringSocketClient (nouveau, api/monitoring-socket.ts) : wrapper
  socket.io-client, interface MonitoringSocketClientLike volontairement
  petite et injectable dans MonitoringPage (prop optionnelle) pour rester
  testable sans serveur réel.
- MonitoringPage : data et connectionState séparés — une coupure de
  connexion après un premier chargement affiche un badge "reconnexion en
  cours" sans jamais effacer le dernier board connu (repose sur la
  reconnexion automatique de socket.io-client). Seul le tout premier
  chargement affiche l'état plein-écran chargement/erreur.
- adminApiClient.getMonitoring() supprimée (plus utilisée).

Tests :
- Nouveau apps/api/test/admin-monitoring-socket.test.ts : vraie connexion
  socket.io-client contre un serveur réel démarré dans le test (port 0),
  authentification avec un admin seedé + cookie signé, vérifie la
  réception de monitoring:update et le rejet sans cookie admin valide.
  Les tests heartbeat de l'ancien admin-monitoring.test.ts (route
  /internal/tech-steps/heartbeat, sans rapport avec ce changement) sont
  déplacés vers tech-step-worker.routes.test.ts, leur router d'origine.
- Nouveau cypress/component/MonitoringPage.cy.tsx : monte la page avec un
  client factice, vérifie rendu des cartes, badge de reconnexion sans
  perte du board, états chargement/erreur — pas de test e2e Gherkin
  contre un vrai WebSocket (aucun mécanisme pour ça dans ce repo,
  fragile ; le test Mocha ci-dessus est la preuve bout-en-bout serveur).
- apps/web/cypress/e2e/admin-monitoring.cy.ts (REST) supprimé, remplacé
  par le test composant ci-dessus.

Vérification : pnpm lint clean, build complet du monorepo clean, suite
Cypress e2e complète 27/27 fichiers (114/114 tests), suite composant
complète 5/5 fichiers (54/54 tests, dont les 4 nouveaux). Suite Mocha
apps/api non exécutable cette session (Postgres/Docker injoignable,
comme pour #33) — le nouveau test socket.io n'a donc pas pu être
run-vérifié, seulement relu attentivement et vérifié par le lint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:44:39 +02:00
71c409cf32 Merge pull request 'fix(admin): isole l'API admin sous /admin/api pour éviter la collision avec les pages front' (#33) from fix/admin-monitoring-spa-route-collision into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build (push) Successful in 1m47s
CI / intent-service-test (push) Successful in 3m22s
CI / e2e (push) Successful in 6m20s
CI / test (push) Successful in 13m44s
Reviewed-on: #33
2026-09-05 14:24:58 +02:00
ba131bd8c8 fix(admin): isole l'API admin sous /admin/api pour éviter la collision avec les pages front
All checks were successful
CI / build (push) Successful in 2m53s
CI / lint (push) Successful in 2m55s
CI / intent-service-test (push) Successful in 5m6s
CI / e2e (push) Successful in 8m17s
CI / test (push) Successful in 15m19s
Un rafraîchissement (F5) sur /admin/monitoring affichait le JSON brut de
l'API au lieu de la page — même chose à craindre pour tout futur /admin/*
GET sans segment supplémentaire.

Cause : `server.mountRouter("/admin", adminRouter)` (app.ts) montait
l'API admin exactement sous /admin, la même racine que les pages
/admin/* d'apps/web, servies depuis la même origine/le même process en
prod (image Docker unique). Express matche par chemin, sans égard au
Accept — `GET /admin/monitoring` (route API) et la page du même nom
étaient en conflit direct ; l'API gagnait systématiquement (montée
avant serveStaticFrontend), donc chaque F5 sur cette page recevait le
JSON de l'API plutôt que index.html.

Seule /admin/monitoring collidait réellement aujourd'hui (seul
sous-routeur admin à répondre GET sur sa racine bare) mais le
mécanisme touche toute future route admin nommée comme sa page — d'où
un correctif structurel plutôt qu'un renommage ponctuel.

Correctif : l'API admin est montée sous /admin/api (app.ts), jamais
bare /admin. Toute la surface admin bouge en conséquence
(auth/metrics/monitoring/tech-steps/catalog) ; les pages front
(/admin/monitoring, /admin/catalogue, /admin/corrections...) sont
inchangées, seul admin-client.ts change de base URL. Mis à jour en
cohérence : commentaires JSDoc citant les routes (apps/api + apps/web +
packages/shared), specs/backend-architecture.md,
specs/batch-cooking-modele.md, les intercepts Cypress (globs + un
matcher regex qui avait échappé au premier passage) et les chemins
supertest du suite Mocha admin-*.test.ts.

Simplifie au passage admin-monitoring.cy.ts : le contournement
resourceType ajouté en #32 pour cette même collision n'a plus lieu
d'être une fois les chemins distincts (retour à un intercept déclaratif
simple) — le bug Cypress/Chrome qu'il avait mis au jour reste noté en
commentaire pour référence future.

Vérifié : pnpm lint clean ; build api+web clean ; suite e2e complète
28/28 fichiers, 116/116 tests verts (dont les 16 tests admin-*).
Suite Mocha apps/api non exécutable cette session (Postgres/Docker
injoignable) — chemins mis à jour mécaniquement en cohérence avec le
nouveau mount point, à faire tourner en CI/à la prochaine session avec
accès DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:48:07 +02:00
1ec846ed3d Merge pull request 'fix(recipes): corrige la numérotation des étapes faussée par le popover de correction' (#31) from fix/step-counter-scoped-to-direct-children into main
Some checks failed
CI / lint (push) Successful in 4m9s
CI / build (push) Successful in 4m2s
CI / intent-service-test (push) Successful in 6m34s
CI / test (push) Failing after 9m1s
CI / e2e (push) Successful in 8m9s
2026-09-05 11:30:49 +02:00
0569884ddc Merge pull request 'fix(admin-monitoring): remplace le filtre resourceType cassé par une vérification manuelle' (#32) from fix/admin-monitoring-intercept into fix/step-counter-scoped-to-direct-children
Some checks failed
CI / lint (push) Successful in 2m56s
CI / build (push) Successful in 3m1s
CI / intent-service-test (push) Successful in 5m26s
CI / e2e (push) Successful in 11m59s
CI / test (push) Failing after 16m11s
2026-09-05 11:30:39 +02:00
c1aba1f18c fix(admin-monitoring): remplace le filtre resourceType cassé par une vérification manuelle
Some checks failed
CI / lint (push) Failing after 24s
CI / build (push) Successful in 1m53s
CI / intent-service-test (push) Successful in 3m52s
CI / e2e (push) Successful in 7m41s
CI / test (push) Failing after 12m15s
`admin-monitoring.cy.ts` échouait de façon reproductible ("renders one
card per service": cy.wait timeout, "No request ever occurred").

Diagnostiqué par isolation : le matcher déclaratif `resourceType: "fetch"`
de Cypress 13.17.0 ne capte plus du tout la requête réelle de
AdminApiClient.getMonitoring() sur le Chrome 152 de cette machine — un
intercept `{resourceType: "fetch"}` sans filtre d'URL ne voit passer que
/admin/auth/me, jamais /admin/monitoring, alors que le `req.resourceType`
de cette même requête, lu depuis un handler matché par url/method seuls,
vaut bien "fetch" de façon fiable et reproductible. Le filtrage déclaratif
est cassé, pas la requête elle-même.

Remplace le matcher par un handler de fonction qui laisse passer la
requête de document (`cy.visit()` lui-même, sur la même URL que l'API en
same-origin/CI) via `req.continue()` et ne stub que le reste — vérifié
stable sur 3 runs consécutifs.

Corrige au passage le test "shows an error state" qui passait pour la
mauvaise raison : sans serveur API réel, une requête non interceptée
échoue aussi au niveau réseau, ce que MonitoringPage.catch() ne
distingue pas d'un vrai 500 — désormais aliasé et attendu comme le
premier test, pour prouver qu'il dépend bien du mock.

Suite e2e complète: 28/28 fichiers, 116/116 tests verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:20:57 +02:00
3b78582d29 test(recipe-form): corrige la visibilité flaky de l'étape d'ingrédient préchargé
All checks were successful
CI / lint (push) Successful in 2m39s
CI / build (push) Successful in 2m43s
CI / intent-service-test (push) Successful in 4m23s
CI / e2e (push) Successful in 8m0s
CI / test (push) Successful in 14m57s
Le step Gherkin partagé « the recipe should include the ingredient »
utilisait un `.should("be.visible")` nu sur `.ingredient-row__name`.
Sur le formulaire d'édition de recette, la liste d'ingrédients peut se
trouver sous le pli du scroll propre à `.app-content` (Nom/Description/
Photo/Portions/Visibilité/Régimes viennent avant) — un `.should`
seul ne scrolle pas automatiquement, contrairement aux commandes
d'interaction (`.click()`/`.check()`).

Repéré comme bug pré-existant et hors périmètre lors de la vérification
de #29/#30 (voir la note dans tech-step-metadata-editor-redesign.md).
Même correctif que « I should see the highlighted technique »
(common.steps.ts) et « I should see the highlighted temperature »
(recipes.ts) : `.scrollIntoView()` avant l'assertion.

Reproduit avant correctif (recipe-form.feature, scénario « Preloads an
existing recipe's ingredients when editing »: 4/5 puis 5/5 après le
correctif). Suite e2e complète: 27/28 fichiers verts (la seule
exception, admin-monitoring.cy.ts, est un flake distinct et
sans rapport, signalé séparément).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:00:47 +02:00
bd607b17f3 fix(recipes): corrige la numérotation des étapes faussée par le popover de correction
All checks were successful
CI / lint (push) Successful in 2m8s
CI / build (push) Successful in 1m34s
CI / intent-service-test (push) Successful in 3m51s
CI / e2e (push) Successful in 5m25s
CI / test (push) Successful in 13m54s
.recipe-detail-panel__steps utilisait un compteur CSS (counter-reset/
counter-increment: step) sur un sélecteur `li` — un descendant, pas
seulement les enfants directs. Depuis la refonte du popover de correction
(tech-step-metadata-editor-redesign), celui-ci imbrique ses propres <li>
plusieurs niveaux plus bas (une par ingrédient/ustensile/température
attaché, plus la liste du picker de technique — jusqu'à ~74 entrées) :
chacun incrémentait ce même compteur "step", faussant le numéro affiché de
toutes les étapes suivantes dès qu'une correction avec métadonnées
attachées (ou son picker de technique) était ouverte. C'est le bug
rapporté : "les étapes affichées sont numérotées n'importe comment".

Correctif : `li`/`img`/`p` → `> li`/`> img`/`> p` (enfants directs
uniquement) dans cette règle. Même principe pour `img`/`p` : leurs styles
(image bloc, paragraphe inline sans marge) ne doivent s'appliquer qu'au
propre <p>/<img> de l'étape, pas à ceux du popover imbriqué plus loin.

Test de non-régression ajouté (recipes.cy.ts) : ouvre une correction avec
ingrédient + ustensile attachés puis le picker de technique, vérifie via
getComputedStyle que ces <li> imbriqués n'ont pas rejoint la chaîne du
compteur (`counter-increment: none`) alors que les <li> des étapes
elles-mêmes l'ont toujours (`counter-increment: step 1`) — confirmé que ce
test échoue bien sans le correctif (`step 1` au lieu de `none`) avant de le
restaurer.

Vérifié : pnpm lint + build clean. recipes.cy.ts (9/9, dont le nouveau
test), recipes.feature (5/5), recipe-sources.feature (6/6).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:21:16 +02:00
81cdc55673 Merge pull request 'feat(recipes): permet de corriger la température d'un tech-step' (#29) from feat/tech-step-temperature-correction into main
Some checks failed
CI / lint (push) Successful in 3m23s
CI / build (push) Successful in 3m55s
CI / intent-service-test (push) Successful in 6m30s
CI / test (push) Failing after 9m7s
CI / e2e (push) Successful in 9m6s
2026-09-05 08:06:04 +02:00
df9693ffac Merge pull request 'feat(recipes): refond le panneau de correction des métadonnées de tech-step' (#30) from feat/tech-step-metadata-editor-redesign into feat/tech-step-temperature-correction
Some checks failed
CI / lint (push) Successful in 3m37s
CI / build (push) Successful in 3m46s
CI / intent-service-test (push) Successful in 5m19s
CI / e2e (push) Successful in 11m22s
CI / test (push) Failing after 17m48s
2026-09-05 08:05:53 +02:00
6c5978dd1c feat(recipes): refond le panneau de correction des métadonnées de tech-step
All checks were successful
CI / lint (push) Successful in 2m35s
CI / build (push) Successful in 2m38s
CI / intent-service-test (push) Successful in 4m23s
CI / e2e (push) Successful in 8m0s
CI / test (push) Successful in 15m1s
Suite à la maquette validée : redessine TechStepCorrectionPopover et ajoute
l'édition de la température, jusqu'ici la seule des 4 métadonnées sans
aucune interface (ni dans l'UI, ni côté API — voir la PR back-end empilée
sous celle-ci).

- StepDescription.tsx : le surlignage de température devient cliquable
  (un <button>, plus un <span> mort) — rouvre le popover de correction de
  la technique à laquelle il est rattaché (findTechStepForTemperature,
  résolu par span puisque la température n'a pas d'existence propre, elle
  est une métadonnée de clause comme les ingrédients/ustensiles).
- TechStepCorrectionPopover.tsx : réécrit. Les 4 sections (Technique,
  Ingrédients, Ustensiles, Température) restent toutes visibles en même
  temps — plus de bascule de tout le popover vers un sous-écran pendant
  l'ajout d'un ingrédient/ustensile/température, seule la section
  concernée affiche son propre mini-formulaire, les autres (dont le
  picker de technique) restent inchangées à côté. Le picker de technique
  se cache derrière "Changer" une fois une technique choisie (rien à
  décider une fois confirmée correcte). Chaque ingrédient/ustensile/
  température attaché s'affiche en ligne avec sa citation exacte du texte
  source (nouveau : la traçabilité vers le texte existait déjà en donnée,
  mais n'était affichée nulle part). Nouveau sélecteur à 3 modes
  (°C / Thermostat / Qualitatif) pour la température, cohérent avec le
  modèle de données (un seul des trois à la fois). Icônes SVG par
  catégorie (tag/feuille/poêle/thermomètre), même langage stroke-based
  que layouts/nav-icons.tsx.
- recipes.scss : nouveau langage visuel du popover (sections avec icône,
  lignes de métadonnées façon carte plutôt que puces, bouton "+ Ajouter"
  en pointillés plutôt qu'un lien texte, sélecteurs segmentés). Les deux
  surlignages (.step-tech-step, .step-temperature) passent de
  `cursor: help` à `cursor: pointer` — les deux sont maintenant de vrais
  déclencheurs cliquables.
- i18n : nouvelles clés (changeTechnique, temperatureSection,
  addTemperature, removeTemperature, temperatureMode.*) ; clé
  currentTechnique supprimée (le pictogramme remplace la phrase "Technique
  retenue : X") ; préfixe "+ " retiré de addIngredient/addUtensil (l'icône
  + le porte déjà visuellement).
- Tests : TechStepCorrectionPopover.cy.tsx largement réécrit (10 tests,
  dont 2 nouveaux sur la température) ; recipes.feature/recipes.ts : nouveau
  scénario Gherkin "cliquer un surlignage de température rouvre le popover
  de sa technique" (fixture Gratin dédiée), step "Changer" ajouté au
  scénario de relabel existant.

Vérifié : pnpm lint + build (shared/api/web) clean. Suites Cypress
exécutées localement (Chrome headless) : TechStepCorrectionPopover.cy.tsx
10/10, suite component complète 50/50, recipes.feature 5/5, recipes.cy.ts
8/8, recipe-sources.feature 6/6, planning-page.cy.ts 7/7. Un échec
pré-existant et sans rapport (recipe-form.feature, "Preloads an existing
recipe's ingredients when editing") reproduit identiquement avec ce
travail entièrement stashé — confirmé non lié.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:22:01 +02:00
5d4ed0dffb feat(recipes): permet de corriger la température d'un tech-step
All checks were successful
CI / lint (push) Successful in 2m20s
CI / build (push) Successful in 2m23s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 6m17s
CI / test (push) Successful in 14m12s
Jusqu'ici, submitTechStepCorrectionSchema (POST /recipes/:id/steps/:stepId/
corrections) acceptait ingredients/utensils pour compléter les métadonnées
d'une technique corrigée, mais aucun champ temperatures — StepTechStepTemperature
n'était jamais écrit que par toStepTechStepCreateInputs, lors d'un create/update
complet de recette. La température ajoutée en #22 n'avait jamais eu son
propre circuit de correction manuelle.

- packages/shared/src/schemas/recipe.ts : nouveau
  manualStepTechStepTemperatureInputSchema (celsius/gasMark/qualitative
  mutuellement exclusifs — exactement un des trois — même span start/end
  que ingredients/utensils). Le champ temperatures rejoint ingredients/
  utensils dans le même lot combiné (les trois voyagent ensemble : dès que
  l'un est présent, les trois sont résolus, un absent devient [] donc
  effacé — jamais une fusion partielle catégorie par catégorie).
- recipe-tech-step-correction.service.ts : applyManualCorrection écrit
  désormais StepTechStepTemperature (delete+recreate, même logique "le
  manuel remplace tout" que les deux autres tables). raw n'est jamais
  envoyé par le client — dérivé de description.slice(start, end), pour ne
  pas faire confiance à une copie cliente qui pourrait diverger du texte
  réel. loadVisibleStepOrThrow retourne désormais le texte complet de la
  description (pas seulement sa longueur), nécessaire pour cette dérivation.
- specs/backend-architecture.md + specs/batch-cooking-modele.md : les deux
  routes de correction et step_tech_step_temperature n'étaient documentées
  nulle part — comblé au passage.
- Tests Mocha ajoutés (celsius/gasMark/qualitative, rejet si zéro ou
  plusieurs champs renseignés, remplacement sans accumulation, dérivation
  de raw, span hors limites).

Périmètre : back-end uniquement (voir la maquette proposée précédemment).
Le popover de correction (apps/web) ne sera mis à jour que dans une PR
suivante, empilée sur celle-ci.

Vérifié : pnpm lint + build (shared, api) clean. Mocha non exécuté — pas
de Postgres/Docker accessible dans cette session (ni Bash ni PowerShell).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:53:14 +02:00
cb43c9c6c5 Merge pull request 'feat(cooking-session): déplie une recette de l'onglet Recettes au lieu de rediriger' (#28) from feat/cooking-recipes-inline-detail into main
All checks were successful
CI / lint (push) Successful in 2m50s
CI / build (push) Successful in 2m54s
CI / intent-service-test (push) Successful in 4m29s
CI / e2e (push) Successful in 8m9s
CI / test (push) Successful in 14m48s
2026-09-04 23:20:38 +02:00
bdc1b4c9e9 feat(cooking-session): déplie une recette de l'onglet Recettes au lieu de rediriger
Some checks failed
CI / lint (push) Successful in 52s
CI / build (push) Successful in 1m40s
CI / intent-service-test (push) Successful in 3m57s
CI / e2e (push) Successful in 6m32s
CI / test (push) Failing after 13m50s
Cliquer sur une recette de l'onglet Recettes de /cuisiner naviguait vers
/recettes/:id, quittant la session de cuisine en cours. La ligne devient un
bouton qui déplie désormais le détail complet de la recette directement
sous elle (RecipeDetailPanel, showActions=false — même traitement "aperçu"
que RecipePickerDialog), chargé à la demande via GET /recipes/:id, un seul
dépliant ouvert à la fois (même logique que le sous-menu Paramètres de la
sidebar). RecipesTab est désormais keyed par semaine comme PlanTab, pour
que changer de semaine referme un dépliant devenu obsolète.

Clé i18n recipesTab.viewRecipe supprimée (plus d'appelant).

Tests : cooking-session-page.cy.ts (déplier/replier, un seul ouvert à la
fois) + cooking-session.feature (scénario dédié). pnpm lint + pnpm --filter
web build clean ; 16/16 tests passants (Chrome headless).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:08:47 +02:00
9cde984543 Merge pull request 'feat(cooking-session): regroupe les tâches du stepper en mini-sections par recette' (#27) from feat/cooking-stepper-recipe-groups into main
All checks were successful
CI / lint (push) Successful in 2m27s
CI / build (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m17s
CI / e2e (push) Successful in 7m59s
CI / test (push) Successful in 14m36s
2026-09-04 19:34:41 +02:00
a24ef6a78b feat(cooking-session): regroupe les tâches du stepper en mini-sections par recette
All checks were successful
CI / lint (push) Successful in 2m20s
CI / build (push) Successful in 2m25s
CI / intent-service-test (push) Successful in 4m31s
CI / e2e (push) Successful in 6m6s
CI / test (push) Successful in 14m3s
Dans l'onglet Plan de /cuisiner, la liste de tâches d'une phase était un
flux plat — la recette concernée n'apparaissait qu'en petite ligne sous
chaque carte. Regroupe désormais les tâches par combinaison exacte de
recettes (groupTasksByRecipes, cooking-session.ts) : chaque groupe devient
une mini-section titrée par le(s) nom(s) de recette(s), un pool
merged-prep/merged-setup formant sa propre section partagée plutôt que
d'être rattaché à une seule recette. La ligne "pour X" par carte devient
redondante avec le titre de section et est retirée (clé i18n forRecipes
supprimée).

Couverture ajoutée : cooking-session-page.cy.ts (mode layout, 3 sections
distinctes sur la fixture à préchauffages multiples) + cooking-session.feature
(parcours Gherkin, section partagée + sections solo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:47:25 +02:00
4c0a72a1e3 Merge pull request 'fix(recipes): persiste les métadonnées de clause des tech-steps à l'édition' (#26) from fix/recipe-tech-step-metadata-on-update into main
All checks were successful
CI / lint (push) Successful in 57s
CI / build (push) Successful in 1m26s
CI / intent-service-test (push) Successful in 3m35s
CI / e2e (push) Successful in 7m35s
CI / test (push) Successful in 14m13s
Reviewed-on: #26
2026-09-04 18:19:20 +02:00
ec01fc0107 fix(recipes): persiste les métadonnées de clause des tech-steps à l'édition
All checks were successful
CI / build (push) Successful in 2m44s
CI / lint (push) Successful in 2m50s
CI / intent-service-test (push) Successful in 4m36s
CI / e2e (push) Successful in 8m12s
CI / test (push) Successful in 14m57s
`createRecipeInternal` écrivait, pour chaque technique détectée, ses
ingrédients / ustensiles / températures de clause ; `updateRecipe` et le
script `backfill-tech-steps.ts` n'écrivaient que le span de la technique.
Toute recette ré-enregistrée via l'UI (ou touchée par le backfill) perdait
donc silencieusement ces métadonnées — et `cooking-optimizer.ts` ne pouvait
plus détecter de conflit de four entre deux préchauffages dès qu'une recette
avait été éditée une fois (ni sur les imports antérieurs à la détection des
températures, jamais ré-enregistrés).

- `toStepTechStepCreateInputs` (nouveau, `lib/recipe-matching/`) : seule
  source de vérité du payload `techSteps.create`, utilisée par les trois
  appelants pour qu'ils ne divergent plus.
- `backfill-tech-steps.ts` : `createMany` -> `create` par ligne (createMany
  ne fait pas les écritures imbriquées) + doc à jour.
- Rattrapage automatique au déploiement : `model BackfillState` + migration
  `backfill_state`, script `backfill-runtime.ts` ajouté au CMD Docker après
  `seed-runtime`. Gardé par un marqueur (tourne une seule fois), n'échoue
  jamais le boot (une panne du backfill est loggée, le serveur démarre
  quand même, retente au prochain start).
- Test `PATCH /recipes/:id` : une température de clause survit à l'édition.
- `reset-db.ts` : `backfill_state` ajouté au TRUNCATE.

Vérifié : `tsc --noEmit` + `pnpm --filter api build` + `pnpm lint` OK ;
migration = SQL généré par Prisma pour le modèle, à l'identique. Mocha
`recipe.test.ts` non lancé (Postgres + intent-service requis, indisponibles
ici) — à jouer en CI / sur la machine de dev.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 09:08:15 +02:00
df0791a694 Merge pull request 'perf(tech-steps): corrige la largeur du textcat — démarrage ~5× plus rapide' (#25) from perf/nlp-textcat-width into main
All checks were successful
CI / build (push) Successful in 3m36s
CI / lint (push) Successful in 3m40s
CI / intent-service-test (push) Successful in 4m28s
CI / e2e (push) Successful in 8m56s
CI / test (push) Successful in 15m47s
Reviewed-on: #25
2026-09-04 08:14:40 +02:00
dfd5a2f650 merge(main): rattrape main (#23 stepper + optimiseur)
All checks were successful
CI / lint (push) Successful in 2m54s
CI / build (push) Successful in 2m50s
CI / intent-service-test (push) Successful in 4m26s
CI / e2e (push) Successful in 8m5s
CI / test (push) Successful in 14m48s
2026-09-03 23:26:20 +02:00
6324a87445 fix(tech-steps): recule sur 65536/unigramme apres regression F1 en CI
Some checks failed
CI / e2e (push) Waiting to run
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
Le premier essai (length=2048 + ngram_size=2) a fait chuter le F1 agrege de
`tech-step-eval.test.ts` de 0.815 a 0.753, sous le plancher de 0.8. Balayage
mesure (fr, 25 iters, ngram=1, jeu de dev = derniere utterance tenue hors
entrainement ; collisions = tokens distincts partageant un bucket, sur 920) :

  length=262144  dev 97.3%  conf 0.809    2/920 collisions   400 s   <- defaut
  length=65536   dev 97.3%  conf 0.809   10/920 collisions   106 s   <- retenu
  length=16384   dev 97.3%  conf 0.808   50/920 collisions    35 s
  length=2048    dev 97.3%  conf 0.816  305/920 collisions    13 s   <- livre puis recule

Deux corrections :

- ngram_size 2 -> 1. Les bigrammes rendent le classifieur nettement plus
  confiant (0.809 -> 0.919), ce qui degrade la justesse bout-en-bout :
  `_classifyClause` (tech-step-matcher.ts) ne retombe sur l'ancre NER que
  sous CONFIDENCE_THRESHOLD, donc une classification confiante mais fausse
  sur une technique voisine (blindBake au lieu de bake, poach au lieu de
  boil) passe le seuil et ecrase l'ancre correcte. C'etait la seule cause
  de la chute du F1.

- length 2048 -> 65536. A 2048, 305/920 tokens entrent en collision (33 %)
  et la confiance moyenne se decale (0.816 vs 0.809). 65536 : 10 collisions,
  confiance identique au bit pres au defaut spaCy, memes verdicts sur les
  probes difficiles. ~5x plus rapide (vs ~30x pour 2048), 15x moins de
  parametres. Descendre plus bas est possible mais reste du travail pour la
  phase ou le jeu d'evaluation aura ete elargi et pourra le valider.

initialize() fr+en (chargement + entrainement) : 208.9 s contre ~930 s.
Suite pytest : 46 passed en 219.8 s (l'essentiel est le fixture qui entraine
le vrai corpus des deux locales, session-scoped).

start_period / timeout d'attente : 300s -> 600s (plafond pour runner lent).

Non verifie ici : le gate F1 lui-meme (tech-step-eval.test.ts, Postgres +
service requis, pas de Docker dans cet environnement) -> CI. Les 6 autres
echecs du job `test` (resetDatabase / seedReferenceData : contrainte unique
sur `key`, timeouts de hook) sont pre-existants sur `main` @ 81f7cb9 et sans
rapport avec ce changement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:25:05 +02:00
b8411a2e91 Merge pull request 'feat(cooking-session): onglets Recettes/Plan + stepper d'étapes cochables' (#23) from feat/cooking-session-stepper into main
All checks were successful
CI / lint (push) Successful in 3m23s
CI / build (push) Successful in 3m16s
CI / e2e (push) Successful in 9m8s
CI / intent-service-test (push) Successful in 14m30s
CI / test (push) Successful in 25m19s
Reviewed-on: #23
2026-09-03 22:39:47 +02:00
393300205d perf(tech-steps): corrige la largeur du textcat, entrainement 32x plus rapide
Some checks failed
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 6m39s
CI / lint (push) Successful in 1m41s
CI / intent-service-test (push) Successful in 3m4s
CI / test (push) Failing after 12m43s
Le service mettait ~930 s (15-16 min) a devenir `healthy`, ce que le README
presentait comme "un compromis assume, pas un defaut de configuration a
corriger". Mauvais diagnostic : le cout ne venait ni des iterations ni de la
taille du corpus, mais du parametre `length` de `spacy.TextCatBOW.v3`, laisse
a son defaut spaCy de 262144 faute d'etre passe explicitement.

Avec `no_output_layer: False` et 74 techniques, la couche de sortie faisait
19 398 730 parametres mis a jour par Adam a chaque pas de gradient, pour une
table de hachage vide a 99 % (le corpus tient dans quelques milliers de
tokens distincts).

Mesure contre le vrai corpus (fr, 25 iterations, jeu de dev = derniere
utterance de chaque technique tenue hors entrainement) :

  length=262144 ngram=1  dev 97.3%  conf 0.809  394.7 s   <- avant
  length=2048   ngram=2  dev 97.3%  conf 0.919   12.3 s   <- apres

Meme accuracy, meilleure confiance, 32x plus rapide. Les bigrammes profitent
des locutions du corpus ("faire fondre", "laisser mijoter") qu'un sac
d'unigrammes ne distingue pas de leurs constituants isoles.

PipelineRegistry.initialize() (fr+en, chargement + entrainement) mesure a
28,5 s contre ~930 s. Suite pytest complete : 46 passed en 40,6 s.

- locale_pipeline.py : `_TEXTCAT_MODEL_LENGTH` / `_TEXTCAT_NGRAM_SIZE`
  explicites et commentes avec les mesures ; historique de calibration de
  `_TRAINING_ITERATIONS` corrige (il attribuait le cout aux iterations)
- README : sections "Temps de demarrage" et "Limitations connues" reecrites
  (la couverture d'evaluation, 26/74 techniques, y devient la limitation
  bloquante ; ajout des collisions de classes observees)
- docker-compose.yml / ci.yml : commentaires citant ~930 s corriges,
  start_period et timeout 1200s -> 300s (plafond pour runner lent, pas cible)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:01:06 +02:00
39e748adc9 Merge pull request 'feat(cooking-session): regroupement d'étapes sensible aux métadonnées d'action' (#24) from feat/cooking-optimizer-metadata into feat/cooking-session-stepper
All checks were successful
CI / lint (push) Successful in 1m12s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 6m3s
CI / intent-service-test (push) Successful in 13m11s
CI / test (push) Successful in 23m15s
2026-09-03 14:09:30 +02:00
355 changed files with 22539 additions and 2523 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
@ -61,17 +68,70 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
# spent queued in "Wait for any earlier test job" below. Also a runaway
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
# leaked handle would otherwise hang the job until the runner's global
# timeout (a `test` job zombied ~1h once). If a hung job hits this
# ceiling the runner removes its container, which unblocks the queue.
timeout-minutes: 45
steps: steps:
- name: Install curl and the docker CLI - name: Install curl and the docker CLI
run: | run: |
apt-get update && apt-get install -y curl docker.io apt-get update && apt-get install -y curl docker.io
# Serialize `test` across every concurrent run. Two `test` jobs must
# never overlap: they share one `--name postgres` container on the
# runner's shared docker network (next step), so their `prisma migrate
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
# pending migrations" when there should be some, `tech_step`/`diet` id
# shifts breaking unrelated assertions; and even past that they starve
# each other's RAM/CPU (each also trains a spaCy model) until
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
# re-trigger once the runner was idle.
#
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
# 1.26 and is silently ignored here. A lock object (network, container,
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
# trailing `if: always()` step after an earlier step fails, so there is
# no reliable in-job release. Instead, key off the one thing the runner
# *does* always clean up — the job container itself (removed on pass,
# fail, and cancel alike): wait while any lower-numbered `test` job's
# container is still running. Lower task id goes first, so two jobs
# that start together can't deadlock; nothing to release.
- name: Wait for any earlier test job
run: |
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
echo "this test job is task ${my_id:-<unknown, not serializing>}"
# ~25 min cap (75 * 20s): comfortably covers one earlier run
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
# wait exceeds this just fails and gets an empty-commit re-run.
for attempt in $(seq 1 75); do
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
if [ -z "$earlier" ]; then
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
exit 0
fi
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
sleep 20
done
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
exit 1
# Postgres as a plain background container instead of a `services:` # Postgres as a plain background container instead of a `services:`
# block — see this workflow's top-level `env.DATABASE_URL` comment for # block — see this workflow's top-level `env.DATABASE_URL` comment for
# why. Reachable as `postgres` from this job (the runner mounts the # why. Reachable as `postgres` from this job (the runner mounts the
# docker socket and runs jobs on a shared user-defined network, so a # docker socket and runs jobs on a shared user-defined network, so a
# `--name postgres` sibling resolves by name); the network is # `--name postgres` sibling resolves by name); the network is
# discovered from the job container itself rather than hardcoded. # discovered from the job container itself rather than hardcoded. The
# `docker rm -f postgres` below also clears a container left behind by
# the previous run (this runner has no reliable post-job cleanup), and
# the serialization step above means no other run is using it now.
- name: Start Postgres in the background - name: Start Postgres in the background
run: | run: |
net="$(docker inspect "$(hostname)" \ net="$(docker inspect "$(hostname)" \
@ -79,6 +139,15 @@ jobs:
| awk '{print $1}')" | awk '{print $1}')"
echo "job network: ${net:?could not determine the job container network}" echo "job network: ${net:?could not determine the job container network}"
docker rm -f postgres >/dev/null 2>&1 || true docker rm -f postgres >/dev/null 2>&1 || true
# Also drop any `postgres-<suffix>` container. A since-reverted
# attempt at per-run Postgres naming on this branch left some
# running on this persistent runner (their `if: always()` teardown
# never fired — this runner skips trailing steps after a failure),
# and nothing else removes them. A second idle Postgres here starves
# the runner's RAM and slows every query ~5x → `resetDatabase()`
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
# more, so this only ever clears that debris.
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
docker run -d --name postgres --network "$net" \ docker run -d --name postgres --network "$net" \
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \ -e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
postgres:16-alpine postgres:16-alpine
@ -121,11 +190,14 @@ jobs:
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 & uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished # `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk — # training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~540s (fr) / ~390s (en), # see its own README) — measured at ~3min combined (fr+en) against
# ~930s combined, against the current ~74-technique corpus (see # the current ~74-technique corpus, down from ~930s once
# docker-compose.yml's healthcheck for the same reasoning and why # `spacy.TextCatBOW.v3`'s `length` stopped being left at its
# this grew slightly from the original ~670s). # 262144 default (see `_TEXTCAT_MODEL_LENGTH` in
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done' # `locale_pipeline.py`, and docker-compose.yml's healthcheck for
# the same reasoning). The timeout stays well above that: it's a
# ceiling for a slow/contended runner, not a target.
timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy - run: pnpm --filter api exec prisma migrate deploy
@ -200,3 +272,90 @@ jobs:
# `component.devServer`), unlike `e2e` above which needs the real app # `component.devServer`), unlike `e2e` above which needs the real app
# running first. # running first.
- run: pnpm --filter web cy:run:component - run: pnpm --filter web cy:run:component
# Builds and packages a signed, downloadable Android APK on every push to
# `main` — issue #64. `needs:` on every other job is what actually makes
# this "build, TEST, and package" rather than just "build and package in
# parallel with tests that might still be failing": Forgejo/GitHub Actions
# has no native way for a job in a *different* workflow file to block on
# one here, so this has to live in this same file to get a real dependency
# rather than a same-push coincidence. `if:` (not a separate trigger) keeps
# every other job running on every branch as today — only this one is
# main-only.
#
# No dedicated Android test suite exists (zero native code beyond the
# Capacitor-generated shell) — "testée" is satisfied by gating on the 5
# existing jobs, not by inventing an instrumented test suite for this.
#
# Unverified on this runner as of writing: no Android build has ever run
# on this self-hosted Forgejo `act` runner (unlike lint/test/build/e2e,
# exercised for months). If SDK installation turns out too slow/heavy here,
# cache `~/.android/sdk`/`~/.gradle` (same idea as the Cypress binary cache
# in `e2e` above) rather than accepting a slow job on every push.
build-android:
runs-on: ubuntu-latest
needs: [lint, test, intent-service-test, build, e2e]
if: github.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
# Matches what Android Gradle Plugin 8.x (bundled with the
# Capacitor-generated project, see apps/web/android/build.gradle)
# expects — bump alongside AGP if that version ever moves.
java-version: "21"
- uses: https://github.com/android-actions/setup-android@v3
- run: pnpm install --frozen-lockfile
# Unlike the normal web build (served same-origin as the API), a
# packaged Capacitor build runs from the WebView's own local origin and
# needs an absolute, public HTTPS URL baked in at build time — see
# apps/web/.env.capacitor.example's own comment on this.
- name: Build le bundle web pour Capacitor
env:
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
run: pnpm --filter web build:capacitor
- run: pnpm --filter web exec cap sync android
# Le keystore de release est un secret généré une fois à la main (voir
# le README, section "Application mobile") — jamais régénéré ici, un
# nouveau keystore casserait la mise à jour de l'appli déjà installée
# (Android refuse une signature différente de celle déjà installée).
- name: Décode le keystore de release
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
- name: Build l'APK signé
working-directory: apps/web/android
env:
ANDROID_KEYSTORE_PATH: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# chmod défensif : le dev de ce repo se fait sous Windows, où le bit
# exécutable git de gradlew ne veut rien dire et peut être reperdu
# silencieusement par un futur commit — ne pas dépendre uniquement
# du mode suivi par git (voir aussi le fix de ce mode lui-même).
run: |
chmod +x ./gradlew
./gradlew assembleRelease
# v4+ exige l'API "Results" de GitHub, que ce runner Forgejo self-hosted
# ne supporte pas ("GHESNotSupportedError") — v3 utilise encore
# l'ancienne API Artifacts (compatible), seule version utilisable ici.
- uses: https://github.com/actions/upload-artifact@v3
with:
name: batchcooking-release.apk
path: apps/web/android/app/build/outputs/apk/release/app-release.apk

View file

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

6
.gitignore vendored
View file

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

View file

@ -207,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,80 @@ 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)
`apps/web` est empaqueté dans une coquille [Capacitor](https://capacitorjs.com/)
pour Android — le projet natif est versionné dans `apps/web/android/` (issue
\#39). L'authentification passe par un jeton Bearer stocké dans le stockage
sécurisé de Capacitor (le cookie de session `SameSite=Lax` ne part pas depuis
l'origine interne d'une WebView), et `main.tsx` bascule sur `HashRouter` quand
`Capacitor.isNativePlatform()` est vrai.
**Prérequis** : Android Studio (SDK Android + un JDK ; le JBR fourni avec
Android Studio suffit). Aucun serveur Postgres/Docker local n'est nécessaire —
l'appli parle à l'API HTTPS publique.
**Build** — depuis `apps/web/` :
```bash
# 1. URL publique de l'API, gravée dans le bundle empaqueté (le build web
# normal utilise une URL relative « même origine », impossible ici).
cp .env.capacitor.example .env.capacitor # puis renseigner VITE_API_URL
# 2. Build web en mode capacitor + synchronisation dans le projet natif.
pnpm build:capacitor
npx cap sync android
# 3. Ouvrir dans Android Studio (build/run/émulateur depuis l'IDE)…
npx cap open android
# …ou build/run en ligne de commande si un appareil/émulateur est prêt :
npx cap run android
```
Le bundle web copié (`apps/web/android/app/src/main/assets/public/`) est
git-ignoré : il est régénéré par `pnpm build:capacitor && npx cap sync android`.
Les icônes et le splash sont générés depuis `apps/web/assets/logo.svg` (voir
`apps/web/assets/README.md`). iOS est hors périmètre pour l'instant (nécessite
Xcode/macOS).
**CI — build automatique d'un APK signé** (`.github/workflows/ci.yml`, job
`build-android`, issue #64) : à chaque push sur `main`, une fois les 5 autres
jobs verts, un APK release signé est construit et déposé comme artefact de
run téléchargeable. Secrets du dépôt Forgejo requis (Réglages du dépôt →
Secrets) :
| Secret | Contenu |
|---|---|
| `MOBILE_API_URL` | URL HTTPS publique de l'API — même valeur que `VITE_API_URL` dans `.env.capacitor` |
| `ANDROID_KEYSTORE_BASE64` | Le keystore de release (`.jks`) encodé en base64 |
| `ANDROID_KEYSTORE_PASSWORD` | Mot de passe du keystore |
| `ANDROID_KEY_ALIAS` | Alias de la clé dans le keystore |
| `ANDROID_KEY_PASSWORD` | Mot de passe de la clé |
Le keystore doit être généré **une seule fois**, à la main, et rester stable
pour toujours : Android refuse d'installer la mise à jour d'une appli signée
par une clé différente de celle déjà installée.
```bash
keytool -genkeypair -v -keystore batchcooking-release.jks \
-alias batchcooking -keyalg RSA -keysize 2048 -validity 10000
base64 -w0 batchcooking-release.jks # → coller comme secret ANDROID_KEYSTORE_BASE64
```
Conserver le fichier `.jks` et ses mots de passe en lieu sûr en dehors du
dépôt (jamais commité — voir `apps/web/android/.gitignore`) : le perdre
empêche de publier toute future mise à jour de l'appli.
## Auth (apps/api) ## Auth (apps/api)

View file

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

@ -60,8 +60,11 @@ COPY --from=build /repo/apps/web/dist ./apps/web/dist
WORKDIR /repo/apps/api WORKDIR /repo/apps/api
EXPOSE 3000 EXPOSE 3000
# Applies pending migrations, then seeds the reference data (Diet/Category/ # Applies pending migrations, seeds the reference data (Diet/Category/
# Allergy — see src/scripts/seed-runtime.ts) before starting. Both steps are # Allergy — see src/scripts/seed-runtime.ts), then runs any pending one-time
# safe to repeat on every container start: migrate deploy only applies # data backfill (src/scripts/backfill-runtime.ts) before starting. migrate
# pending migrations, and the seed upserts by unique name. # deploy only applies pending migrations and the seed upserts by unique
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js"] # name, so both are safe to repeat every start; the backfill step gates
# itself on a `backfill_state` row (no-op once done) and is written never to
# fail the boot — see its doc comment.
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/scripts/backfill-runtime.js && node dist/server.js"]

View file

@ -23,10 +23,12 @@
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"argon2": "0.31.2", "argon2": "0.31.2",
"cookie": "^2.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@ -38,6 +40,7 @@
"chai": "^5.1.2", "chai": "^5.1.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"mocha": "^10.8.2", "mocha": "^10.8.2",
"socket.io-client": "^4.8.3",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"

View file

@ -0,0 +1,11 @@
-- CreateTable: idempotency ledger for one-time data backfills run from the
-- production image's start command (apps/api/Dockerfile CMD ->
-- src/scripts/backfill-runtime.ts). One row per completed backfill, keyed
-- by an opaque backfill name; presence of the row is the "already done,
-- don't repeat" signal. See the `BackfillState` model doc comment.
CREATE TABLE "backfill_state" (
"key" TEXT NOT NULL,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "backfill_state_pkey" PRIMARY KEY ("key")
);

View file

@ -0,0 +1,27 @@
-- Splits "portions to prepare" from "portions actually needed" on a
-- planning slot, and adds the two columns a leftovers-serving item needs
-- (its source item + how much of its own pool was thrown away). Purely
-- additive — no existing column renamed. See the `PlanningItem` model doc
-- comments and issue #34.
-- `discarded_portions` and `source_planning_item_id` are trivial adds.
ALTER TABLE "planning_item"
ADD COLUMN "discarded_portions" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "source_planning_item_id" INTEGER;
-- `portions_requested` is NOT NULL but the table may have rows already:
-- add it nullable, backfill, then lock it down — same discipline as the
-- `ingredient_unit_catalog` / `planning_item_portions` migrations. Every
-- pre-existing row predates this feature, so it had no leftovers:
-- requested == prepared at the time it was added.
ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER;
UPDATE "planning_item" SET "portions_requested" = "portions";
ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL;
-- Self-FK for a "leftovers" item pointing back at the item it draws from.
-- ON DELETE CASCADE: removing the source item removes everything served
-- from its pool too (same as the Planning -> PlanningItem cascade).
ALTER TABLE "planning_item"
ADD CONSTRAINT "planning_item_source_planning_item_id_fkey"
FOREIGN KEY ("source_planning_item_id") REFERENCES "planning_item"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,65 @@
-- Guided-tour scenarios, their steps, the catalogue of valid highlight
-- targets, and per-user "seen" progress. All four tables are new — plain
-- CREATEs, nothing to backfill. See issues #42 (the tour engine) and #44
-- (admin CRUD + the DB-backed model this migration implements, which
-- revises #42's originally-planned in-code registry).
-- CreateTable
CREATE TABLE "tour_target" (
"key" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "tour_target_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "tour_scenario" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tour_scenario_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tour_scenario_step" (
"id" SERIAL NOT NULL,
"scenario_id" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
"route" TEXT,
"target_key" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "tour_scenario_step_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tour_progress" (
"user_profile_id" INTEGER NOT NULL,
"scenario_id" INTEGER NOT NULL,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tour_progress_pkey" PRIMARY KEY ("user_profile_id","scenario_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tour_scenario_key_key" ON "tour_scenario"("key");
-- CreateIndex
CREATE UNIQUE INDEX "tour_scenario_step_scenario_id_order_key" ON "tour_scenario_step"("scenario_id", "order");
-- AddForeignKey
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_target_key_fkey" FOREIGN KEY ("target_key") REFERENCES "tour_target"("key") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -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")
@ -125,6 +124,9 @@ model UserProfile {
/// list regardless of that real-world cardinality. /// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin") administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference? preferences UserPreference?
/// Guided-tour scenarios this profile has seen (completed or skipped) —
/// see {@link TourProgress} and issues #42 / #44.
tourProgress TourProgress[]
/// Tech-step corrections this profile has submitted (any profile that can /// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author — /// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`). /// see `StepTechStepCorrection.correctorId`).
@ -132,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")
} }
@ -218,12 +225,36 @@ model PlanningItem {
weekDay String @map("week_day") weekDay String @map("week_day")
meal String meal String
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
/// Portions actually *prepared* for this slot — always a whole multiple
/// of `recipe.portions` (a dish is cooked in whole batches). Derived from
/// `portionsRequested` + the recipe's yield via `computeBatchedPortions`
/// (`@batch-cooking/shared`) in `addPlanningItem`, never sent raw by the
/// client. On a "leftovers" item (`sourcePlanningItemId` set) it always
/// equals `portionsRequested`.
portions Int portions Int
/// How many portions this slot must actually feed. For a fresh item
/// (`sourcePlanningItemId` null), `<= portions` — the gap is this item's
/// leftover pool. For a "leftovers" item, always `== portions` (serving
/// leftovers prepares nothing new, so it can't generate more — no
/// leftovers-on-leftovers).
portionsRequested Int @map("portions_requested")
/// Non-null only on a "leftovers" item: the item whose leftover pool it
/// draws from. `onDelete: Cascade` — deleting the source item also
/// deletes everything served from its leftovers (same reasoning as the
/// existing `Planning` -> `PlanningItem` cascade).
sourcePlanningItemId Int? @map("source_planning_item_id")
/// Portions of *this* item's leftover pool explicitly thrown away (the
/// basket's "jeter" button). Always 0 on a "leftovers" item (its own
/// pool is null by construction).
discardedPortions Int @default(0) @map("discarded_portions")
/// See `Planning.createdAt` — same admin-metrics-only timestamp. /// See `Planning.createdAt` — same admin-metrics-only timestamp.
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id]) recipe Recipe @relation(fields: [recipeId], references: [id])
sourcePlanningItem PlanningItem? @relation("PlanningItemLeftoverSource", fields: [sourcePlanningItemId], references: [id], onDelete: Cascade)
/// The "leftovers" items served from this item's own pool — inverse of `sourcePlanningItem`.
leftoverUses PlanningItem[] @relation("PlanningItemLeftoverSource")
@@map("planning_item") @@map("planning_item")
} }
@ -346,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")
@ -367,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
@ -671,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")
@ -701,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`.
@ -711,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")
} }
@ -967,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 {
@ -1064,3 +1114,145 @@ model WorkerHeartbeat {
@@map("worker_heartbeats") @@map("worker_heartbeats")
} }
/// Idempotency ledger for one-time data backfills run from the production
/// image's start command (`src/scripts/backfill-runtime.ts`, invoked in
/// apps/api/Dockerfile's CMD after `prisma migrate deploy` + `seed-runtime`).
///
/// The CMD re-runs on every container start, but a backfill here *rewrites*
/// existing rows (unlike `seed-runtime`, which only upserts and is safe to
/// repeat) — e.g. `step_tech_step_metadata_20260904` recomputes every
/// `StepTechStep` sequence against the intent service. So each backfill
/// gates itself on a row in this table: `key` present → already done, skip;
/// absent → run it, then insert the row. A schema migration only ever
/// creates the table structure; it can't run TypeScript against the intent
/// service, which is why this is a start-command step and not a migration.
model BackfillState {
key String @id
completedAt DateTime @default(now()) @map("completed_at")
@@map("backfill_state")
}
/// Catalogue des cibles de surlignage valides pour un didacticiel guidé —
/// même esprit que `TechStep`/`Unit`/`Diet` : une valeur de référence
/// maintenue par le code, seedée (`reference-seed-data.ts`). Chaque `key`
/// correspond à un attribut `data-tour-<key>` réellement posé quelque part
/// dans le frontend ; un admin choisit parmi ces cibles en construisant un
/// scénario, il ne peut pas en inventer une (poser un nouveau `data-tour-*`
/// reste un changement de code + une ligne seedée ici). Voir issues #42/#44.
model TourTarget {
key String @id
/// Libellé pour aider l'admin à choisir dans le formulaire, ex.
/// "Bouton + d'un créneau du planning".
description String
steps TourScenarioStep[]
@@map("tour_target")
}
/// Un scénario de didacticiel — une séquence d'étapes surlignées, rejouable
/// et éditable depuis l'admin (#44). `key` est une clé stable (comme
/// `Diet.key`/`Source.key`) : `"general"` / `"techStepMetadata"` pour les
/// deux scénarios seedés, libre pour un scénario créé par un admin.
model TourScenario {
id Int @id @default(autoincrement())
key String @unique
/// Libellé admin, ex. "Tour d'orientation".
name String
/// Désactiver sans supprimer — `GET /reference/tours` ne renvoie que les
/// scénarios activés.
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
steps TourScenarioStep[]
progress TourProgress[]
@@map("tour_scenario")
}
/// Une étape d'un scénario : (si `route` est défini) navigue vers cette
/// route, puis surligne l'élément portant `data-tour-<targetKey>` avec un
/// popover `title`/`content`. `title`/`content` sont du **texte libre**
/// saisi par l'admin, pas des clés i18n (#44 : le chrome de l'UI vient
/// d'i18next, le contenu administré dynamiquement est du texte, comme le
/// nom d'une recette).
model TourScenarioStep {
id Int @id @default(autoincrement())
scenarioId Int @map("scenario_id")
order Int
/// Route React Router à ouvrir avant cette étape ; `null` = rester sur la
/// route de l'étape précédente.
route String?
targetKey String @map("target_key")
title String
content String
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
target TourTarget @relation(fields: [targetKey], references: [key])
@@unique([scenarioId, order])
@@map("tour_scenario_step")
}
/// Présence d'une ligne = ce profil a *vu* ce scénario. Le terminer **ou**
/// le passer volontairement crée la ligne (pas de re-nag après un skip
/// délibéré). Révise la version de #42 (`tourKey: String` libre) en une
/// vraie FK vers `TourScenario` (#44).
model TourProgress {
userProfileId Int @map("user_profile_id")
scenarioId Int @map("scenario_id")
completedAt DateTime @default(now()) @map("completed_at")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
@@id([userProfileId, scenarioId])
@@map("tour_progress")
}
// -----------------------------------------------------------------------------
// Contact
// In-app "report a bug / suggestion" form — user-submitted, read-only for
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
// into a Forgejo issue).
// -----------------------------------------------------------------------------
/// A message sent via the app's contact form (bug, suggestion, other).
/// Written once by a signed-in user, never edited afterwards — consulted
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
/// even if the account that sent it is deleted in the meantime.
model ContactMessage {
id Int @id @default(autoincrement())
authorId Int? @map("author_id")
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
/// `CONTACT_MESSAGE_TYPES` const (same approach as
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
/// value is one line, no Postgres enum migration.
type String
message String
/// Technical context captured automatically at submit time, for triage —
/// all nullable (an older client, or a direct API call, can omit them).
pageUrl String? @map("page_url")
userAgent String? @map("user_agent")
appVersion String? @map("app_version")
createdAt DateTime @default(now()) @map("created_at")
/// Number of the Forgejo issue an admin created from this message via
/// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the
/// "Créer un ticket" button disappears (one message links to at most one
/// issue).
forgejoIssueNumber Int? @map("forgejo_issue_number")
/// The issue's html URL (`<base>/<owner>/<repo>/issues/<n>`) — stored
/// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays
/// correct even if that config changes later.
forgejoIssueUrl String? @map("forgejo_issue_url")
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@map("contact_message")
}

View file

@ -7,9 +7,11 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js"; import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js"; import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { contactRouter } from "./modules/contact/contact.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js";
@ -34,10 +36,11 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never // pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route). // makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger); server.addMiddleware(requestLogger);
// One allowed origin: the app (`CORS_ORIGIN`). The admin surface // Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
// (`/admin/*`) is served by this same API and consumed by `apps/web`'s // config/env.ts for why the packaged Android app's origin must always be
// own `/admin/*` routes — same origin as the rest of the app, so no // one of them). The admin surface (`/admin/*`) is served by this same API
// extra CORS entry. // and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
// rest of the app, so no extra CORS entry.
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => { server.addRoute("get", "/health", (_req: Request, res: Response) => {
@ -45,17 +48,33 @@ export function createServer(): ExpressServer {
}); });
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
// Admin application surface (`apps/web`'s `/admin/*` routes) — its own // Admin application surface — its own auth (`requireAdmin`, distinct
// auth (`requireAdmin`, distinct cookie/secret), never the end-user // cookie/secret), never the end-user session. Mounted at `/admin/api`,
// session. // *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too
server.mountRouter("/admin", adminRouter); // (`/admin/monitoring`, `/admin/login`...), and this API is served from
// the same origin/process as that frontend (see `serveStaticFrontend`
// below). A bare `/admin` mount put `GET /admin/monitoring` (API) and the
// page route `/admin/monitoring` in direct conflict — Express matches by
// path alone, so a hard navigation/refresh on that page was answered by
// this router's own handler (raw JSON) instead of falling through to the
// SPA's `index.html`. The `/api` segment guarantees no current or future
// admin page path can ever collide with an admin API path again.
server.mountRouter("/admin/api", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// In-app "signaler un bug / suggestion" form (issue #80) — session
// required, one POST. Admins read these rows via `/admin/api/contact`,
// never this router.
server.mountRouter("/contact", contactRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by // Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own // `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers // doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single // rather than nested under one of them since it isn't scoped to a single
// recipe/step the way `recipeRouter`'s own correction routes are. // recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter); server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
// mobile.service.ts's doc comment for why this can't just be called
// directly from the browser.
server.mountRouter("/mobile", mobileRouter);
server.mountRouter("/planning", planningRouter); server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);

View file

@ -13,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. */
@ -34,8 +48,42 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default("7d"), JWT_EXPIRES_IN: z.string().default("7d"),
/** Name of the httpOnly cookie carrying the session JWT. */ /** Name of the httpOnly cookie carrying the session JWT. */
AUTH_COOKIE_NAME: z.string().default("session"), AUTH_COOKIE_NAME: z.string().default("session"),
/** Origin allowed by CORS — must match wherever apps/web is served from. */ /**
CORS_ORIGIN: z.string().default("http://localhost:5173"), * Origin(s) allowed by CORS, comma-separated — parsed below into a
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
* package) and reused as-is for the Socket.IO handshake's own CORS config
* (`server.ts`, which needs its own since the WS upgrade never runs
* through `setupCore`'s middleware).
*
* Must always include `https://localhost`: the packaged Android app's
* WebView is served from exactly that fixed origin (Capacitor's default
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
* doc comment), and every request it makes to this API — `/auth/login`
* and `/auth/signup` included — is genuinely cross-origin, unlike
* apps/web's *browser* build, which is served from the very same origin
* as this API in production (see docker-compose.yml's `app` service) and
* so never needs an entry here at all. Missing this origin doesn't fail
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
* for an origin it's about to reject (it only *omits*
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
* looks identical to a normal one right up until the browser silently
* drops the real `POST` — surfacing client-side as a generic "unexpected
* error" with no 401/403 anywhere to point at the actual cause. Default
* covers both the Vite dev server and the packaged app out of the box; a
* real deployment's own web origin (e.g. a production domain) never needs
* adding here for the reason above, but the env var still allows
* extending/overriding this list (comma-separated) if that ever changes.
*/
CORS_ORIGIN: z
.string()
.default("http://localhost:5173,https://localhost")
.transform((value) =>
value
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
),
/** /**
* Absolute path to the built frontend (`apps/web/dist`), to serve * Absolute path to the built frontend (`apps/web/dist`), to serve
* alongside the API. Optional, no default — only set inside the * alongside the API. Optional, no default — only set inside the
@ -109,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

@ -191,6 +191,114 @@ export const UTENSILS: string[] = [
"mandoline", "mandoline",
]; ];
// Guided-tour highlight targets (issues #42/#44). Each `key` is the suffix
// of a `data-tour-<key>` attribute the frontend puts on the element the
// tour points at; `description` is the hint an admin sees when picking a
// target in the scenario editor. Reference data maintained here, like
// `TECH_STEPS`/`UTENSILS` — adding a real new target needs a frontend
// `data-tour-*` *and* a line here.
export const TOUR_TARGETS: Array<{ key: string; description: string }> = [
{ key: "nav-planning", description: "Lien « Planning » de la barre latérale" },
{ key: "nav-recipes", description: "Lien « Recettes » de la barre latérale" },
{ key: "nav-shopping-list", description: "Lien « Liste de courses » de la barre latérale" },
{ key: "planning-grid", description: "La grille du planning de la semaine" },
{ key: "planning-add-recipe", description: "Bouton « + » d'un créneau du planning" },
{ key: "planning-cook-button", description: "Bouton « Cuisiner » de l'en-tête du planning" },
{ key: "shopping-list-summary", description: "La liste de courses générée pour la semaine" },
{
key: "techstep-description",
description: "Le texte d'une étape de recette, avec les techniques surlignées",
},
{
key: "techstep-technique-picker",
description: "Le sélecteur de technique du popover de correction",
},
];
// The two tours filed in issue #42, as the DB-backed scenarios #44 defines.
// Only *created* if absent (an admin owns name/enabled/steps afterwards —
// see `seedTourScenarios`); `content`/`title` are plain French text, not
// i18n keys (#44). The second tour is deliberately short for now — issue
// #36 (correction popover closes mid-selection) is still open, so v1 only
// covers the gestures that already work (select text, pick a technique).
export const TOUR_SCENARIOS: Array<{
key: string;
name: string;
steps: Array<{ route: string | null; targetKey: string; title: string; content: string }>;
}> = [
{
key: "general",
name: "Tour d'orientation",
steps: [
{
route: "/",
targetKey: "nav-planning",
title: "Votre semaine, ici",
content: "Le planning est votre point de départ : ce que vous mangez, jour par jour.",
},
{
route: null,
targetKey: "nav-recipes",
title: "Vos recettes",
content:
"Retrouvez vos recettes, importez-en depuis d'autres sites, et filtrez selon vos préférences.",
},
{
route: null,
targetKey: "nav-shopping-list",
title: "La liste de courses",
content:
"Générée automatiquement à partir de tout ce que vous avez planifié pour la semaine.",
},
{
route: null,
targetKey: "planning-grid",
title: "Sept jours, cinq repas",
content: "Chaque case est un créneau. Ajoutez-y des recettes, ajustez les portions.",
},
{
route: null,
targetKey: "planning-add-recipe",
title: "Ajouter une recette",
content: "Ce bouton ouvre le sélecteur de recettes pour remplir un créneau.",
},
{
route: null,
targetKey: "planning-cook-button",
title: "Passer en cuisine",
content:
"Une fois la semaine planifiée, ce bouton réorganise tout en une séance de cuisine optimisée.",
},
{
route: "/liste-de-courses",
targetKey: "shopping-list-summary",
title: "Tout ce qu'il vous faut",
content: "Les ingrédients de toutes vos recettes de la semaine, regroupés et additionnés.",
},
],
},
{
key: "techStepMetadata",
name: "Corriger les techniques d'une recette",
steps: [
{
route: null,
targetKey: "techstep-description",
title: "Les techniques repérées",
content:
"Les mots surlignés sont des techniques de cuisson détectées automatiquement. Sélectionnez un passage pour le corriger.",
},
{
route: null,
targetKey: "techstep-technique-picker",
title: "Choisir la bonne technique",
content:
"Indiquez la technique correcte pour le passage sélectionné — la correction est immédiate.",
},
],
},
];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than // businesses to declare — a standard, defensible reference list rather than
// an invented one. Split into ALLERGY (classic IgE-mediated immune // an invented one. Split into ALLERGY (classic IgE-mediated immune
@ -1416,4 +1524,47 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
if (dietLinks.length > 0) { if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true }); await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
} }
await seedTourScenarios(prisma);
}
/**
* Seeds the guided-tour reference data (issues #42/#44):
*
* - `TourTarget` rows — upserted by key with `update: { description }`, same
* as `Unit` above: a reseed must correct an edited hint, not just skip.
* - `TourScenario` + its steps — **create-if-absent only**. Once a scenario
* exists, an admin owns its `name`/`enabled`/steps (that's the whole
* point of #44's CRUD), so a later deploy's reseed must never clobber
* those. In the test suite every `resetDatabase()` truncates first, so
* the defaults are always recreated fresh there.
*/
async function seedTourScenarios(prisma: PrismaClient): Promise<void> {
for (const { key, description } of TOUR_TARGETS) {
await prisma.tourTarget.upsert({
where: { key },
update: { description },
create: { key, description },
});
}
for (const { key, name, steps } of TOUR_SCENARIOS) {
const existing = await prisma.tourScenario.findUnique({ where: { key } });
if (existing) continue;
await prisma.tourScenario.create({
data: {
key,
name,
steps: {
create: steps.map((step, index) => ({
order: index,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
},
},
});
}
} }

View file

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

@ -0,0 +1,95 @@
import type { MonitoringView } from "@batch-cooking/shared";
import type { Server as SocketIoServer } from "socket.io";
import { requireAdminSocket } from "../middlewares/require-admin-socket.js";
import { getMonitoring } from "../modules/admin/admin-monitoring.service.js";
import { logger } from "./logger.service.js";
/** How often the server re-probes every dependency, in ms — see `MonitoringBroadcastService`. */
const PROBE_INTERVAL_MS = 60_000;
/** Every authenticated admin socket joins this one room — a single probe loop serves every open tab, instead of each polling independently. */
const MONITORING_ROOM = "monitoring";
/** Event name every `monitoring:update` push carries — the sole message this service ever emits. */
const UPDATE_EVENT = "monitoring:update";
/**
* Pushes the admin monitoring status board to every connected admin over
* WebSocket, replacing the old `GET /admin/api/monitoring` polling
* endpoint entirely (removed — see `admin.routes.ts`'s doc comment): one
* server-side probe loop, shared by every viewer, instead of each browser
* tab re-probing Postgres/the API/the intent-service/the LLM worker on its
* own timer.
*
* Owns the *whole* `socket.io` server passed to it (`io.use`, top-level
* `"connection"` handler) — fine while this is the server's only
* real-time feature; a second one added later should likely get its own
* namespace rather than share this room's connection handler.
*/
export class MonitoringBroadcastService {
private readonly _io: SocketIoServer;
/**
* Most recent probe result — replayed to a newly-joined admin
* immediately (see the `"connection"` handler below), so a freshly
* opened tab isn't left blank for up to a minute waiting for the next
* scheduled tick. `null` until the very first probe (kicked off by
* {@link start}) completes.
*/
private _lastSnapshot: MonitoringView | null = null;
/** Handle for the running probe loop, or `null` before {@link start}/after {@link stop}. */
private _intervalHandle: NodeJS.Timeout | null = null;
public constructor(io: SocketIoServer) {
this._io = io;
this._io.use(requireAdminSocket);
this._io.on("connection", (socket) => {
void socket.join(MONITORING_ROOM);
if (this._lastSnapshot) {
socket.emit(UPDATE_EVENT, this._lastSnapshot);
}
});
}
/**
* Starts the probe loop: one immediate probe (so the very first admin
* to connect after a server restart doesn't wait a full minute), then
* every {@link PROBE_INTERVAL_MS}. Call once, at server boot
* (`server.ts`) — meant to run for the lifetime of the process, same
* posture as the tech-step worker's own cron (`server.ts` never calls
* {@link stop} today; the process exiting takes the interval with it).
*/
public start(): void {
void this._tick();
this._intervalHandle = setInterval(() => void this._tick(), PROBE_INTERVAL_MS);
}
/**
* Stops the probe loop. Nothing in production calls this today — it
* exists so a graceful-shutdown hook could use it later, and so tests
* can tear an instance down cleanly instead of leaking a live
* `setInterval` past their own lifetime (which would otherwise hold the
* whole Mocha process open).
*/
public stop(): void {
if (this._intervalHandle) {
clearInterval(this._intervalHandle);
this._intervalHandle = null;
}
}
/** Probes every dependency, caches the result, and broadcasts it to {@link MONITORING_ROOM}. */
private async _tick(): Promise<void> {
try {
this._lastSnapshot = await getMonitoring();
this._io.to(MONITORING_ROOM).emit(UPDATE_EVENT, this._lastSnapshot);
} catch (err) {
// getMonitoring() itself never rejects — every individual probe is
// already bounded/caught (see its own doc comment) — so this is
// purely defensive, per the repo's await-in-try/catch rule; not
// expected to actually fire.
logger.error("Monitoring probe tick failed unexpectedly", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}

View file

@ -0,0 +1,64 @@
import type { Prisma } from "@prisma/client";
import type { TechStepMatch } from "./tech-step-matcher.js";
/**
* Turns one step's {@link TechStepMatch}es into the Prisma nested-`create`
* payload for its `techSteps` relation — the technique row **and** all three
* of its clause-metadata child sets (`ingredients` / `utensils` /
* `temperatures`).
*
* The single source of truth for that shape. It used to be written out by
* hand in three places — `recipe.service.ts`'s `createRecipeInternal` and
* `updateRecipe`, plus `src/scripts/backfill-tech-steps.ts` — and they had
* drifted: only the create path wrote the metadata children, so **every
* recipe resaved through the UI, and every recipe the backfill touched,
* silently lost every ingredient / utensil / temperature the classifier had
* attributed to its steps**. That left `cooking-optimizer.ts`'s
* temperature-aware `preheat` grouping (the "don't preheat to 150 °C and
* 200 °C at once" note) with no data to work from on any recipe that wasn't
* a pristine, never-edited import. Building the payload once, here, is what
* stops the three call sites diverging again.
*
* `order` is each match's index in `matches` (0-based). Callers pass matches
* in reading order already; a caller that needs a different base (the
* backfill, which offsets past untouched `"manual"` rows) overrides `order`
* on the returned objects before handing them to Prisma.
*/
export function toStepTechStepCreateInputs(
matches: TechStepMatch[],
): Prisma.StepTechStepUncheckedCreateWithoutStepInput[] {
return matches.map((match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
ingredients: {
create: match.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
})),
},
utensils: {
create: match.utensils.map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
temperatures: {
create: match.temperatures.map((temperature) => ({
start: temperature.start,
end: temperature.end,
celsius: temperature.celsius,
gasMark: temperature.gasMark,
qualitative: temperature.qualitative,
raw: temperature.raw,
})),
},
}));
}

View file

@ -0,0 +1,43 @@
import { parseCookie } from "cookie";
import type { Socket } from "socket.io";
import { env } from "../config/env.js";
import { resolveAdminFromToken } from "./require-admin.js";
/**
* `socket.io` handshake middleware guarding the admin WebSocket surface —
* the connection-level equivalent of {@link requireAdmin} (`require-
* admin.ts`). A `socket.io` handshake is a plain HTTP request that never
* runs through Express's own middleware stack (`cookie-parser` included),
* so the raw `Cookie` header has to be parsed by hand here with the
* `cookie` package (the same one `cookie-parser` itself wraps).
*
* Registered via `io.use(requireAdminSocket)` — `socket.io` calls every
* `io.use` middleware once per connection attempt, before `"connection"`
* fires, exactly like Express's per-request middleware chain. Calling
* `next()` with no argument admits the connection; calling it with an
* `Error` rejects it, which the client sees as a `connect_error` event
* (`socket.io-client` never even fires `"connect"` for it).
*
* On success, stashes the resolved admin on `socket.data.adminUser` —
* `socket.io`'s per-connection storage bag, the WebSocket equivalent of
* `res.locals` — for any handler that ends up needing it later.
*/
export async function requireAdminSocket(
socket: Socket,
next: (err?: Error) => void,
): Promise<void> {
try {
const cookieHeader = socket.handshake.headers.cookie;
const token = cookieHeader ? parseCookie(cookieHeader)[env.ADMIN_COOKIE_NAME] : undefined;
if (typeof token !== "string") {
throw new Error("Not authenticated");
}
socket.data.adminUser = await resolveAdminFromToken(token);
next();
} catch {
// Covers everything resolveAdminFromToken can throw, plus a missing
// cookie — same "never distinguish the reason" posture as requireAdmin.
next(new Error("Not authenticated"));
}
}

View file

@ -19,26 +19,50 @@ export interface AdminLocals {
} }
/** /**
* Express middleware guarding every `/admin/*` route — the operations UI * Verifies an admin session JWT and resolves the `AdminUser` it belongs
* (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin * to — the transport-agnostic core both {@link requireAdmin} (HTTP) and
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie * `requireAdminSocket` (`require-admin-socket.ts`, the WebSocket handshake
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET` * equivalent) build on; they only differ in *where* they read the raw
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion` * cookie value from (`req.cookies`, already parsed by `cookie-parser`, vs
* against the database so a stateless JWT can still be invalidated * a `socket.io` handshake's raw `Cookie` header, which isn't). Kept here
* server-side. * rather than in `lib/admin-jwt.ts`, since it also does the DB
* `tokenVersion` re-check that file deliberately doesn't know about.
*
* @throws {Error} for any failure — malformed/expired JWT, unknown admin,
* stale `tokenVersion`, or no `ADMIN_JWT_SECRET` configured (from
* {@link verifyAdminToken}). Always the same generic message; callers
* map it to their own transport's "unauthenticated" shape.
*/
export async function resolveAdminFromToken(token: string): Promise<AdminUserView> {
const payload = verifyAdminToken(token);
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
throw new Error("Not authenticated");
}
return toSafeAdmin(admin);
}
/**
* Express middleware guarding every `/admin/api/*` route — the operations
* UI (`apps/web`'s `/admin/*` pages) authenticating as an `AdminUser`.
* Reads the admin session cookie (`ADMIN_COOKIE_NAME`, deliberately
* **not** the same cookie as end-user sessions) and resolves it via
* {@link resolveAdminFromToken}.
* *
* A completely separate mechanism from {@link requireAuth}, not layered on * A completely separate mechanism from {@link requireAuth}, not layered on
* it: an end-user session token and an admin session token are never * it: an end-user session token and an admin session token are never
* interchangeable in either direction. * interchangeable in either direction.
* *
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance * Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so * that doesn't run the admin app) makes {@link resolveAdminFromToken}
* every request is rejected rather than the surface left open — same * throw, so every request is rejected rather than the surface left open —
* posture as `requireInternalWorker`. * same posture as `requireInternalWorker`.
* *
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or * cookie or anything {@link resolveAdminFromToken} rejects on. Never
* no secret configured. Never distinguishes the reason. * distinguishes the reason.
*/ */
export async function requireAdmin( export async function requireAdmin(
req: Request, req: Request,
@ -51,20 +75,13 @@ export async function requireAdmin(
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
} }
const payload = verifyAdminToken(token); res.locals.adminUser = await resolveAdminFromToken(token);
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
res.locals.adminUser = toSafeAdmin(admin);
next(); next();
} catch (err) { } catch (err) {
if (err instanceof HttpError) { if (err instanceof HttpError) {
next(err); next(err);
} else { } else {
// Covers jwt.verify failures and the unset-secret throw from verifyAdminToken. // Covers everything resolveAdminFromToken can throw.
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
} }
} }

View file

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

View file

@ -5,7 +5,7 @@ import { env } from "../../config/env.js";
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js"; import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
import { adminLogin } from "./admin-auth.service.js"; import { adminLogin } from "./admin-auth.service.js";
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */ /** Router mounted at `/admin/api/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
export const adminAuthRouter = Router(); export const adminAuthRouter = Router();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;

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

@ -4,7 +4,7 @@ import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js"; import { requireAdmin } from "../../middlewares/require-admin.js";
import { getMetrics } from "./admin-metrics.service.js"; import { getMetrics } from "./admin-metrics.service.js";
/** Router mounted at `/admin/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */ /** Router mounted at `/admin/api/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
export const adminMetricsRouter = Router(); export const adminMetricsRouter = Router();
/** /**

View file

@ -1,20 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import { getMonitoring } from "./admin-monitoring.service.js";
/** Router mounted at `/admin/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */
export const adminMonitoringRouter = Router();
/**
* Actively probes Postgres, the API, `tech-step-intent-service` and the LLM
* worker's heartbeat, returning a {@link MonitoringView} status board (see
* {@link getMonitoring}). No params; the admin UI polls it on an interval.
*/
adminMonitoringRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getMonitoring());
}),
);

View file

@ -155,7 +155,13 @@ async function probeWorker(): Promise<ServiceHealthView> {
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the * Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
* LLM worker (via its stored heartbeat). Each probe is independent and * LLM worker (via its stored heartbeat). Each probe is independent and
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the * bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
* others or the endpoint. * others or this call as a whole.
*
* No HTTP route calls this directly (there was one, `GET
* /admin/api/monitoring` — removed) — `MonitoringBroadcastService`
* (`lib/monitoring-broadcast.service.ts`) is the only caller now, on a
* server-side timer, broadcasting the result over WebSocket instead of
* each admin's browser polling this on its own.
*/ */
export async function getMonitoring(): Promise<MonitoringView> { export async function getMonitoring(): Promise<MonitoringView> {
try { try {

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

@ -18,7 +18,7 @@ import {
updateSuggestion, updateSuggestion,
} from "./admin-tech-steps.service.js"; } from "./admin-tech-steps.service.js";
/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */ /** Router mounted at `/admin/api/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
export const adminTechStepsRouter = Router(); export const adminTechStepsRouter = Router();
adminTechStepsRouter.get( adminTechStepsRouter.get(

View file

@ -0,0 +1,80 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createTourScenarioSchema,
ErrorCode,
updateTourScenarioSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
createTourScenario,
deleteTourScenario,
listTourScenarios,
listTourTargets,
updateTourScenario,
} from "./admin-tours.service.js";
/**
* Router mounted at `/admin/api/tours` (via `admin.routes.ts`) — every
* route behind {@link requireAdmin}. Full CRUD over the guided-tour
* scenarios the public `GET /reference/tours` serves to the web tour
* engine (issues #42/#44).
*/
export const adminToursRouter = Router();
/** Express types a numeric route param `string | undefined`; parse + validate here. */
function parseId(value: string | undefined): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, `Invalid tour scenario id "${value}"`);
}
return id;
}
adminToursRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listTourScenarios());
}),
);
/** The catalogue of valid highlight targets — for the editor's per-step target picker. */
adminToursRouter.get(
"/targets",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listTourTargets());
}),
);
adminToursRouter.post(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const scenario = await createTourScenario(createTourScenarioSchema.parse(req.body));
res.status(201).json(scenario);
}),
);
adminToursRouter.patch(
"/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const scenario = await updateTourScenario(
parseId(req.params.id),
updateTourScenarioSchema.parse(req.body),
);
res.status(200).json(scenario);
}),
);
adminToursRouter.delete(
"/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
await deleteTourScenario(parseId(req.params.id));
res.status(204).end();
}),
);

View file

@ -0,0 +1,175 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type AdminTourScenarioView,
type CreateTourScenarioInput,
ErrorCode,
type TourTargetView,
type UpdateTourScenarioInput,
} from "@batch-cooking/shared";
import { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
/** Shapes one Prisma `TourScenario` (with its steps) into the admin view. */
function toAdminView(scenario: {
id: number;
key: string;
name: string;
enabled: boolean;
steps: Array<{
order: number;
route: string | null;
targetKey: string;
title: string;
content: string;
}>;
}): AdminTourScenarioView {
return {
id: scenario.id,
key: scenario.key,
name: scenario.name,
enabled: scenario.enabled,
steps: scenario.steps
.slice()
.sort((a, b) => a.order - b.order)
.map((step) => ({
order: step.order,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
};
}
/** Every tour scenario — enabled *and* disabled — with its ordered steps. */
export async function listTourScenarios(): Promise<AdminTourScenarioView[]> {
try {
const scenarios = await prisma.tourScenario.findMany({
orderBy: { key: "asc" },
include: { steps: { orderBy: { order: "asc" } } },
});
return scenarios.map(toAdminView);
} catch (err) {
throw err; // see the repo's service-layer try/catch convention
}
}
/** The catalogue of valid highlight targets, for the editor's target picker. */
export async function listTourTargets(): Promise<TourTargetView[]> {
try {
return await prisma.tourTarget.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err;
}
}
/**
* Creates an empty scenario (no steps yet — the editor adds them via
* {@link updateTourScenario}).
*
* @throws {HttpError} `409 TOUR_KEY_ALREADY_IN_USE` if `key` is taken.
*/
export async function createTourScenario(
input: CreateTourScenarioInput,
): Promise<AdminTourScenarioView> {
try {
const scenario = await prisma.tourScenario.create({
data: { key: input.key, name: input.name },
include: { steps: true },
});
return toAdminView(scenario);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new HttpError(
409,
ErrorCode.TOUR_KEY_ALREADY_IN_USE,
`A tour scenario with key "${input.key}" already exists`,
);
}
throw err;
}
}
/**
* Updates a scenario's `name`/`enabled` and **replaces its entire step
* list** (delete-all then recreate with `order` = array index) — the same
* block-replace `updateRecipe` uses for a recipe's steps, not a partial
* patch. `key` is immutable.
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
* @throws {HttpError} `404 TOUR_TARGET_NOT_FOUND` if a step's `targetKey`
* isn't a seeded `TourTarget`.
*/
export async function updateTourScenario(
id: number,
input: UpdateTourScenarioInput,
): Promise<AdminTourScenarioView> {
try {
const existing = await prisma.tourScenario.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
}
const targetKeys = [...new Set(input.steps.map((step) => step.targetKey))];
if (targetKeys.length > 0) {
const known = await prisma.tourTarget.findMany({
where: { key: { in: targetKeys } },
select: { key: true },
});
const knownKeys = new Set(known.map((target) => target.key));
const unknown = targetKeys.find((key) => !knownKeys.has(key));
if (unknown !== undefined) {
throw new HttpError(
404,
ErrorCode.TOUR_TARGET_NOT_FOUND,
`No tour target with key "${unknown}"`,
);
}
}
const [, , scenario] = await prisma.$transaction([
prisma.tourScenario.update({
where: { id },
data: { name: input.name, enabled: input.enabled },
}),
prisma.tourScenarioStep.deleteMany({ where: { scenarioId: id } }),
prisma.tourScenario.update({
where: { id },
data: {
steps: {
create: input.steps.map((step, index) => ({
order: index,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
},
},
include: { steps: { orderBy: { order: "asc" } } },
}),
]);
return toAdminView(scenario);
} catch (err) {
throw err;
}
}
/**
* Deletes a scenario. Its steps and every user's `TourProgress` row for it
* cascade away (a scenario that no longer exists has no "seen" state worth
* keeping).
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
*/
export async function deleteTourScenario(id: number): Promise<void> {
try {
const existing = await prisma.tourScenario.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
}
await prisma.tourScenario.delete({ where: { id } });
} catch (err) {
throw err;
}
}

View file

@ -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,22 +1,32 @@
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 { adminMonitoringRouter } from "./admin-monitoring.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
import { adminToursRouter } from "./admin-tours.routes.js";
import { adminUsersRouter } from "./admin-users.routes.js";
/** /**
* Aggregator for the admin application's API surface, mounted at `/admin` * Aggregator for the admin application's API surface, mounted at
* in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes * `/admin/api` in `app.ts` (not bare `/admin` — see that mount point's own
* only — `/admin/auth` is public (login), everything added later * comment for why: it would collide with apps/web's `/admin/*` page
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`, * routes). Every sub-router here backs one of those pages —
* `/admin/catalog/*`) sits behind `requireAdmin` * `/admin/api/auth` is public (login), everything added later
* (`middlewares/require-admin.ts`). * (`/admin/api/metrics`, `/admin/api/tech-steps/*`, `/admin/api/catalog/*`)
* sits behind `requireAdmin` (`middlewares/require-admin.ts`).
*
* Monitoring has **no** REST route here on purpose — it's pushed over
* WebSocket instead (`lib/monitoring-broadcast.service.ts`, wired in
* `server.ts`), not requested per-page-load. See that service's doc
* comment for the full mechanism.
*/ */
export const adminRouter = Router(); export const adminRouter = Router();
adminRouter.use("/auth", adminAuthRouter); adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter); adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/monitoring", adminMonitoringRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

View file

@ -1,5 +1,10 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared"; import {
type AuthResponse,
deleteAccountSchema,
loginSchema,
signupSchema,
} from "@batch-cooking/shared";
import type { CookieOptions, Response } from "express"; import type { CookieOptions, Response } from "express";
import { Router } from "express"; import { Router } from "express";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
@ -43,7 +48,11 @@ authRouter.post(
const input = signupSchema.parse(req.body); const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input); const { profile, token } = await signup(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(201).json(profile); // Token is echoed in the body too, not just the cookie — see
// `AuthResponse` (packages/shared) for why (Capacitor WebView origin
// can't receive a SameSite cookie). Purely additive: every existing
// profile field stays exactly where it was.
res.status(201).json({ ...profile, token } satisfies AuthResponse);
}), }),
); );
@ -54,7 +63,7 @@ authRouter.post(
const input = loginSchema.parse(req.body); const input = loginSchema.parse(req.body);
const { profile, token } = await login(input); const { profile, token } = await login(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(200).json(profile); res.status(200).json({ ...profile, token } satisfies AuthResponse);
}), }),
); );

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

@ -22,6 +22,11 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
function cookingSessionPlanningInclude() { function cookingSessionPlanningInclude() {
return { return {
items: { items: {
// A "leftovers" item (`sourcePlanningItemId` set) reserves part of an
// already-cooked batch — it has nothing to cook, so it must not enter
// the optimized cooking plan (issue #34). Excluded at the query so it
// never reaches `toOptimizerRecipe`.
where: { sourcePlanningItemId: null },
include: { include: {
recipe: { recipe: {
select: { select: {
@ -84,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

@ -0,0 +1,31 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express";
import { getLatestApkUrl, getLatestVersion } from "./mobile.service.js";
/**
* Router mounted at `/mobile` in app.ts. Both routes are deliberately
* public (no `requireAuth`): the landing page's download link is shown to
* logged-out visitors, and the native update check (issue #66) runs on
* every app start, session or not. Both exist purely to work around
* Forgejo's Releases API sending no CORS headers — see `mobile.service.ts`'s
* own doc comment for the full story.
*/
export const mobileRouter = Router();
mobileRouter.get(
"/latest-release",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json({ version: await getLatestVersion() });
}),
);
// A plain redirect, not JSON — lets any caller (the landing page's `<a>`,
// the update banner's "Mettre à jour" link) use a static href with zero
// client-side fetch, and always resolves to something useful even if
// Forgejo is briefly unreachable (see getLatestApkUrl's fallback).
mobileRouter.get(
"/download",
wrapAsyncHandler(async (_req, res) => {
res.redirect(302, await getLatestApkUrl());
}),
);

View file

@ -0,0 +1,75 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
/**
* Forgejo's own Releases API for this repo — public, unauthenticated (the
* same fact `local-verification-while-ci-disabled` already leans on for
* CI-status checks without a token). **Must be called server-side, not
* from the browser**: verified live that Forgejo sends no
* `Access-Control-Allow-Origin` header on it, so a direct `fetch()` from
* `apps/web` fails with an opaque CORS error even though the endpoint
* itself is perfectly reachable — this whole module exists to proxy it
* from a context CORS doesn't apply to.
*/
const LATEST_RELEASE_API_URL =
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
/**
* Human-facing Forgejo release page — safe fallback for {@link getLatestApkUrl}
* when no matching asset is found (a release with nothing attached yet,
* Forgejo briefly unreachable, ...). Never a dead link, just a slightly
* less direct one: the visitor lands on the release itself and can find
* the asset from there.
*/
const LATEST_RELEASE_PAGE_URL = "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest";
/** Stable filename `release.yml` always attaches the signed APK under — see that workflow's own comment. */
const APK_ASSET_NAME = "batchcooking.apk";
interface ForgejoReleaseAsset {
name: string;
browser_download_url: string;
}
interface ForgejoRelease {
tag_name: string;
assets: ForgejoReleaseAsset[];
}
async function fetchLatestRelease(): Promise<ForgejoRelease> {
const response = await fetch(LATEST_RELEASE_API_URL);
if (!response.ok) {
throw new HttpError(
502,
ErrorCode.INTERNAL_ERROR,
`Forgejo releases API responded ${response.status}`,
);
}
return (await response.json()) as ForgejoRelease;
}
/**
* The latest tagged Android build's version (e.g. `"v1.4.2"`) — used by the
* native app's update check (issue #66, `useAppUpdateCheck.ts`).
*/
export async function getLatestVersion(): Promise<string> {
const release = await fetchLatestRelease();
return release.tag_name;
}
/**
* Where a "download the app" click should end up right now: the actual
* signed-APK asset of the latest release if one is attached, else
* {@link LATEST_RELEASE_PAGE_URL} as a safe fallback. Never throws — a
* download link must always go *somewhere* useful, even when Forgejo is
* briefly unreachable.
*/
export async function getLatestApkUrl(): Promise<string> {
try {
const release = await fetchLatestRelease();
const apk = release.assets.find((asset) => asset.name === APK_ASSET_NAME);
return apk?.browser_download_url ?? LATEST_RELEASE_PAGE_URL;
} catch {
return LATEST_RELEASE_PAGE_URL;
}
}

View file

@ -1,10 +1,21 @@
import { parseDateOnly } from "@batch-cooking/date-tools"; import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared"; import {
addPlanningItemSchema,
assignLeftoverSchema,
ErrorCode,
getPlanningByDateSchema,
} from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js"; import {
addPlanningItem,
assignLeftoverPlanningItem,
discardLeftover,
getPlanningForDate,
removePlanningItem,
} from "./planning.service.js";
/** Router mounted at `/planning` in app.ts. */ /** Router mounted at `/planning` in app.ts. */
export const planningRouter = Router(); export const planningRouter = Router();
@ -69,6 +80,44 @@ planningRouter.post(
}), }),
); );
/**
* Serves part of another planning item's leftover pool on a slot — creates
* a "leftovers" `PlanningItem` (see {@link assignLeftoverPlanningItem}).
* `POST /items/leftover`, not `/items/:id/...`: the `:id` here would be the
* *target* slot, which doesn't exist yet — the source is in the body.
*/
planningRouter.post(
"/items/leftover",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = assignLeftoverSchema.parse(req.body);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await assignLeftoverPlanningItem(houseId, viewerId, date, input);
res.status(201).json(item);
}),
);
/** Throws away item `:id`'s whole remaining leftover pool (idempotent — see {@link discardLeftover}). */
planningRouter.patch(
"/items/:id/discard-leftover",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parsePlanningItemId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await discardLeftover(id, viewerId, houseId);
res.status(200).json(item);
}),
);
/** Removes one recipe from a planning slot. */ /** Removes one recipe from a planning slot. */
planningRouter.delete( planningRouter.delete(
"/items/:id", "/items/:id",

View file

@ -2,6 +2,9 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
type AddPlanningItemInput, type AddPlanningItemInput,
type AssignLeftoverInput,
computeAvailableLeftover,
computeBatchedPortions,
ErrorCode, ErrorCode,
type PlanningItemView, type PlanningItemView,
type PlanningView, type PlanningView,
@ -10,6 +13,35 @@ import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js"; import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible } from "../recipe/recipe.service.js"; import { assertRecipeVisible } from "../recipe/recipe.service.js";
/**
* One `PlanningItem` row (with its recipe resolved to `{id, name}`) shaped
* into the wire {@link PlanningItemView}. The three read/write paths below
* that return an item — `getPlanningForDate`, `addPlanningItem`,
* `assignLeftoverPlanningItem`, `discardLeftover` — all go through this so
* the shape is defined once.
*/
function toPlanningItemView(item: {
id: number;
weekDay: string;
meal: string;
portions: number;
portionsRequested: number;
discardedPortions: number;
sourcePlanningItemId: number | null;
recipe: { id: number; name: string };
}): PlanningItemView {
return {
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
portionsRequested: item.portionsRequested,
discardedPortions: item.discardedPortions,
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
recipe: item.recipe,
};
}
/** /**
* Finds the household's planning that covers `date` and shapes it into a * Finds the household's planning that covers `date` and shapes it into a
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is * {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
@ -64,13 +96,7 @@ export async function getPlanningForDate(
id: planning.id, id: planning.id,
startDate: planning.startDate.toISOString(), startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(), finishDate: planning.finishDate.toISOString(),
items: planning.items.map((item) => ({ items: planning.items.map(toPlanningItemView),
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
})),
}; };
} catch (err) { } catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
@ -144,6 +170,18 @@ export async function addPlanningItem(
} }
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId); await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
// `assertRecipeVisible` already guaranteed the recipe exists and is
// visible — this second read just pulls its written yield, needed to
// turn "portions requested" into "portions to actually prepare"
// (a whole number of batches, see `computeBatchedPortions`). The
// `?? 1` never triggers in practice (the assert above threw otherwise);
// it only keeps TypeScript happy about `findUnique`'s `| null`.
const recipe = await prisma.recipe.findUnique({
where: { id: input.recipeId },
select: { portions: true },
});
const { portions } = computeBatchedPortions(recipe?.portions ?? 1, input.portionsRequested);
const weekStart = getWeekStart(toDateOnly(date)); const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart); const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
@ -153,23 +191,22 @@ export async function addPlanningItem(
weekDay: input.weekDay, weekDay: input.weekDay,
meal: input.meal, meal: input.meal,
recipeId: input.recipeId, recipeId: input.recipeId,
portions: input.portions, portions,
portionsRequested: input.portionsRequested,
}, },
include: { recipe: { select: { id: true, name: true } } }, include: { recipe: { select: { id: true, name: true } } },
}); });
analytics.recordEvent("planning.item_added", { analytics.recordEvent("planning.item_added", {
actorId: viewerId, actorId: viewerId,
context: { recipeId: input.recipeId, portions: input.portions }, context: {
recipeId: input.recipeId,
portions,
portionsRequested: input.portionsRequested,
},
}); });
return { return toPlanningItemView(item);
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
};
} catch (err) { } catch (err) {
throw err; // see getPlanningForDate()'s catch comment above throw err; // see getPlanningForDate()'s catch comment above
} }
@ -197,3 +234,139 @@ export async function removePlanningItem(id: number, houseId: number | null): Pr
throw err; // see getPlanningForDate()'s catch comment above throw err; // see getPlanningForDate()'s catch comment above
} }
} }
/**
* Loads a `PlanningItem` (scoped to `houseId`) with everything
* {@link computeAvailableLeftover} needs — its own portion fields plus how
* much of its pool has already been served elsewhere (`leftoverUses`) — or
* throws `404 PLANNING_ITEM_NOT_FOUND` (same "don't confirm what exists"
* posture as `removePlanningItem`). Shared by
* {@link assignLeftoverPlanningItem} and {@link discardLeftover}.
*/
async function loadItemWithLeftoverPool(id: number, houseId: number | null) {
const item = await prisma.planningItem.findUnique({
where: { id },
include: {
planning: { select: { houseId: true } },
leftoverUses: { select: { portions: true } },
recipe: { select: { id: true, name: true } },
},
});
if (!item || houseId === null || item.planning.houseId !== houseId) {
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
}
return item;
}
/**
* Serves `input.portions` of another item's leftover pool on the
* `(date, weekDay, meal)` slot — creates a "leftovers" `PlanningItem`
* (`sourcePlanningItemId` set) on the same recipe as its source. Nothing
* new is cooked for it, so `portions === portionsRequested` and it excludes
* itself from the shopping list / cooking session (see those services'
* `where: { sourcePlanningItemId: null }`).
*
* The source's `recipeId` is copied, never taken from the client (a
* leftovers item can only be its source's recipe). `input.portions` is
* re-checked against the pool actually still available
* ({@link computeAvailableLeftover}) — trusting a client value here would
* let a household conjure portions it never cooked.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null`.
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `sourceItemId` doesn't exist, belongs to another household, or is itself a leftovers item (no leftovers-on-leftovers).
* @throws {HttpError} `409 LEFTOVER_QUANTITY_EXCEEDED` if `portions` exceeds the source's still-available pool.
*/
export async function assignLeftoverPlanningItem(
houseId: number | null,
viewerId: number,
date: DateTime,
input: AssignLeftoverInput,
): Promise<PlanningItemView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const source = await loadItemWithLeftoverPool(input.sourceItemId, houseId);
if (source.sourcePlanningItemId !== null) {
// A leftovers item has no pool of its own — serving leftovers cooks
// nothing, so it can't be a source for more.
throw new HttpError(
404,
ErrorCode.PLANNING_ITEM_NOT_FOUND,
`Planning item ${input.sourceItemId} is not a leftovers source`,
);
}
const available = computeAvailableLeftover(source);
if (input.portions > available) {
throw new HttpError(
409,
ErrorCode.LEFTOVER_QUANTITY_EXCEEDED,
`Item ${source.id} has ${available} leftover portion(s) available, asked for ${input.portions}`,
);
}
const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
const item = await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: input.weekDay,
meal: input.meal,
recipeId: source.recipeId,
portions: input.portions,
portionsRequested: input.portions,
sourcePlanningItemId: source.id,
},
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.leftover_assigned", {
actorId: viewerId,
context: { recipeId: source.recipeId, portions: input.portions, sourceItemId: source.id },
});
return toPlanningItemView(item);
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
* Throws away the whole still-available leftover pool of item `id`
* (`discardedPortions += available`) — the basket's "jeter" button.
* Idempotent: an item with nothing left to discard is returned unchanged,
* no event recorded.
*
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` — same guard as `removePlanningItem`.
*/
export async function discardLeftover(
id: number,
viewerId: number,
houseId: number | null,
): Promise<PlanningItemView> {
try {
const item = await loadItemWithLeftoverPool(id, houseId);
const available = computeAvailableLeftover(item);
if (available === 0) {
return toPlanningItemView(item);
}
const updated = await prisma.planningItem.update({
where: { id },
data: { discardedPortions: item.discardedPortions + available },
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.leftover_discarded", {
actorId: viewerId,
context: { recipeId: item.recipe.id, portions: available },
});
return toPlanningItemView(updated);
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}

View file

@ -1,5 +1,9 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import {
type CompletedToursView,
completeTourSchema,
ErrorCode,
updateAllergiesSchema, updateAllergiesSchema,
updateDietSchema, updateDietSchema,
updateDislikedIngredientsSchema, updateDislikedIngredientsSchema,
@ -8,7 +12,9 @@ import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { import {
getAllergyIds, getAllergyIds,
getCompletedTourKeys,
getDislikedIngredientIds, getDislikedIngredientIds,
markTourSeen,
updateAllergies, updateAllergies,
updateDiet, updateDiet,
updateDislikedIngredients, updateDislikedIngredients,
@ -17,6 +23,14 @@ import {
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */ /** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
export const profileRouter = Router(); export const profileRouter = Router();
/** Express types a route param `string | undefined` even for a segment that always matches when the route does — satisfies TS, unreachable in practice. */
function requireParam(value: string | undefined): string {
if (value === undefined) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
}
return value;
}
/** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ /** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */
profileRouter.patch( profileRouter.patch(
"/diet", "/diet",
@ -70,3 +84,29 @@ profileRouter.patch(
res.status(200).json(ids); res.status(200).json(ids);
}), }),
); );
/** Which guided-tour scenarios this profile has already seen — the web tour engine reads this once per session (issues #42/#44). */
profileRouter.get(
"/tours",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const completedTourKeys = await getCompletedTourKeys(res.locals.userProfile.id);
res.status(200).json({ completedTourKeys } satisfies CompletedToursView);
}),
);
/**
* Marks a guided-tour scenario as seen — idempotent. An empty body means
* "completed"; `{ "skipped": true }` means the user dismissed it (different
* analytics event, same "don't re-nag" effect). 404s on an unknown/disabled
* tour key.
*/
profileRouter.post(
"/tours/:tourKey/complete",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const { skipped } = completeTourSchema.parse(req.body ?? {});
await markTourSeen(res.locals.userProfile.id, requireParam(req.params.tourKey), skipped);
res.status(204).end();
}),
);

View file

@ -1,6 +1,7 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { toSafeProfile } from "../../lib/safe-profile.js"; import { toSafeProfile } from "../../lib/safe-profile.js";
/** /**
@ -149,3 +150,57 @@ export async function updateDislikedIngredients(
throw err; // see updateDiet()'s catch comment above throw err; // see updateDiet()'s catch comment above
} }
} }
/**
* The `key`s of every guided-tour scenario this profile has already seen
* (completed or skipped — both leave a `TourProgress` row). The web tour
* engine reads this once per session to decide which tours to auto-start
* (issues #42/#44).
*/
export async function getCompletedTourKeys(userProfileId: number): Promise<string[]> {
try {
const rows = await prisma.tourProgress.findMany({
where: { userProfileId },
select: { scenario: { select: { key: true } } },
});
return rows.map((row) => row.scenario.key);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
* Marks a guided-tour scenario as seen by this profile — idempotent upsert,
* safe to call again (e.g. the engine retries after a flaky network). Emits
* `tour.completed` or `tour.skipped` for the admin dashboard (issue #44's
* analytics — the dashboard renders any new `AnalyticsEvent` type
* generically, no frontend change needed).
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `tourKey` doesn't match an
* **enabled** `TourScenario` (a disabled tour can't be completed).
*/
export async function markTourSeen(
userProfileId: number,
tourKey: string,
skipped: boolean,
): Promise<void> {
try {
const scenario = await prisma.tourScenario.findUnique({ where: { key: tourKey } });
if (!scenario?.enabled) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No enabled tour with key ${tourKey}`);
}
await prisma.tourProgress.upsert({
where: { userProfileId_scenarioId: { userProfileId, scenarioId: scenario.id } },
update: {},
create: { userProfileId, scenarioId: scenario.id },
});
analytics.recordEvent(skipped ? "tour.skipped" : "tour.completed", {
actorId: userProfileId,
context: { tourKey },
});
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}

View file

@ -1,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,
@ -32,22 +33,24 @@ const correctionInclude = {
} satisfies Prisma.StepTechStepCorrectionInclude; } satisfies Prisma.StepTechStepCorrectionInclude;
/** /**
* Loads `stepId`'s current `description` length (the only thing a * Loads `stepId`'s current `description` (a correction needs its full text
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND` * now, not just its length — see {@link submitTechStepCorrection}'s
* if no such step exists, or if it exists but doesn't belong to `recipeId` * `raw` derivation for a manual temperature mention), or throws —
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a * `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
* request naming a real step under the wrong recipe should look identical * belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
* to naming one that doesn't exist, same "don't leak which part was wrong" * meaningless otherwise — a request naming a real step under the wrong
* posture `assertRecipeVisible` already has for visibility). Otherwise * recipe should look identical to naming one that doesn't exist, same
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`, * "don't leak which part was wrong" posture `assertRecipeVisible` already
* never `403`) if the recipe exists but isn't visible to the viewer. * has for visibility). Otherwise whatever {@link assertRecipeVisible}
* throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but
* isn't visible to the viewer.
*/ */
async function loadVisibleStepOrThrow( async function loadVisibleStepOrThrow(
recipeId: number, recipeId: number,
stepId: number, stepId: number,
viewerId: number, viewerId: number,
viewerHouseId: number | null, viewerHouseId: number | null,
): Promise<{ id: number; descriptionLength: number }> { ): Promise<{ id: number; description: string }> {
try { try {
const step = await prisma.step.findUnique({ const step = await prisma.step.findUnique({
where: { id: stepId }, where: { id: stepId },
@ -57,7 +60,7 @@ async function loadVisibleStepOrThrow(
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`); throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
} }
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId); await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
return { id: step.id, descriptionLength: step.description.length }; return { id: step.id, description: step.description };
} catch (err) { } catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment throw err; // see recipe.service.ts's equivalent catch comment
} }
@ -208,6 +211,21 @@ interface ManualUtensilMention {
start: number; start: number;
end: number; end: number;
} }
/**
* Same as {@link ManualIngredientMention}, for a temperature — `raw` is
* already resolved by the caller ({@link submitTechStepCorrection}, which
* has the step's full `description` text to slice) rather than trusted
* from the client; see `manualStepTechStepTemperatureInputSchema`'s own
* doc comment (`packages/shared`) for why.
*/
interface ManualTemperatureMention {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
raw: string;
start: number;
end: number;
}
/** /**
* Applies a correction's *effect* on `stepId`'s real `StepTechStep` * Applies a correction's *effect* on `stepId`'s real `StepTechStep`
@ -233,16 +251,16 @@ interface ManualUtensilMention {
* *
* `metadata`, when given (only ever alongside a real `correctedTechStepId` * `metadata`, when given (only ever alongside a real `correctedTechStepId`
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here), * — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on * replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
* this occurrence — `source: "auto"` (the classifier's own detection) and * `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the
* any earlier `"manual"` set alike — with the newly-submitted one. This is * classifier's own detection) and any earlier `"manual"` set alike — with
* "le manuel remplace tout" (confirmed with the user): the resolved * the newly-submitted one. This is "le manuel remplace tout" (confirmed
* `order` this technique ends up at (whichever branch above produced it) * with the user): the resolved `order` this technique ends up at
* is the same `techStepOrder` both metadata tables key on, so the same * (whichever branch above produced it) is the same `techStepOrder` all
* `deleteMany` + `createMany` pair below is correct whether this call just * three metadata tables key on, so the same `deleteMany` + `createMany`
* updated an existing row (which may already carry auto-detected * pair below is correct whether this call just updated an existing row
* metadata) or created a brand new one (nothing to delete yet — a no-op * (which may already carry auto-detected metadata) or created a brand new
* `deleteMany`, not a special case). * one (nothing to delete yet — a no-op `deleteMany`, not a special case).
* *
* Runs inside the same transaction {@link submitTechStepCorrection} uses * Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves any of these * for the audit-trail insert, so a request never leaves any of these
@ -255,7 +273,11 @@ async function applyManualCorrection(
span: { start: number; end: number }, span: { start: number; end: number },
previousTechStepId: number | null, previousTechStepId: number | null,
correctedTechStepId: number | null, correctedTechStepId: number | null,
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] }, metadata?: {
ingredients: ManualIngredientMention[];
utensils: ManualUtensilMention[];
temperatures: ManualTemperatureMention[];
},
): Promise<void> { ): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } }); const existing = await tx.stepTechStep.findMany({ where: { stepId } });
@ -329,6 +351,22 @@ async function applyManualCorrection(
})), })),
}); });
} }
await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } });
if (metadata.temperatures.length > 0) {
await tx.stepTechStepTemperature.createMany({
data: metadata.temperatures.map((temperature) => ({
stepId,
techStepOrder: order,
celsius: temperature.celsius,
gasMark: temperature.gasMark,
qualitative: temperature.qualitative,
raw: temperature.raw,
start: temperature.start,
end: temperature.end,
source: "manual",
})),
});
}
} }
} else if (target) { } else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } }); await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
@ -343,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(),
}; };
@ -366,10 +414,11 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if * {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` (the correction's own span, or any of * `start`/`end` (the correction's own span, or any of
* `input.ingredients`/`input.utensils`' own spans) fall outside the * `input.ingredients`/`input.utensils`/`input.temperatures`' own spans)
* step's current `description` (it may have been edited since the user * fall outside the step's current `description` (it may have been
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/ * edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id * `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND`
* if any referenced id
* doesn't exist. * doesn't exist.
*/ */
export async function submitTechStepCorrection( export async function submitTechStepCorrection(
@ -381,18 +430,20 @@ export async function submitTechStepCorrection(
): Promise<SubmitTechStepCorrectionResult> { ): Promise<SubmitTechStepCorrectionResult> {
try { try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId); const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
const descriptionLength = step.description.length;
const spans = [ const spans = [
{ start: input.start, end: input.end }, { start: input.start, end: input.end },
...(input.ingredients ?? []), ...(input.ingredients ?? []),
...(input.utensils ?? []), ...(input.utensils ?? []),
...(input.temperatures ?? []),
]; ];
for (const span of spans) { for (const span of spans) {
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) { if (span.start >= descriptionLength || span.end > descriptionLength) {
throw new HttpError( throw new HttpError(
400, 400,
ErrorCode.INVALID_CORRECTION_SPAN, ErrorCode.INVALID_CORRECTION_SPAN,
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`, `Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`,
); );
} }
} }
@ -409,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,
@ -417,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,
}); });
@ -427,8 +503,10 @@ 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.utensils === undefined input.ingredients === undefined &&
input.utensils === undefined &&
input.temperatures === undefined
? undefined ? undefined
: { : {
ingredients: (input.ingredients ?? []).map((ingredient) => ({ ingredients: (input.ingredients ?? []).map((ingredient) => ({
@ -443,6 +521,16 @@ export async function submitTechStepCorrection(
start: utensil.start, start: utensil.start,
end: utensil.end, end: utensil.end,
})), })),
// `raw` is derived here (not sent by the client) — see
// `ManualTemperatureMention`'s own doc comment.
temperatures: (input.temperatures ?? []).map((temperature) => ({
celsius: temperature.celsius ?? null,
gasMark: temperature.gasMark ?? null,
qualitative: temperature.qualitative ?? null,
raw: step.description.slice(temperature.start, temperature.end),
start: temperature.start,
end: temperature.end,
})),
}, },
); );
@ -472,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,
@ -481,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,
@ -20,6 +21,7 @@ import {
type TechStepMatch, type TechStepMatch,
techStepClassifier, techStepClassifier,
} from "../../lib/recipe-matching/tech-step-matcher.js"; } from "../../lib/recipe-matching/tech-step-matcher.js";
import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-persistence.js";
// No user-language preference exists anywhere in the app yet (a single // No user-language preference exists anywhere in the app yet (a single
// "fr" translation file, no locale field on User/UserProfile) — steps are // "fr" translation file, no locale field on User/UserProfile) — steps are
@ -28,7 +30,7 @@ import {
// 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: {
@ -73,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 {
@ -120,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>();
@ -135,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,
@ -148,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,
}; };
} }
@ -189,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
@ -239,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,
@ -300,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,
@ -381,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
} }
} }
@ -395,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[];
} }
@ -419,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":
@ -463,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
} }
} }
@ -489,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,
@ -515,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
} }
} }
@ -532,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,
@ -543,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
} }
} }
@ -577,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;
} }
/** /**
@ -614,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;
} }
@ -631,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
} }
} }
@ -650,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
@ -694,45 +754,13 @@ async function createRecipeInternal(
description: step.description, description: step.description,
picture: step.picture ?? null, picture: step.picture ?? null,
order: index, order: index,
techSteps: { // Technique rows + their clause metadata (ingredients /
create: matches.map((match, order) => ({ // utensils / temperatures) — see `toStepTechStepCreateInputs`.
techStepId: match.techStepId, // `updateRecipe` below builds the exact same payload from the
start: match.start, // same helper so a resave never drops what a create persisted.
end: match.end, techSteps: { create: toStepTechStepCreateInputs(matches) },
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
ingredients: {
create: match.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
})), })),
}, },
utensils: {
create: match.utensils.map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
temperatures: {
create: match.temperatures.map((temperature) => ({
start: temperature.start,
end: temperature.end,
celsius: temperature.celsius,
gasMark: temperature.gasMark,
qualitative: temperature.qualitative,
raw: temperature.raw,
})),
},
})),
},
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
include: recipeInclude(authorId), include: recipeInclude(authorId),
}); });
@ -750,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,
@ -782,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
@ -794,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,
@ -820,19 +849,15 @@ export async function updateRecipe(
description: step.description, description: step.description,
picture: step.picture ?? null, picture: step.picture ?? null,
order: index, order: index,
techSteps: { // Same builder as `createRecipeInternal` — a resave must
create: matches.map((match, order) => ({ // persist the clause metadata (ingredients / utensils /
techStepId: match.techStepId, // temperatures), not just the technique span. Omitting it
start: match.start, // here is what left every edited recipe with temperature-less
end: match.end, // `preheat` steps, so `cooking-optimizer.ts` could never spot
contextStart: match.contextStart, // an oven-temperature conflict between two of them.
contextEnd: match.contextEnd, techSteps: { create: toStepTechStepCreateInputs(matches) },
order,
})), })),
}, },
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
}); });
return createdPlaceholders; return createdPlaceholders;
@ -845,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
} }
} }
@ -880,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
} }
} }
@ -908,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
} }
} }
@ -919,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
} }
} }
@ -942,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
} }
} }
@ -958,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
} }
} }
@ -977,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
} }
} }
@ -999,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
} }
} }
@ -1021,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

@ -6,6 +6,7 @@ import {
getIngredients, getIngredients,
getSources, getSources,
getTechSteps, getTechSteps,
getTours,
getUnits, getUnits,
getUtensils, getUtensils,
} from "./reference.service.js"; } from "./reference.service.js";
@ -69,3 +70,14 @@ referenceRouter.get(
res.status(200).json(await getSources()); res.status(200).json(await getSources());
}), }),
); );
// Enabled guided-tour scenarios + their steps — read by the web tour
// engine (issue #42). Public like every route here: the general tour can
// start right after signup, before onboarding finishes. Admin CRUD of
// these lives under `/admin/api/tours` (issue #44).
referenceRouter.get(
"/tours",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getTours());
}),
);

View file

@ -4,6 +4,7 @@ import type {
IngredientView, IngredientView,
SourceView, SourceView,
TechStepView, TechStepView,
TourScenarioView,
UnitView, UnitView,
UtensilView, UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -76,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
} }
@ -173,3 +184,32 @@ export async function getIngredients(): Promise<IngredientView[]> {
throw err; // see getDiets()'s catch comment above throw err; // see getDiets()'s catch comment above
} }
} }
/**
* Every **enabled** guided-tour scenario with its ordered steps, for the
* web tour engine (issues #42/#44). A disabled scenario is hidden here
* without being deleted (an admin can toggle it back on). Steps come back
* ordered; `route`/`title`/`content` pass straight through.
*/
export async function getTours(): Promise<TourScenarioView[]> {
try {
const scenarios = await prisma.tourScenario.findMany({
where: { enabled: true },
orderBy: { key: "asc" },
include: { steps: { orderBy: { order: "asc" } } },
});
return scenarios.map((scenario) => ({
key: scenario.key,
name: scenario.name,
steps: scenario.steps.map((step) => ({
order: step.order,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}

View file

@ -13,6 +13,11 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
function shoppingListPlanningInclude() { function shoppingListPlanningInclude() {
return { return {
items: { items: {
// "Leftovers" items (`sourcePlanningItemId` set) reserve a portion of
// an already-cooked batch — nothing new to buy for them. Excluded
// here so they never reach `aggregateShoppingList`; counting them
// would double up the source recipe's ingredients (issue #34).
where: { sourcePlanningItemId: null },
include: { include: {
recipe: { recipe: {
select: { select: {
@ -39,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;
} }
/** /**
@ -65,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

@ -0,0 +1,72 @@
import { prisma } from "../db/prisma.js";
import { backfillTechSteps } from "./backfill-tech-steps.js";
/**
* `backfill_state.key` for the tech-step clause-metadata backfill. Opaque,
* dated so a future metadata rework can add a second key rather than
* re-using this one.
*
* Why it's needed: `updateRecipe` and `backfill-tech-steps.ts` used to
* persist only a detected technique's span, never its ingredient / utensil /
* temperature children (only `createRecipeInternal` did). So every recipe
* ever edited through the UI, and every recipe imported before the create
* path learned temperatures, is missing the clause metadata that
* `cooking-optimizer.ts` needs to spot an oven-temperature conflict between
* two `preheat` steps. {@link backfillTechSteps} recomputes the whole
* `StepTechStep` sequence with the now-complete writer.
*/
const TECH_STEP_METADATA_BACKFILL_KEY = "step_tech_step_metadata_20260904";
/**
* One-time data backfills for the production image — run via
* `node dist/scripts/backfill-runtime.js` in apps/api/Dockerfile's CMD,
* after `seed-runtime` and before the server starts.
*
* Unlike `seed-runtime` (idempotent by construction — it only upserts, so
* repeating it every container start is free), a backfill here *rewrites*
* existing rows and would be wasteful-to-harmful to repeat: the tech-step
* one re-hits `services/tech-step-intent-service` once per `Step`. So each
* backfill gates itself on a `backfill_state` row — present means "done,
* skip"; absent means "run it, then record it".
*
* A backfill failing must **not** keep the API down: it's a data-quality
* catch-up, not a correctness prerequisite (contrast `seed-runtime`, whose
* failure genuinely means missing reference data). So a failure here is
* logged, the marker is left unwritten (the next container start retries),
* and the process still exits 0 so the CMD chain reaches `node dist/server.js`.
* Only a failure to even read `backfill_state` propagates — at that point
* the database is unreachable and the server wouldn't start anyway.
*/
async function runOneTimeBackfills(): Promise<void> {
const alreadyDone = await prisma.backfillState.findUnique({
where: { key: TECH_STEP_METADATA_BACKFILL_KEY },
});
if (alreadyDone !== null) {
console.info(
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" already completed at ${alreadyDone.completedAt.toISOString()} — skipping.`,
);
return;
}
console.info(`Running one-time backfill "${TECH_STEP_METADATA_BACKFILL_KEY}"...`);
try {
const { total, changed } = await backfillTechSteps();
await prisma.backfillState.create({ data: { key: TECH_STEP_METADATA_BACKFILL_KEY } });
console.info(
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" done — ${changed}/${total} step(s) recomputed.`,
);
} catch (err) {
console.error(
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" failed — leaving it unrecorded so the next start retries. The API will still start.`,
err,
);
}
}
runOneTimeBackfills()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -1,23 +1,27 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { prisma } from "../db/prisma.js"; import { prisma } from "../db/prisma.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
import { toStepTechStepCreateInputs } from "../lib/recipe-matching/tech-step-persistence.js";
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js"; import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
/** /**
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep` * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
* entries against the *current* classifier * entries — technique spans **and** their clause metadata (ingredients,
* utensils, temperatures) — against the *current* classifier
* (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`), * (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
* the same way `updateRecipe` does when a user resaves a recipe through the UI — * the same way `updateRecipe` does when a user resaves a recipe through the
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's * UI: both build the row payload from the shared
* no persisted per-recipe locale to recover for a step that already * {@link toStepTechStepCreateInputs}. Always `"fr"`
* exists, so this matches real resave behavior exactly rather than * (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's no persisted
* guessing). * per-recipe locale to recover for a step that already exists, so this
* matches real resave behavior exactly rather than guessing).
* *
* Needed because tech-step detection only ever runs at create/update time * Needed because tech-step detection only ever runs at create/update time
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a * (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
* step saved before a classifier/corpus change (new vocabulary, or the * step saved before a classifier/corpus change (new vocabulary, or the
* `contextStart`/`contextEnd` columns a previous session added) keeps * `contextStart`/`contextEnd` columns a previous session added), or before
* whatever it was matched with at the time until it's next resaved. * the clause-metadata writers were wired into the *update* path at all,
* keeps whatever it was matched with at the time until it's next resaved.
* *
* `"manual"`-sourced entries (a viewer's correction, applied immediately — * `"manual"`-sourced entries (a viewer's correction, applied immediately —
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`) * see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
@ -72,19 +76,20 @@ export async function backfillTechSteps(): Promise<{ total: number; changed: num
// into a clean 0..N-1 sequence right after — these just need to // into a clean 0..N-1 sequence right after — these just need to
// not collide with `@@id([stepId, order])` for this insert). // not collide with `@@id([stepId, order])` for this insert).
const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1; const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.createMany({ // One `create` per row rather than a single `createMany`:
data: nonOverlappingMatches.map((match, index) => ({ // `createMany` cannot write the nested `ingredients` / `utensils` /
stepId: step.id, // `temperatures` children, and silently dropping those is exactly
techStepId: match.techStepId, // the bug this script shared with the old `updateRecipe` — see
order: startOrder + index, // `toStepTechStepCreateInputs`. A backfill that leaves recipes
start: match.start, // without their clause temperatures defeats the whole point of
end: match.end, // running it (`cooking-optimizer.ts` needs them).
contextStart: match.contextStart, const inputs = toStepTechStepCreateInputs(nonOverlappingMatches);
contextEnd: match.contextEnd, for (const [index, input] of inputs.entries()) {
source: "auto", await tx.stepTechStep.create({
})), data: { ...input, stepId: step.id, order: startOrder + index, source: "auto" },
}); });
} }
}
await renumberStepTechSteps(tx, step.id); await renumberStepTechSteps(tx, step.id);
}); });

View file

@ -1,6 +1,8 @@
import { Server as SocketIoServer } from "socket.io";
import { createServer } from "./app.js"; import { createServer } from "./app.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { logger } from "./lib/logger.service.js"; import { logger } from "./lib/logger.service.js";
import { MonitoringBroadcastService } from "./lib/monitoring-broadcast.service.js";
import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js"; import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
import { registerAllRecipeSources } from "./sources/index.js"; import { registerAllRecipeSources } from "./sources/index.js";
@ -50,6 +52,17 @@ await warmUpTechStepClassifier();
const server = createServer(); const server = createServer();
// Attaches to the same underlying `http.Server` `server.listen()` below
// will start — `socket.io` binds to the HTTP server itself, not to
// Express or a port, hence `ExpressServer.httpServer` (packages/express-
// tools) rather than going through `server.instance` (the Express app).
// Own CORS config: the WebSocket handshake is a separate HTTP upgrade
// request that never runs through `setupCore`'s `cors()` middleware.
const io = new SocketIoServer(server.httpServer, {
cors: { origin: env.CORS_ORIGIN, credentials: true },
});
new MonitoringBroadcastService(io).start();
server.listen(env.PORT, () => { server.listen(env.PORT, () => {
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV }); logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
}); });

View file

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

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

@ -0,0 +1,170 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "ohMonBento";
const SITE_ORIGIN = "https://ohmonbento.com";
/** One recipe card scraped off an ohmonbento.com search page — see {@link extractRecipeCards}. */
interface OhMonBentoCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of an ohmonbento.com search results page.
* Each card is a `<div class="entry-thumb">` wrapping a single
* `<a href="/<slug>/">` with one `<img>` inside — no `ItemList` JSON-LD
* (unlike marmiton.ts) and no lazy-load `data-src` dance (unlike
* quitoque.ts): the real image is just the `<img>`'s `src`. `href` is a
* root-relative path, resolved against {@link SITE_ORIGIN} here.
* Attribute-order-agnostic.
*/
function extractRecipeCards(html: string): OhMonBentoCard[] {
const cards: OhMonBentoCard[] = [];
const thumbPattern = /<div\s+[^>]*class="[^"]*\bentry-thumb\b[^"]*"[^>]*>([\s\S]*?)<\/div>/g;
for (const match of html.matchAll(thumbPattern)) {
const block = match[1] ?? "";
const href = /<a\s+[^>]*\bhref="([^"]+)"/.exec(block)?.[1];
if (href === undefined) continue;
const title = /\balt="([^"]*)"/.exec(block)?.[1] ?? "";
if (title.length === 0) continue;
const url = href.startsWith("http") ? href : `${SITE_ORIGIN}${href}`;
cards.push({ url, title, image: /\bsrc="([^"]+)"/.exec(block)?.[1] ?? null });
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as this
* adapter's (`sourceKey` `"ohMonBento"`) — same reasoning as marmiton.ts's
* identically-named helper. (Not factored out into a shared helper —
* marmiton/750g/manger-bouger/quitoque each keep their own copy; the
* near-identical five-liner isn't worth a module.)
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* ohmonbento.com — a French Japanese-cuisine / bento cooking blog
* (WordPress). Unofficial (`official: false`, same reasoning as every
* other scraped adapter): no published API, this reads ordinary pages and
* the schema.org JSON-LD they embed. `robots.txt` allows everything except
* `/wp-admin/`.
*
* The simplest of the scraped adapters: like marmiton.ts, `fetchDetail`/
* `parse` delegate straight to {@link jsonLdRecipeAdapter} — a recipe page
* carries a WP Recipe Maker `Recipe` inside a Yoast `@graph`, which
* `findRecipeNode` already handles (recurses into the array). Only `list()`
* is site-specific: WordPress search at `/?s={query}&paged={page}` — the
* **same URL whether `query` is empty (whole catalogue) or not** (no
* branching, unlike 750g.ts) — with a `<div class="entry-thumb">` card
* scrape (see {@link extractRecipeCards}). A page past the last one 404s,
* the same clean end-of-list signal as marmiton.ts.
*/
export const ohMonBentoAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Oh mon Bento",
official: false,
iconUrl: `${SITE_ORIGIN}/favicon.ico`,
// Le contenu d'ohmonbento.com (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
// Curseur opaque = numéro de page suivant ; WordPress pagine via
// `&paged=N` (page 1 implicite quand le paramètre est absent). Le
// paramètre `?s=` est toujours présent, même vide — c'est ce qui
// liste tout le catalogue (vérifié sur le ticket).
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const listUrl = `${SITE_ORIGIN}/?s=${encodeURIComponent(query)}${
page > 1 ? `&paged=${page}` : ""
}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error listing Oh mon Bento recipes (${listUrl})`,
{ cause },
);
}
// Une page au-delà de la dernière renvoie une vraie 404 — pas un
// échec, juste « il n'y a plus rien » (même signal que marmiton.ts).
if (response.status === 404) {
return { items: [], nextCursor: null };
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Oh mon Bento responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
// La 404 ci-dessus est le vrai signal de fin ; on ne propose une page
// suivante que si celle-ci a renvoyé au moins une carte (même logique
// que marmiton.ts).
const nextCursor = items.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "ohMonBento" by whichever branch
// above threw it) — same reasoning as `marmiton.ts`/`quitoque.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// ohmonbento.com (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// `rekeySourceError` est le traitement utile de l'erreur à ce point
// d'appel (relabelliser sa `sourceKey`), pas un simple re-throw.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
return jsonLdRecipeAdapter.parse(raw);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -0,0 +1,188 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "quitoque";
const SITE_ORIGIN = "https://www.quitoque.fr";
const LIST_URL = `${SITE_ORIGIN}/recettes`;
/** One recipe card scraped off a quitoque.fr `/recettes` listing page — see {@link extractRecipeCards}. */
interface QuitoqueCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of a quitoque.fr `/recettes` listing page.
* There's no `ItemList` JSON-LD to lean on (unlike marmiton.ts) — but each
* card is a single `<a class="card__content_header" href="/recettes/…">`
* wrapping its own `<img>`, so no "nearest preceding image" pairing is
* needed (unlike 750g.ts). The real image URL is the `<img>`'s `data-src`
* (a lazy-load attribute); its `src` is only a placeholder. `href` is a
* root-relative path, resolved against {@link SITE_ORIGIN} here so callers
* get a canonical absolute URL.
*
* Attribute-order-agnostic: matches every `<a>…</a>`, keeps the ones whose
* `class` contains `card__content_header`, and reads `href`/`alt`/`data-src`
* wherever they sit.
*/
function extractRecipeCards(html: string): QuitoqueCard[] {
const cards: QuitoqueCard[] = [];
const anchorPattern = /<a\s+([^>]*?)>([\s\S]*?)<\/a>/g;
for (const match of html.matchAll(anchorPattern)) {
const attrs = match[1] ?? "";
if (!/class="[^"]*\bcard__content_header\b[^"]*"/.test(attrs)) continue;
const href = /\bhref="(\/recettes\/[^"]+)"/.exec(attrs)?.[1];
if (href === undefined) continue;
const inner = match[2] ?? "";
const title = /\balt="([^"]*)"/.exec(inner)?.[1] ?? "";
if (title.length === 0) continue;
cards.push({
url: `${SITE_ORIGIN}${href}`,
title,
image: /\bdata-src="([^"]+)"/.exec(inner)?.[1] ?? null,
});
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter (`sourceKey` `"quitoque"`) — same reasoning as
* marmiton.ts's identically-named helper: `fetchDetail`/`parse` are thin
* wrappers around the generic adapter, but a caller reading `.sourceKey`
* off a caught `RecipeSourceError` should see the source it asked about.
* (Not factored out into a shared helper — 750g/manger-bouger each keep
* their own copy too; three near-identical five-liners aren't worth a
* module.)
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* quitoque.fr — a French meal-kit company whose site publishes a large
* browsable recipe catalogue (`/recettes`, ~2700 recipes). Unofficial
* (`official: false`, same reasoning as jsonLdRecipeAdapter/marmiton/750g/
* mangerBouger): no published API, this reads ordinary pages and the
* schema.org JSON-LD they embed for search engines. `robots.txt` allows
* `/recettes` (and lists `ClaudeBot`/`GPTBot`/`Google-Extended` explicitly
* under `allow: /`).
*
* The thinnest kind of wrapper — like marmiton.ts, `fetchDetail`/`parse`
* delegate straight to {@link jsonLdRecipeAdapter} (a quitoque.fr recipe
* page's JSON-LD is a plain schema.org `Recipe`, the sole element of a
* top-level array, which `findRecipeNode` already handles). Only `list()`
* is site-specific: `/recettes?page=N` with a card scrape (see
* {@link extractRecipeCards}). The site has no real free-text search, so a
* `query` is a best-effort substring filter over each page's card titles
* (same accepted gap 750g.ts documents for its own search).
*/
export const quitoqueAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Quitoque",
official: false,
iconUrl: `${SITE_ORIGIN}/favicon.ico`,
// Le contenu de quitoque.fr (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
// Curseur opaque = numéro de page suivant ; quitoque.fr pagine via
// `?page=N` (page 1 implicite quand le paramètre est absent), même
// esprit que marmiton.ts/750g.ts.
const page = params.cursor ? Number(params.cursor) : 1;
const query = (params.query ?? "").trim().toLowerCase();
const listUrl = `${LIST_URL}${page > 1 ? `?page=${page}` : ""}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error listing Quitoque recipes (${listUrl})`,
{ cause },
);
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Quitoque responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
const rawCards = extractRecipeCards(html);
const cards =
query.length > 0
? rawCards.filter((card) => card.title.toLowerCase().includes(query))
: rawCards;
const items: RecipeSourceListItem[] = cards.map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
// Une page qui ne renvoie **aucune** carte est le signal de fin
// (même logique que marmiton.ts). Basé sur le nombre de cartes
// brutes, pas filtrées : une page qui a des cartes mais dont aucune
// ne matche `query` peut quand même avoir une suite qui matche.
const nextCursor = rawCards.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "quitoque" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`750g.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// quitoque.fr (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
return jsonLdRecipeAdapter.parse(raw);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -66,20 +66,91 @@ function detailUrl(idMeal: string): string {
return `https://www.themealdb.com/meal/${idMeal}`; return `https://www.themealdb.com/meal/${idMeal}`;
} }
/** Category-list response shape (`/list.php?c=list`) — same `meals` envelope as everything else, but each row only carries `strCategory`. */
interface TheMealDbCategoryListResponse {
meals: { strCategory: string | null }[] | null;
}
/** Narrows to meals that actually carry a name — a titleless row isn't worth surfacing in a browse list. */
function hasName(meal: TheMealDbMeal): meal is TheMealDbMeal & { strMeal: string } {
return Boolean(meal.strMeal);
}
/** Maps one raw meal (from `/search.php` or `/filter.php`) into the generic list-item shape. */
function toListItem(meal: TheMealDbMeal & { strMeal: string }) {
return {
externalId: meal.idMeal,
title: meal.strMeal,
picture: meal.strMealThumb,
url: detailUrl(meal.idMeal),
};
}
/**
* Name search (`/search.php?s=`) — the only mode TheMealDB's free tier
* offers for a non-empty query. One response holds every match, so there's
* never a next page.
*/
async function searchByName(query: string): Promise<RecipeSourceListResult> {
try {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
);
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor: null };
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
}
/**
* "Browse everything" (an empty/omitted query). TheMealDB's free tier has
* no catalog-wide listing endpoint — `/search.php?s=` only answers with a
* fixed ~25-meal sample — so this walks the category list instead:
* `/list.php?c=list` for the categories, then `/filter.php?c={category}`
* for one category at a time, exposing **one category per cursor step** so
* the infinite scroll keeps pulling real, distinct pages instead of
* stalling on that sample (issue #38). The category list is re-fetched on
* each step rather than cached in module state — it's a single small
* request, it keeps this adapter stateless like the others, and the walk
* is paced one step per scroll anyway.
*/
async function listByCategory(cursor: string | null | undefined): Promise<RecipeSourceListResult> {
try {
const parsed = Number.parseInt(cursor ?? "0", 10);
const startAt = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
const categoryList = await fetchTheMealDb<TheMealDbCategoryListResponse>("/list.php?c=list");
const categories = (categoryList.meals ?? [])
.map((row) => row.strCategory)
.filter((name): name is string => Boolean(name));
const category = categories[startAt];
if (category === undefined) {
// Cursor has walked past the last category — the clean end of the list.
return { items: [], nextCursor: null };
}
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/filter.php?c=${encodeURIComponent(category)}`,
);
const nextCursor = startAt + 1 < categories.length ? String(startAt + 1) : null;
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor };
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
}
/** /**
* TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the * TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the
* publisher's own structured JSON, hence `official: true`). The first real * publisher's own structured JSON, hence `official: true`). The first real
* `RecipeSourceAdapter` implementation, proving the generic contract * `RecipeSourceAdapter` implementation, proving the generic contract
* (recipe-source-adapter.ts) end to end against a live source. * (recipe-source-adapter.ts) end to end against a live source.
* *
* `list()` is search-only — TheMealDB has no dedicated "browse everything" * `list()` has two modes (see the helpers above): a non-empty `query` is a
* endpoint on its free tier. An omitted `query` searches for an empty * name search (`/search.php?s=`, single unpaginated response,
* string, which TheMealDB happens to answer with a small default sample * `nextCursor: null`); an empty/omitted `query` — this contract's "browse
* (~25 meals) rather than nothing — close enough to this contract's * everything" — walks the category list one category per cursor step,
* "omitted `query` means browse everything" convention * since the free tier has no real catalog-wide endpoint.
* (`RecipeSourceListParams.query`) to lean on as-is, though it's a fixed
* sample, not the whole catalog. Search isn't paginated either — one
* response holds every match, so `nextCursor` is always `null`.
*/ */
export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = { export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
key: SOURCE_KEY, key: SOURCE_KEY,
@ -93,22 +164,8 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try { try {
const query = params.query ?? ""; const query = (params.query ?? "").trim();
const data = await fetchTheMealDb<TheMealDbMealsResponse>( return query.length > 0 ? await searchByName(query) : await listByCategory(params.cursor);
`/search.php?s=${encodeURIComponent(query)}`,
);
const meals = data.meals ?? [];
return {
items: meals
.filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal))
.map((meal) => ({
externalId: meal.idMeal,
title: meal.strMeal,
picture: meal.strMealThumb,
url: detailUrl(meal.idMeal),
})),
nextCursor: null,
};
} catch (err) { } catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above throw err; // see fetchTheMealDb()'s catch comment above
} }

View file

@ -48,7 +48,9 @@ export async function resetDatabase() {
"recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe", "ingredients", "sources", "unit", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house", "user_profiles", "diet", "house",
"admin_users", "analytics_events", "worker_heartbeats" "tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
"contact_message",
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);
await seedReferenceData(prisma); await seedReferenceData(prisma);

View file

@ -44,16 +44,16 @@ describe("Admin auth", () => {
await prisma.$disconnect(); await prisma.$disconnect();
}); });
describe("POST /admin/auth/login", () => { describe("POST /admin/api/auth/login", () => {
it("rejects a missing body with 400 VALIDATION_ERROR", async () => { it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
const res = await request(app).post("/admin/auth/login").send({}); const res = await request(app).post("/admin/api/auth/login").send({});
expect(res.status).to.equal(400); expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
}); });
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => { it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app) const res = await request(app)
.post("/admin/auth/login") .post("/admin/api/auth/login")
.send({ email: "nobody@example.com", password: "whatever" }); .send({ email: "nobody@example.com", password: "whatever" });
expect(res.status).to.equal(401); expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
@ -62,7 +62,7 @@ describe("Admin auth", () => {
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const { email } = await seedAdmin(); const { email } = await seedAdmin();
const res = await request(app) const res = await request(app)
.post("/admin/auth/login") .post("/admin/api/auth/login")
.send({ email, password: "not-the-password" }); .send({ email, password: "not-the-password" });
expect(res.status).to.equal(401); expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
@ -76,7 +76,7 @@ describe("Admin auth", () => {
} }
const { email, password } = await seedAdmin(); const { email, password } = await seedAdmin();
const res = await request(app).post("/admin/auth/login").send({ email, password }); const res = await request(app).post("/admin/api/auth/login").send({ email, password });
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email); expect(res.body.email).to.equal(email);
@ -94,9 +94,9 @@ describe("Admin auth", () => {
}); });
}); });
describe("GET /admin/auth/me", () => { describe("GET /admin/api/auth/me", () => {
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => { it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/admin/auth/me"); const res = await request(app).get("/admin/api/auth/me");
expect(res.status).to.equal(401); expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
}); });
@ -109,9 +109,9 @@ describe("Admin auth", () => {
} }
const { email, password } = await seedAdmin(); const { email, password } = await seedAdmin();
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password }); await agent.post("/admin/api/auth/login").send({ email, password });
const res = await agent.get("/admin/auth/me"); const res = await agent.get("/admin/api/auth/me");
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email); expect(res.body.email).to.equal(email);
}); });
@ -124,12 +124,12 @@ describe("Admin auth", () => {
} }
const { email, password } = await seedAdmin(); const { email, password } = await seedAdmin();
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password }); await agent.post("/admin/api/auth/login").send({ email, password });
const logoutRes = await agent.post("/admin/auth/logout"); const logoutRes = await agent.post("/admin/api/auth/logout");
expect(logoutRes.status).to.equal(204); expect(logoutRes.status).to.equal(204);
const meRes = await agent.get("/admin/auth/me"); const meRes = await agent.get("/admin/api/auth/me");
expect(meRes.status).to.equal(401); expect(meRes.status).to.equal(401);
}); });
@ -141,7 +141,7 @@ describe("Admin auth", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/admin/auth/me"); const res = await agent.get("/admin/api/auth/me");
expect(res.status).to.equal(401); expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
}); });

View file

@ -47,7 +47,7 @@ async function seedPlaceholderRecipe(name: string): Promise<number> {
} }
/** /**
* `/admin/catalog/*` — the off-catalog ingredient review. Every route is * `/admin/api/catalog/*` — the off-catalog ingredient review. Every route is
* behind `requireAdmin`; the list groups placeholder rows by normalized * behind `requireAdmin`; the list groups placeholder rows by normalized
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows * name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
* no recipe references any more. * no recipe references any more.
@ -66,18 +66,18 @@ describe("Admin catalog — off-catalog ingredients", () => {
async function adminAgent() { async function adminAgent() {
const { email, password } = await seedAdmin(); const { email, password } = await seedAdmin();
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password }); await agent.post("/admin/api/auth/login").send({ email, password });
return agent; return agent;
} }
it("rejects every route without an admin session", async () => { it("rejects every route without an admin session", async () => {
const get = await request(app).get("/admin/catalog/placeholders"); const get = await request(app).get("/admin/api/catalog/placeholders");
expect(get.status).to.equal(401); expect(get.status).to.equal(401);
const patch = await request(app) const patch = await request(app)
.patch("/admin/catalog/placeholders/mark-reviewed") .patch("/admin/api/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [1] }); .send({ ingredientIds: [1] });
expect(patch.status).to.equal(401); expect(patch.status).to.equal(401);
const post = await request(app).post("/admin/catalog/placeholders/prune-orphans"); const post = await request(app).post("/admin/api/catalog/placeholders/prune-orphans");
expect(post.status).to.equal(401); expect(post.status).to.equal(401);
}); });
@ -87,7 +87,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
await seedPlaceholderRecipe("Sumac"); await seedPlaceholderRecipe("Sumac");
const agent = await adminAgent(); const agent = await adminAgent();
const res = await agent.get("/admin/catalog/placeholders"); const res = await agent.get("/admin/api/catalog/placeholders");
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);
@ -106,7 +106,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
const agent = await adminAgent(); const agent = await adminAgent();
const patched = await agent const patched = await agent
.patch("/admin/catalog/placeholders/mark-reviewed") .patch("/admin/api/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [id] }); .send({ ingredientIds: [id] });
expect(patched.status).to.equal(200); expect(patched.status).to.equal(200);
expect(patched.body.reviewed).to.equal(1); expect(patched.body.reviewed).to.equal(1);
@ -114,9 +114,9 @@ describe("Admin catalog — off-catalog ingredients", () => {
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt, (await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
).to.be.an.instanceOf(Date); ).to.be.an.instanceOf(Date);
const pending = await agent.get("/admin/catalog/placeholders"); const pending = await agent.get("/admin/api/catalog/placeholders");
expect(pending.body).to.have.length(0); expect(pending.body).to.have.length(0);
const reviewed = await agent.get("/admin/catalog/placeholders").query({ reviewed: "true" }); const reviewed = await agent.get("/admin/api/catalog/placeholders").query({ reviewed: "true" });
expect(reviewed.body).to.have.length(1); expect(reviewed.body).to.have.length(1);
expect(reviewed.body[0].allReviewed).to.equal(true); expect(reviewed.body[0].allReviewed).to.equal(true);
}); });
@ -133,9 +133,124 @@ describe("Admin catalog — off-catalog ingredients", () => {
}); });
const agent = await adminAgent(); const agent = await adminAgent();
const res = await agent.post("/admin/catalog/placeholders/prune-orphans"); const res = await agent.post("/admin/api/catalog/placeholders/prune-orphans");
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.deleted).to.equal(1); expect(res.body.deleted).to.equal(1);
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

@ -78,7 +78,7 @@ describe("Admin metrics", () => {
}); });
}); });
describe("GET /admin/metrics", () => { describe("GET /admin/api/metrics", () => {
const app = createApp(); const app = createApp();
beforeEach(async () => { beforeEach(async () => {
@ -90,7 +90,7 @@ describe("Admin metrics", () => {
}); });
it("rejects a request with no admin session with 401", async () => { it("rejects a request with no admin session with 401", async () => {
const res = await request(app).get("/admin/metrics"); const res = await request(app).get("/admin/api/metrics");
expect(res.status).to.equal(401); expect(res.status).to.equal(401);
}); });
@ -109,9 +109,9 @@ describe("Admin metrics", () => {
await userB.post("/auth/signup").send(buildSignupPayload()); await userB.post("/auth/signup").send(buildSignupPayload());
const adminAgent = request.agent(app); const adminAgent = request.agent(app);
await adminAgent.post("/admin/auth/login").send({ email, password }); await adminAgent.post("/admin/api/auth/login").send({ email, password });
const res = await adminAgent.get("/admin/metrics").query({ days: 14 }); const res = await adminAgent.get("/admin/api/metrics").query({ days: 14 });
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.rangeDays).to.equal(14); expect(res.body.rangeDays).to.equal(14);
expect(res.body.snapshot.users).to.equal(2); expect(res.body.snapshot.users).to.equal(2);

View file

@ -0,0 +1,174 @@
import type { AddressInfo } from "node:net";
import type { MonitoringView } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import { Server as SocketIoServer } from "socket.io";
import { type Socket as ClientSocket, io as ioClient } from "socket.io-client";
import { createServer } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { signAdminToken } from "../src/lib/admin-jwt.js";
import { MonitoringBroadcastService } from "../src/lib/monitoring-broadcast.service.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
async function seedAdmin(): Promise<{ adminUserId: number; tokenVersion: number }> {
const admin = await prisma.adminUser.create({
data: {
email: faker.internet.email().toLowerCase(),
name: faker.person.fullName(),
passwordHash: await hashAdminPassword(faker.internet.password({ length: 16 })),
},
});
return { adminUserId: admin.id, tokenVersion: admin.tokenVersion };
}
/**
* Waits for `event` on `socket`, rejecting after `timeoutMs` (default
* tuned for the auth-rejection tests, which never touch a real probe and
* so resolve almost immediately — the probe-touching tests below pass a
* larger value explicitly) — every real WebSocket round-trip in this file
* goes through this instead of a bare `socket.once`, so a broken push
* fails the test with a clear message instead of hanging until Mocha's
* own default test timeout.
*/
function waitForEvent<T>(socket: ClientSocket, event: string, timeoutMs = 1500): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for "${event}"`)),
timeoutMs,
);
socket.once(event, (payload: T) => {
clearTimeout(timer);
resolve(payload);
});
});
}
describe("Admin monitoring — WebSocket push", () => {
let baseUrl: string;
let broadcast: MonitoringBroadcastService;
// Hoisted to suite scope (not a local in `before`) so `after` can shut
// it down. `broadcast.stop()` alone only clears the probe interval — the
// socket.io server keeps engine.io's ping timers and the listening
// http.Server (port 0) alive, and Mocha runs without `--exit`
// (`.mocharc.json`), so that one leaked handle stops the whole process
// from ever draining once the last test passes. That is exactly what
// stalled CI: the `test` job printed "N passing" and then hung for
// hours until the runner was killed.
let io: SocketIoServer;
const clientSockets: ClientSocket[] = [];
// A real listening server, not just createApp()'s bare Express instance
// (supertest's usual approach elsewhere in this suite) — socket.io needs
// an actual http.Server to attach to, and a real client (socket.io-
// client) needs a real port to connect to. Port 0 lets the OS pick a
// free one, so this suite never collides with anything else listening.
before(async () => {
const httpServer = createServer().httpServer;
io = new SocketIoServer(httpServer, { cors: { origin: "*" } });
broadcast = new MonitoringBroadcastService(io);
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
const { port } = httpServer.address() as AddressInfo;
baseUrl = `http://localhost:${port}`;
broadcast.start();
});
after(async () => {
broadcast.stop();
// Disconnects any socket still open, stops engine.io's timers, and
// closes the http.Server socket.io was attached to — without this the
// suite never lets Node's event loop drain (see the `io` field's note).
await new Promise<void>((resolve, reject) => {
io.close((err) => (err ? reject(err) : resolve()));
});
await prisma.$disconnect();
});
beforeEach(async () => {
await resetDatabase();
});
afterEach(() => {
for (const socket of clientSockets.splice(0)) {
socket.disconnect();
}
});
/** Connects a client with the given cookie header (or none), tracked for automatic teardown in `afterEach`. */
function connect(cookie?: string): ClientSocket {
const socket = ioClient(baseUrl, {
extraHeaders: cookie ? { Cookie: cookie } : undefined,
// This suite creates several short-lived connections in a row —
// socket.io-client's default reconnection would otherwise keep
// retrying a deliberately-rejected connection in the background
// after its own test has already moved on.
reconnection: false,
forceNew: true,
});
clientSockets.push(socket);
return socket;
}
it("rejects a connection with no admin cookie at all", async () => {
const socket = connect();
const err = await waitForEvent<Error>(socket, "connect_error");
expect(err.message).to.equal("Not authenticated");
});
it("rejects a connection carrying an end-user session cookie, not an admin one", async () => {
const socket = connect("session=whatever-not-an-admin-token");
const err = await waitForEvent<Error>(socket, "connect_error");
expect(err.message).to.equal("Not authenticated");
});
it("accepts a valid admin session and pushes a monitoring:update covering all four targets", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
// Longer than Mocha's 2000ms default: the very first probe tick can
// take close to PROBE_TIMEOUT_MS (2s) if e.g. the intent-service isn't
// reachable from this test environment (a probe timing out is a valid,
// expected outcome here — this test doesn't assert *which* status
// comes back, only that the shape is right).
this.timeout(8000);
const { adminUserId, tokenVersion } = await seedAdmin();
const token = signAdminToken({ adminUserId, tokenVersion });
const socket = connect(`${env.ADMIN_COOKIE_NAME}=${token}`);
const snapshot = await waitForEvent<MonitoringView>(socket, "monitoring:update", 6000);
expect(typeof snapshot.generatedAt).to.equal("string");
const keys = snapshot.services.map((service) => service.key);
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
for (const service of snapshot.services) {
expect(VALID_STATUSES).to.include(service.status);
}
});
it("broadcasts the same update to every connected admin, not one probe per socket", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
this.timeout(8000); // see the previous test's comment
const { adminUserId, tokenVersion } = await seedAdmin();
const token = signAdminToken({ adminUserId, tokenVersion });
const cookie = `${env.ADMIN_COOKIE_NAME}=${token}`;
const first = connect(cookie);
const second = connect(cookie);
const [firstSnapshot, secondSnapshot] = await Promise.all([
waitForEvent<MonitoringView>(first, "monitoring:update", 6000),
waitForEvent<MonitoringView>(second, "monitoring:update", 6000),
]);
expect(firstSnapshot.generatedAt).to.equal(secondSnapshot.generatedAt);
});
});

View file

@ -1,160 +0,0 @@
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";
const SECRET_HEADER = "X-Internal-Worker-Secret";
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
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 };
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
const workerSecretConfigured = env.INTERNAL_WORKER_SECRET !== undefined;
describe("Admin monitoring", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /internal/tech-steps/heartbeat", () => {
it("rejects a request with no worker secret with 401", async () => {
const res = await request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" });
expect(res.status).to.equal(401);
});
it("rejects a malformed body with 400", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const res = await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "not-a-real-event" });
expect(res.status).to.equal(400);
});
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const res = await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({
event: "job",
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
});
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ ok: true });
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastRunAt).to.not.equal(null);
expect(stored.lastResult).to.deep.equal({
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
});
});
it("leaves lastRunAt null for a boot/tick ping", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "boot" });
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastSeenAt).to.be.instanceOf(Date);
expect(stored.lastRunAt).to.equal(null);
});
});
describe("GET /admin/monitoring", () => {
it("rejects a request with no admin session with 401", async () => {
const res = await request(app).get("/admin/monitoring");
expect(res.status).to.equal(401);
});
it("returns a status board covering all four targets, never crashing on an unreachable probe", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
const res = await agent.get("/admin/monitoring");
expect(res.status).to.equal(200);
const keys = res.body.services.map((s: { key: string }) => s.key);
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
for (const service of res.body.services) {
expect(VALID_STATUSES).to.include(service.status);
}
const byKey = Object.fromEntries(res.body.services.map((s: { key: string }) => [s.key, s]));
// The DB is up during the test run, and the API is answering us.
expect(byKey.postgres.status).to.equal("up");
expect(byKey.api.status).to.equal("up");
// No heartbeat has ever been recorded (resetDatabase truncated it).
expect(byKey["tech-step-llm-worker"].status).to.equal("unknown");
});
it("reports the worker as up once it has sent a recent heartbeat", async function () {
if (!adminSecretConfigured || !workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "job", job: "transform-corrections", ok: true, counts: { suggestions: 0 } });
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
const res = await agent.get("/admin/monitoring");
const worker = res.body.services.find(
(s: { key: string }) => s.key === "tech-step-llm-worker",
);
expect(worker.status).to.equal("up");
expect(worker.lastResult.job).to.equal("transform-corrections");
expect(worker.lastRunAt).to.be.a("string");
});
});
});

View file

@ -92,20 +92,20 @@ describe("Admin tech-steps triage", () => {
async function adminAgent() { async function adminAgent() {
const { email, password } = await seedAdmin(); const { email, password } = await seedAdmin();
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password }); await agent.post("/admin/api/auth/login").send({ email, password });
return agent; return agent;
} }
it("rejects every route without an admin session", async () => { it("rejects every route without an admin session", async () => {
for (const path of [ for (const path of [
"/admin/tech-steps/suggestions", "/admin/api/tech-steps/suggestions",
"/admin/tech-steps/corrections", "/admin/api/tech-steps/corrections",
"/admin/tech-steps/training-data-snippet?techStepKey=simmer", "/admin/api/tech-steps/training-data-snippet?techStepKey=simmer",
]) { ]) {
const res = await request(app).get(path); const res = await request(app).get(path);
expect(res.status, path).to.equal(401); expect(res.status, path).to.equal(401);
} }
const post = await request(app).post("/admin/tech-steps/retrain").send({}); const post = await request(app).post("/admin/api/tech-steps/retrain").send({});
expect(post.status).to.equal(401); expect(post.status).to.equal(401);
}); });
@ -129,7 +129,7 @@ describe("Admin tech-steps triage", () => {
const agent = await adminAgent(); const agent = await adminAgent();
const all = await agent.get("/admin/tech-steps/suggestions"); const all = await agent.get("/admin/api/tech-steps/suggestions");
expect(all.status).to.equal(200); expect(all.status).to.equal(200);
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([ expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
"chop", "chop",
@ -139,7 +139,7 @@ describe("Admin tech-steps triage", () => {
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter"); expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
const pendingOnly = await agent const pendingOnly = await agent
.get("/admin/tech-steps/suggestions") .get("/admin/api/tech-steps/suggestions")
.query({ status: "pending" }); .query({ status: "pending" });
expect(pendingOnly.body).to.have.length(1); expect(pendingOnly.body).to.have.length(1);
expect(pendingOnly.body[0].techStepKey).to.equal("simmer"); expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
@ -159,7 +159,7 @@ describe("Admin tech-steps triage", () => {
withSuggestion: { status: "pending", synonyms: ["x"] }, withSuggestion: { status: "pending", synonyms: ["x"] },
}); });
const agent = await adminAgent(); const agent = await adminAgent();
const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({}); const res = await agent.patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`).send({});
expect(res.status).to.equal(400); expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
}); });
@ -172,7 +172,7 @@ describe("Admin tech-steps triage", () => {
} }
const agent = await adminAgent(); const agent = await adminAgent();
const res = await agent const res = await agent
.patch("/admin/tech-steps/suggestions/999999") .patch("/admin/api/tech-steps/suggestions/999999")
.send({ status: "applied" }); .send({ status: "applied" });
expect(res.status).to.equal(404); expect(res.status).to.equal(404);
}); });
@ -191,7 +191,7 @@ describe("Admin tech-steps triage", () => {
const agent = await adminAgent(); const agent = await adminAgent();
const patched = await agent const patched = await agent
.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`) .patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`)
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] }); .send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
expect(patched.status).to.equal(200); expect(patched.status).to.equal(200);
expect(patched.body.status).to.equal("applied"); expect(patched.body.status).to.equal("applied");
@ -215,12 +215,12 @@ describe("Admin tech-steps triage", () => {
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" }); await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
const agent = await adminAgent(); const agent = await adminAgent();
const res = await agent.get("/admin/tech-steps/corrections"); const res = await agent.get("/admin/api/tech-steps/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);
const removals = await agent const removals = await agent
.get("/admin/tech-steps/corrections") .get("/admin/api/tech-steps/corrections")
.query({ hasCorrectedTechStep: "false" }); .query({ hasCorrectedTechStep: "false" });
expect(removals.body).to.have.length(1); expect(removals.body).to.have.length(1);
expect(removals.body[0].clauseText).to.equal("Rien ici"); expect(removals.body[0].clauseText).to.equal("Rien ici");
@ -242,7 +242,7 @@ describe("Admin tech-steps triage", () => {
}); });
const agent = await adminAgent(); const agent = await adminAgent();
const res = await agent const res = await agent
.get("/admin/tech-steps/training-data-snippet") .get("/admin/api/tech-steps/training-data-snippet")
.query({ techStepKey: "simmer", locale: "fr", status: "applied" }); .query({ techStepKey: "simmer", locale: "fr", status: "applied" });
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.suggestionCount).to.equal(1); expect(res.body.suggestionCount).to.equal(1);
@ -261,7 +261,7 @@ describe("Admin tech-steps triage", () => {
} }
this.timeout(60000); this.timeout(60000);
const agent = await adminAgent(); const agent = await adminAgent();
const res = await agent.post("/admin/tech-steps/retrain").send({}); const res = await agent.post("/admin/api/tech-steps/retrain").send({});
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.keys([ expect(res.body).to.have.keys([
"f1", "f1",
@ -292,14 +292,14 @@ describe("Admin tech-steps triage", () => {
// (100ms later) checks it. Swallow its result/rejection — this test // (100ms later) checks it. Swallow its result/rejection — this test
// only asserts on the second request. // only asserts on the second request.
const first = agent const first = agent
.post("/admin/tech-steps/retrain") .post("/admin/api/tech-steps/retrain")
.send({}) .send({})
.then( .then(
(res) => res, (res) => res,
(err) => err, (err) => err,
); );
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
const second = await agent.post("/admin/tech-steps/retrain").send({}); const second = await agent.post("/admin/api/tech-steps/retrain").send({});
expect(second.status).to.equal(409); expect(second.status).to.equal(409);
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING); expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
await first; await first;

View file

@ -0,0 +1,215 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/**
* `/admin/api/tours/*` — CRUD over the guided-tour scenarios that
* `GET /reference/tours` serves to the web tour engine (issues #42/#44).
* Every route behind `requireAdmin`. The DB starts seeded with the two
* default scenarios (`general`, `techStepMetadata`) and the `TourTarget`
* catalogue (see `reference-seed-data.ts`).
*/
describe("Admin tours — scenario CRUD", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
expect((await request(app).get("/admin/api/tours")).status).to.equal(401);
expect((await request(app).get("/admin/api/tours/targets")).status).to.equal(401);
expect(
(await request(app).post("/admin/api/tours").send({ key: "x", name: "X" })).status,
).to.equal(401);
expect((await request(app).patch("/admin/api/tours/1").send({})).status).to.equal(401);
expect((await request(app).delete("/admin/api/tours/1")).status).to.equal(401);
});
it("lists the seeded scenarios (enabled and disabled) with their ordered steps", async () => {
const agent = await adminAgent();
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await agent.get("/admin/api/tours");
expect(res.status).to.equal(200);
const byKey = (key: string) =>
res.body.find((scenario: { key: string }) => scenario.key === key);
expect(byKey("general").enabled).to.equal(true);
expect(byKey("techStepMetadata").enabled).to.equal(false);
expect(byKey("general").steps.length).to.be.greaterThan(0);
expect(byKey("general").steps[0]).to.have.keys([
"order",
"route",
"targetKey",
"title",
"content",
]);
});
it("lists the highlight-target catalogue", async () => {
const agent = await adminAgent();
const res = await agent.get("/admin/api/tours/targets");
expect(res.status).to.equal(200);
expect(res.body.map((t: { key: string }) => t.key)).to.include("planning-grid");
expect(res.body[0]).to.have.keys(["key", "description"]);
});
it("creates an empty scenario, then fills its steps with a block PATCH", async () => {
const agent = await adminAgent();
const created = await agent
.post("/admin/api/tours")
.send({ key: "welcomeBack", name: "Retour utilisateur" });
expect(created.status).to.equal(201);
expect(created.body).to.include({
key: "welcomeBack",
name: "Retour utilisateur",
enabled: true,
});
expect(created.body.steps).to.deep.equal([]);
const patched = await agent.patch(`/admin/api/tours/${created.body.id}`).send({
name: "Retour utilisateur",
enabled: true,
steps: [
{ route: "/", targetKey: "planning-grid", title: "Le planning", content: "Votre semaine." },
{
route: null,
targetKey: "planning-cook-button",
title: "Cuisiner",
content: "Le bouton pour lancer la séance.",
},
],
});
expect(patched.status).to.equal(200);
expect(patched.body.steps.map((s: { order: number }) => s.order)).to.deep.equal([0, 1]);
expect(patched.body.steps[1].targetKey).to.equal("planning-cook-button");
expect(patched.body.steps[1].route).to.equal(null);
});
it("replaces the whole step list on PATCH (delete-all then recreate)", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "Tour d'orientation",
enabled: true,
steps: [
{ route: null, targetKey: "nav-recipes", title: "Recettes", content: "Une seule étape." },
],
});
expect(res.status).to.equal(200);
expect(res.body.steps).to.have.length(1);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(1);
});
it("rejects a duplicate key with 409 TOUR_KEY_ALREADY_IN_USE", async () => {
const agent = await adminAgent();
const res = await agent.post("/admin/api/tours").send({ key: "general", name: "Doublon" });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.TOUR_KEY_ALREADY_IN_USE);
});
it("rejects a PATCH whose step names an unknown target with 404 TOUR_TARGET_NOT_FOUND", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "x",
enabled: true,
steps: [{ route: null, targetKey: "not-a-real-target", title: "t", content: "c" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_TARGET_NOT_FOUND);
// The scenario's real steps are untouched when the PATCH is rejected.
expect(
await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } }),
).to.be.greaterThan(1);
});
it("404s a PATCH/DELETE against an unknown scenario id", async () => {
const agent = await adminAgent();
const patch = await agent
.patch("/admin/api/tours/999999")
.send({ name: "x", enabled: true, steps: [] });
expect(patch.status).to.equal(404);
expect(patch.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
const del = await agent.delete("/admin/api/tours/999999");
expect(del.status).to.equal(404);
});
it("deletes a scenario and cascades its steps and users' progress", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const user = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
await prisma.tourProgress.create({
data: { userProfileId: user.id, scenarioId: general.id },
});
const res = await agent.delete(`/admin/api/tours/${general.id}`);
expect(res.status).to.equal(204);
expect(await prisma.tourScenario.findUnique({ where: { id: general.id } })).to.equal(null);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(0);
expect(await prisma.tourProgress.count({ where: { scenarioId: general.id } })).to.equal(0);
});
it("hides a disabled scenario from the public GET /reference/tours but keeps it in the admin list", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
await agent
.patch(`/admin/api/tours/${general.id}`)
.send({ name: "Tour d'orientation", enabled: false, steps: [] });
const publicRes = await request(app).get("/reference/tours");
expect(publicRes.body.map((t: { key: string }) => t.key)).to.not.include("general");
const adminRes = await agent.get("/admin/api/tours");
expect(adminRes.body.map((t: { key: string }) => t.key)).to.include("general");
});
});

View file

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

@ -55,6 +55,17 @@ describe("Auth", () => {
expect(res.headers["set-cookie"]?.[0]).to.include("session="); expect(res.headers["set-cookie"]?.[0]).to.include("session=");
}); });
it("also echoes the session JWT in the response body (for cookie-less clients)", async () => {
const res = await request(app).post("/auth/signup").send(buildSignupPayload());
expect(res.status).to.equal(201);
// Same token that's set as the cookie — see `AuthResponse`
// (packages/shared). Lets a Capacitor WebView, whose origin can't
// receive the SameSite cookie, store it and send it as a Bearer.
expect(res.body.token).to.be.a("string").with.length.greaterThan(0);
expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`);
});
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => { it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
const payload = buildSignupPayload(); const payload = buildSignupPayload();
await request(app).post("/auth/signup").send(payload); await request(app).post("/auth/signup").send(payload);
@ -95,6 +106,16 @@ describe("Auth", () => {
expect(res.body.email).to.equal(payload.email); expect(res.body.email).to.equal(payload.email);
}); });
it("echoes the session JWT in the response body too", async () => {
const res = await request(app)
.post("/auth/login")
.send({ email: payload.email, password: payload.password });
expect(res.status).to.equal(200);
expect(res.body.token).to.be.a("string").with.length.greaterThan(0);
expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`);
});
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app) const res = await request(app)
.post("/auth/login") .post("/auth/login")
@ -134,6 +155,41 @@ describe("Auth", () => {
}); });
}); });
// The Bearer path exists for clients whose origin can't carry the
// `SameSite=Lax` session cookie (a Capacitor WebView — issue #39). The
// cookie stays the primary mechanism; the header is only consulted when
// no cookie is present. `request(app)` (no `.agent`) sends no cookie, so
// these requests exercise the fallback in isolation.
describe("Authorization: Bearer fallback (cookie-less clients)", () => {
it("authenticates a protected route with a valid Bearer token and no cookie", async () => {
const payload = buildSignupPayload();
const signupRes = await request(app).post("/auth/signup").send(payload);
const res = await request(app)
.get("/auth/me")
.set("Authorization", `Bearer ${signupRes.body.token}`);
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(payload.email);
});
it("rejects a malformed Bearer token with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/auth/me").set("Authorization", "Bearer not-a-real-jwt");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("ignores a non-Bearer Authorization scheme and 401s", async () => {
const res = await request(app)
.get("/auth/me")
.set("Authorization", "Basic dXNlcjpwYXNzd29yZA==");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
describe("DELETE /auth/me", () => { describe("DELETE /auth/me", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).delete("/auth/me").send({ password: "whatever" }); const res = await request(app).delete("/auth/me").send({ password: "whatever" });

View file

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

View file

@ -177,6 +177,7 @@ describe("Cooking session", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: soupe.id, recipeId: soupe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -184,6 +185,7 @@ describe("Cooking session", () => {
meal: "diner", meal: "diner",
recipeId: tarte.id, recipeId: tarte.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
], ],
}); });
@ -280,6 +282,7 @@ describe("Cooking session", () => {
meal: i === 0 ? "dejeuner" : "diner", meal: i === 0 ? "dejeuner" : "diner",
recipeId, recipeId,
portions: 4, portions: 4,
portionsRequested: 4,
})), })),
}); });
} }
@ -325,5 +328,42 @@ describe("Cooking session", () => {
180, 210, 180, 210,
]); ]);
}); });
it("leaves a leftovers item out of the cooking plan — it has nothing to cook (issue #34)", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({
data: {
name: "Soupe",
authorId: houseRes.body.adminId,
portions: 4,
steps: { create: [{ order: 0, description: "Faire mijoter la soupe." }] },
},
});
// One real slot (12 prepared for 10 requested), then a leftovers slot
// reusing 2 of it — the plan must still contain only "Soupe" once.
const source = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 10,
});
await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.body.id,
portions: 2,
});
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.deep.equal(["Soupe"]);
});
}); });
}); });

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

@ -302,5 +302,58 @@ describe("Internal tech-step worker routes", () => {
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND); expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
}); });
}); });
// Moved here from the now-deleted admin-monitoring.test.ts when that
// file's own REST route (GET /admin/api/monitoring) was replaced by a
// WebSocket push (see admin-monitoring-socket.test.ts) — this route
// (POST /internal/tech-steps/heartbeat) is unrelated to that change
// and belongs with the rest of this router's own tests. No separate
// "rejects with no secret" case here: `requireInternalWorker` above
// already proves that generically for this whole router.
describe("POST /internal/tech-steps/heartbeat", () => {
it("rejects a malformed body with 400", async () => {
const res = await withSecret(
request(app).post("/internal/tech-steps/heartbeat").send({ event: "not-a-real-event" }),
);
expect(res.status).to.equal(400);
});
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/heartbeat")
.send({
event: "job",
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
}),
);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ ok: true });
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastRunAt).to.not.equal(null);
expect(stored.lastResult).to.deep.equal({
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
});
});
it("leaves lastRunAt null for a boot/tick ping", async () => {
await withSecret(
request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" }),
);
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastSeenAt).to.be.instanceOf(Date);
expect(stored.lastRunAt).to.equal(null);
});
});
}); });
}); });

View file

@ -0,0 +1,73 @@
import { computeAvailableLeftover, computeBatchedPortions } from "@batch-cooking/shared";
import { expect } from "chai";
/**
* Pure portion arithmetic from `@batch-cooking/shared` — no DB, so this
* suite skips `resetDatabase`. The API (`addPlanningItem`, and later the
* leftover-assignment endpoints) and the web app both call these; the
* numbers below are the two worked examples from issue #34 plus the
* boundary cases.
*/
describe("planning portion arithmetic", () => {
describe("computeBatchedPortions", () => {
it("rounds up to one batch when fewer portions are requested than the recipe yields", () => {
expect(computeBatchedPortions(4, 3)).to.deep.equal({ portions: 4, leftover: 1 });
});
it("cooks several batches when more portions are requested than one batch yields", () => {
expect(computeBatchedPortions(4, 10)).to.deep.equal({ portions: 12, leftover: 2 });
});
it("leaves no leftover on an exact batch boundary", () => {
expect(computeBatchedPortions(4, 4)).to.deep.equal({ portions: 4, leftover: 0 });
expect(computeBatchedPortions(4, 8)).to.deep.equal({ portions: 8, leftover: 0 });
});
it("still prepares one batch when a single portion is requested", () => {
expect(computeBatchedPortions(6, 1)).to.deep.equal({ portions: 6, leftover: 5 });
});
it("handles a one-portion recipe (every request is an exact number of batches)", () => {
expect(computeBatchedPortions(1, 5)).to.deep.equal({ portions: 5, leftover: 0 });
});
});
describe("computeAvailableLeftover", () => {
const base = { portions: 12, portionsRequested: 10, discardedPortions: 0, leftoverUses: [] };
it("is the prepared-minus-requested pool when nothing has been discarded or served", () => {
expect(computeAvailableLeftover(base)).to.equal(2);
});
it("subtracts portions already served to other slots", () => {
expect(
computeAvailableLeftover({ ...base, leftoverUses: [{ portions: 1 }, { portions: 1 }] }),
).to.equal(0);
});
it("subtracts explicitly discarded portions", () => {
expect(computeAvailableLeftover({ ...base, discardedPortions: 2 })).to.equal(0);
});
it("never goes negative", () => {
expect(
computeAvailableLeftover({
...base,
discardedPortions: 5,
leftoverUses: [{ portions: 3 }],
}),
).to.equal(0);
});
it("is 0 for an item whose request landed exactly on a batch (no pool at all)", () => {
expect(
computeAvailableLeftover({
portions: 8,
portionsRequested: 8,
discardedPortions: 0,
leftoverUses: [],
}),
).to.equal(0);
});
});
});

View file

@ -114,6 +114,7 @@ describe("Planning", () => {
meal: "diner", meal: "diner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 3,
}, },
}); });
@ -122,7 +123,14 @@ describe("Planning", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.id).to.equal(planning.id); expect(res.body.id).to.equal(planning.id);
expect(res.body.items).to.have.length(1); expect(res.body.items).to.have.length(1);
expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner" }); expect(res.body.items[0]).to.include({
weekDay: "lundi",
meal: "diner",
portions: 4,
portionsRequested: 3,
discardedPortions: 0,
});
expect(res.body.items[0].sourceItem).to.equal(null);
expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" }); expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" });
}); });
@ -171,6 +179,7 @@ describe("Planning", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 2, portions: 2,
portionsRequested: 2,
}, },
}); });
@ -183,4 +192,261 @@ describe("Planning", () => {
expect(thisWeekRes.body).to.equal(null); expect(thisWeekRes.body).to.equal(null);
}); });
}); });
describe("POST /planning/items", () => {
/** Signs up, creates a household, and returns an authenticated agent + a saved recipe with the given yield. */
async function setup(recipePortions: number) {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({
data: { name: "Lasagnes", authorId: houseRes.body.adminId, portions: recipePortions },
});
return { agent, recipe };
}
it("rounds the prepared portions up to a whole batch of the recipe and reports the leftover", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 3,
});
expect(res.status).to.equal(201);
// 3 requested, 4-portion recipe -> one batch of 4 prepared, 1 leftover.
expect(res.body).to.include({ portions: 4, portionsRequested: 3, discardedPortions: 0 });
expect(res.body.sourceItem).to.equal(null);
const stored = await prisma.planningItem.findUnique({ where: { id: res.body.id } });
expect(stored).to.include({ portions: 4, portionsRequested: 3 });
});
it("cooks several batches when more portions are requested than one batch yields", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portionsRequested: 10,
});
expect(res.status).to.equal(201);
// 10 requested, 4-portion recipe -> three batches (12) prepared, 2 leftover.
expect(res.body).to.include({ portions: 12, portionsRequested: 10 });
});
it("leaves no leftover when the request lands exactly on a batch boundary", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "mercredi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 8,
});
expect(res.status).to.equal(201);
expect(res.body).to.include({ portions: 8, portionsRequested: 8 });
});
it("rejects a non-positive portionsRequested with 400 VALIDATION_ERROR", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 0,
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("leftovers — POST /planning/items/leftover and PATCH .../discard-leftover", () => {
/**
* Signs up + creates a household, saves a 4-portion recipe, and plans
* it once with `requested` portions — so the resulting item has a
* leftover pool of `ceil(requested/4)*4 - requested`. Returns the agent,
* the recipe, and that source item.
*/
async function setupWithSource(requested: number) {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({
data: { name: "Lasagnes", authorId: houseRes.body.adminId, portions: 4 },
});
const addRes = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: requested,
});
return { agent, recipe, source: addRes.body };
}
it("serves part of a source item's leftover pool as a new leftovers item on another slot", async () => {
// 10 requested on a 4-portion recipe -> 12 prepared, pool of 2.
const { agent, recipe, source } = await setupWithSource(10);
expect(source.portions).to.equal(12);
const res = await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.id,
portions: 2,
});
expect(res.status).to.equal(201);
expect(res.body).to.include({
weekDay: "mardi",
meal: "dejeuner",
portions: 2,
portionsRequested: 2,
discardedPortions: 0,
});
expect(res.body.sourceItem).to.deep.equal({ id: source.id });
// The recipe is copied from the source, not sent by the client.
expect(res.body.recipe.id).to.equal(recipe.id);
});
it("rejects assigning more portions than the source pool still has, with 409 LEFTOVER_QUANTITY_EXCEEDED", async () => {
const { agent, source } = await setupWithSource(10); // pool of 2
// Take 2 of the 2 available...
await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.id,
portions: 2,
});
// ...then ask for 1 more — nothing left.
const res = await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mercredi",
meal: "diner",
sourceItemId: source.id,
portions: 1,
});
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.LEFTOVER_QUANTITY_EXCEEDED);
});
it("404s when the source item belongs to another household", async () => {
const { source } = await setupWithSource(10);
const stranger = request.agent(app);
await stranger.post("/auth/signup").send(buildSignupPayload());
await stranger.post("/house").send({ name: "Ailleurs" });
const res = await stranger.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.id,
portions: 1,
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
});
it("404s when the source item is itself a leftovers item (no leftovers-on-leftovers)", async () => {
const { agent, source } = await setupWithSource(10);
const leftover = await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.id,
portions: 1,
});
const res = await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mercredi",
meal: "diner",
sourceItemId: leftover.body.id,
portions: 1,
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
});
it("deleting the source item cascades to every leftovers item served from it", async () => {
const { agent, source } = await setupWithSource(10);
const leftover = await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.id,
portions: 2,
});
await agent.delete(`/planning/items/${source.id}`).expect(204);
const stillThere = await prisma.planningItem.findUnique({ where: { id: leftover.body.id } });
expect(stillThere).to.equal(null);
});
it("discards the whole remaining pool, and is idempotent", async () => {
const { agent, source } = await setupWithSource(10); // pool of 2
const first = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
expect(first.status).to.equal(200);
expect(first.body.discardedPortions).to.equal(2);
// Nothing left — a second call changes nothing.
const second = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
expect(second.status).to.equal(200);
expect(second.body.discardedPortions).to.equal(2);
// And the pool is now empty for an assignment too.
const assign = await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.id,
portions: 1,
});
expect(assign.status).to.equal(409);
});
it("only discards what's still available after some was already served", async () => {
const { agent, source } = await setupWithSource(10); // pool of 2
await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.id,
portions: 1,
});
const res = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
expect(res.status).to.equal(200);
// 1 of the 2 was served; only the other 1 gets discarded.
expect(res.body.discardedPortions).to.equal(1);
});
it("404s discarding an unknown planning item", async () => {
const { agent } = await setupWithSource(4);
const res = await agent.patch("/planning/items/999999/discard-leftover");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
});
});
}); });

View file

@ -195,4 +195,95 @@ describe("Profile", () => {
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
}); });
}); });
describe("Guided tours (GET /profile/tours, POST /profile/tours/:key/complete)", () => {
it("rejects an unauthenticated request with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/profile/tours");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("starts with no completed tours for a fresh profile", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/profile/tours");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ completedTourKeys: [] });
});
it("marks a tour seen and reports it back", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const post = await agent.post("/profile/tours/general/complete").send({});
expect(post.status).to.equal(204);
const res = await agent.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
});
it("is idempotent — completing the same tour twice stays a single row", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/profile/tours/general/complete").send({});
const second = await agent.post("/profile/tours/general/complete").send({});
expect(second.status).to.equal(204);
const res = await agent.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
});
it("accepts { skipped: true } and still marks the tour seen", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const post = await agent
.post("/profile/tours/techStepMetadata/complete")
.send({ skipped: true });
expect(post.status).to.equal(204);
const res = await agent.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal(["techStepMetadata"]);
});
it("rejects an unknown tour key with 404 TOUR_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/profile/tours/does-not-exist/complete").send({});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
});
it("rejects completing a disabled tour with 404 TOUR_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await agent.post("/profile/tours/techStepMetadata/complete").send({});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
});
it("keeps tours per-profile — one user's completion doesn't leak to another", async () => {
const alice = request.agent(app);
await alice.post("/auth/signup").send(buildSignupPayload());
await alice.post("/profile/tours/general/complete").send({});
const bob = request.agent(app);
await bob.post("/auth/signup").send(buildSignupPayload());
const res = await bob.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal([]);
});
});
}); });

View file

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

@ -0,0 +1,195 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { ohMonBentoAdapter } from "../../src/sources/ohmonbento.js";
/** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`quitoque.test.ts`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/** Like {@link stubFetchHtml} but records the URL each call was made with — for asserting `list()`'s search/pagination URL. */
function stubFetchHtmlCapturing(html: string): { calls: string[] } {
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
calls.push(typeof input === "string" ? input : input.toString());
return new Response(html, { status: 200 });
}) as typeof fetch;
return { calls };
}
/** A search-results page with `cards` `<div class="entry-thumb">` blocks, each an `<a href="/<slug>/">` wrapping one `<img alt src>`. */
function searchHtml(cards: { slug: string; title: string; image: string }[]): string {
const blocks = cards
.map(
(card) =>
`<div class="entry-thumb"><a href="/${card.slug}/">` +
`<img alt="${card.title}" src="${card.image}" /></a></div>`,
)
.join("\n");
return `<!doctype html><html><body><div class="search-results">${blocks}</div></body></html>`;
}
/** A recipe page: a WP Recipe Maker `Recipe` inside a Yoast `@graph` array. */
function detailHtml(): string {
const payload = {
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", name: "Oh mon Bento" },
{ "@type": "BreadcrumbList" },
{
"@type": "Recipe",
name: "Kamameshi",
description: "Riz japonais mijoté en cocotte.",
image: "https://ohmonbento.com/wp-content/uploads/kamameshi.jpg",
recipeYield: ["2", "2 portions"],
recipeIngredient: ["300 g de riz rond japonais (Japonica)", "1 c. à soupe de sauce soja"],
recipeInstructions: [
{ "@type": "HowToStep", name: "Rincer le riz", text: "Rincez le riz à l'eau claire." },
{ "@type": "HowToStep", name: "Cuire", text: "Faites cuire à couvert 12 minutes." },
],
},
],
};
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
describe("ohMonBentoAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(ohMonBentoAdapter.key).to.equal("ohMonBento");
expect(ohMonBentoAdapter.name).to.equal("Oh mon Bento");
expect(ohMonBentoAdapter.official).to.equal(false);
expect(ohMonBentoAdapter.iconUrl).to.be.a("string");
expect(ohMonBentoAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("scrapes each entry-thumb card into a RecipeSourceListItem and offers a next page", async () => {
stubFetchHtml(
searchHtml([
{ slug: "kamameshi", title: "Kamameshi", image: "https://img.test/1.jpg" },
{ slug: "onigiri", title: "Onigiri", image: "https://img.test/2.jpg" },
]),
);
const result = await ohMonBentoAdapter.list({});
expect(result.items).to.deep.equal([
{
externalId: "https://ohmonbento.com/kamameshi/",
title: "Kamameshi",
picture: "https://img.test/1.jpg",
url: "https://ohmonbento.com/kamameshi/",
},
{
externalId: "https://ohmonbento.com/onigiri/",
title: "Onigiri",
picture: "https://img.test/2.jpg",
url: "https://ohmonbento.com/onigiri/",
},
]);
expect(result.nextCursor).to.equal("2");
});
it("treats a 404 (past the last page) as the clean end of the list", async () => {
stubFetchHtml("", 404);
const result = await ohMonBentoAdapter.list({ cursor: "99" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("uses the same `/?s=` search URL for an empty query, with no `paged` on page 1", async () => {
const { calls } = stubFetchHtmlCapturing(
searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
);
await ohMonBentoAdapter.list({});
expect(calls).to.deep.equal(["https://ohmonbento.com/?s="]);
});
it("passes the query and page through to `/?s={query}&paged={n}`", async () => {
const { calls } = stubFetchHtmlCapturing(
searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
);
await ohMonBentoAdapter.list({ query: "bento box", cursor: "3" });
expect(calls).to.deep.equal(["https://ohmonbento.com/?s=bento%20box&paged=3"]);
});
it("throws RecipeSourceFetchError keyed 'ohMonBento' on a non-404 non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await ohMonBentoAdapter.list({});
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento");
}
});
});
describe("fetchDetail / parse", () => {
it("parses a Yoast @graph-wrapped WPRM Recipe into a ParsedRecipe", async () => {
stubFetchHtml(detailHtml());
const raw = await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/kamameshi/");
const parsed = ohMonBentoAdapter.parse(raw);
expect(parsed.name).to.equal("Kamameshi");
// recipeYield: ["2", "2 portions"] -> first element, regex \d+ -> 2.
expect(parsed.portions).to.equal(2);
expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([
"300 g de riz rond japonais (Japonica)",
"1 c. à soupe de sauce soja",
]);
expect(parsed.steps.map((s) => s.description)).to.deep.equal([
"Rincez le riz à l'eau claire.",
"Faites cuire à couvert 12 minutes.",
]);
});
it("re-keys a delegated RecipeSourceFetchError to 'ohMonBento' when the detail page 404s", async () => {
stubFetchHtml("", 404);
try {
await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/gone/");
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento");
}
});
it("re-keys a delegated RecipeSourceParseError to 'ohMonBento' when a page has no Recipe JSON-LD", () => {
try {
ohMonBentoAdapter.parse({
html: "<!doctype html><html><body>rien</body></html>",
url: "https://ohmonbento.com/vide/",
});
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("ohMonBento");
}
});
});
});

View file

@ -0,0 +1,203 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { quitoqueAdapter } from "../../src/sources/quitoque.js";
/** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`750g.test.ts`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/** Like {@link stubFetchHtml} but records the URL each call was made with — for asserting `list()`'s pagination. */
function stubFetchHtmlCapturing(html: string): { calls: string[] } {
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
calls.push(typeof input === "string" ? input : input.toString());
return new Response(html, { status: 200 });
}) as typeof fetch;
return { calls };
}
/** A `/recettes` listing page carrying `count` cards, each a single `<a class="card__content_header">` wrapping an `<img alt data-src>`. */
function listingHtml(cards: { slug: string; title: string; image: string }[]): string {
const anchors = cards
.map(
(card) =>
`<article><a class="card__content_header" href="/recettes/${card.slug}">` +
`<img src="/placeholder.svg" alt="${card.title}" data-src="${card.image}" />` +
`</a></article>`,
)
.join("\n");
return `<!doctype html><html><body><main>${anchors}</main></body></html>`;
}
/** A quitoque.fr recipe page: a plain schema.org `Recipe` as the sole element of a top-level JSON-LD array. */
function detailHtml(): string {
const payload = [
{
"@context": "https://schema.org",
"@type": "Recipe",
name: "Mijoté de lentilles au lait de coco",
description: "Un plat végétarien réconfortant.",
image: "https://www.quitoque.fr/img/mijote.jpg",
recipeYield: "2",
recipeIngredient: ["150 g lentilles vertes (Bio)", "1 gousse d'ail", "0.5 citron jaune"],
recipeInstructions: [
{ "@type": "HowToStep", name: "1. Les légumes", text: "Émincez l'ail." },
{ "@type": "HowToStep", name: "2. La cuisson", text: "Faites mijoter 25 minutes." },
],
},
];
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
describe("quitoqueAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(quitoqueAdapter.key).to.equal("quitoque");
expect(quitoqueAdapter.name).to.equal("Quitoque");
expect(quitoqueAdapter.official).to.equal(false);
expect(quitoqueAdapter.iconUrl).to.be.a("string");
expect(quitoqueAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("scrapes each card into a RecipeSourceListItem (real image from data-src, absolute url) and offers a next page", async () => {
stubFetchHtml(
listingHtml([
{
slug: "mijote-lentilles",
title: "Mijoté de lentilles",
image: "https://img.test/1.jpg",
},
{ slug: "curry-legumes", title: "Curry de légumes", image: "https://img.test/2.jpg" },
]),
);
const result = await quitoqueAdapter.list({});
expect(result.items).to.deep.equal([
{
externalId: "https://www.quitoque.fr/recettes/mijote-lentilles",
title: "Mijoté de lentilles",
picture: "https://img.test/1.jpg",
url: "https://www.quitoque.fr/recettes/mijote-lentilles",
},
{
externalId: "https://www.quitoque.fr/recettes/curry-legumes",
title: "Curry de légumes",
picture: "https://img.test/2.jpg",
url: "https://www.quitoque.fr/recettes/curry-legumes",
},
]);
expect(result.nextCursor).to.equal("2");
});
it("treats a page with no cards as the end of the list", async () => {
stubFetchHtml("<!doctype html><html><body><main></main></body></html>");
const result = await quitoqueAdapter.list({ cursor: "9" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("follows the cursor to `/recettes?page=N`", async () => {
const { calls } = stubFetchHtmlCapturing(
listingHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
);
await quitoqueAdapter.list({ cursor: "3" });
expect(calls).to.deep.equal(["https://www.quitoque.fr/recettes?page=3"]);
});
it("best-effort filters a page's cards by a query substring, but still advances if the raw page had cards", async () => {
stubFetchHtml(
listingHtml([
{ slug: "poulet-curry", title: "Poulet au curry", image: "https://img.test/p.jpg" },
{ slug: "boeuf-carottes", title: "Bœuf carottes", image: "https://img.test/b.jpg" },
]),
);
const result = await quitoqueAdapter.list({ query: "poulet" });
expect(result.items.map((item) => item.title)).to.deep.equal(["Poulet au curry"]);
// The page did have cards, so the next page might match too.
expect(result.nextCursor).to.equal("2");
});
it("throws RecipeSourceFetchError keyed 'quitoque' on a non-2xx listing response", async () => {
stubFetchHtml("", 503);
try {
await quitoqueAdapter.list({});
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque");
}
});
});
describe("fetchDetail / parse", () => {
it("parses a quitoque.fr recipe page's top-level-array JSON-LD into a ParsedRecipe", async () => {
stubFetchHtml(detailHtml());
const raw = await quitoqueAdapter.fetchDetail(
"https://www.quitoque.fr/recettes/mijote-lentilles",
);
const parsed = quitoqueAdapter.parse(raw);
expect(parsed.name).to.equal("Mijoté de lentilles au lait de coco");
expect(parsed.portions).to.equal(2);
expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([
"150 g lentilles vertes (Bio)",
"1 gousse d'ail",
"0.5 citron jaune",
]);
expect(parsed.steps.map((s) => s.description)).to.deep.equal([
"Émincez l'ail.",
"Faites mijoter 25 minutes.",
]);
});
it("re-keys a delegated RecipeSourceFetchError to 'quitoque' when the detail page 404s", async () => {
stubFetchHtml("", 404);
try {
await quitoqueAdapter.fetchDetail("https://www.quitoque.fr/recettes/gone");
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque");
}
});
it("re-keys a delegated RecipeSourceParseError to 'quitoque' when a page has no Recipe JSON-LD", () => {
try {
quitoqueAdapter.parse({
html: "<!doctype html><html><body>rien ici</body></html>",
url: "https://www.quitoque.fr/recettes/vide",
});
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("quitoque");
}
});
});
});

View file

@ -16,6 +16,28 @@ function stubFetch(body: unknown, status = 200) {
globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch; globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch;
} }
/**
* Route-aware variant of {@link stubFetch} for the empty-query category
* walk, which fires several different requests (`/list.php?c=list`, then a
* `/filter.php?c=…`) within a single `list()` call. Matches by URL
* substring; an unstubbed path throws rather than silently returning junk.
* Returns the list of requested URLs, so a test can assert *which*
* endpoint was hit (e.g. category walk vs. `/search.php`).
*/
function stubFetchByUrl(routes: Record<string, unknown>, status = 200): string[] {
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url = typeof input === "string" ? input : input.toString();
calls.push(url);
const route = Object.keys(routes).find((fragment) => url.includes(fragment));
if (route === undefined) {
throw new Error(`stubFetchByUrl: no route matches ${url}`);
}
return new Response(JSON.stringify(routes[route]), { status });
}) as typeof fetch;
return calls;
}
const baseMeal: TheMealDbMeal = { const baseMeal: TheMealDbMeal = {
idMeal: "52795", idMeal: "52795",
strMeal: "Chicken Handi", strMeal: "Chicken Handi",
@ -109,6 +131,113 @@ describe("theMealDbAdapter", () => {
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error); expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
} }
}); });
// Empty/omitted query = "browse everything". The free tier has no
// catalog-wide endpoint, so the adapter walks categories one per
// cursor step (issue #38) rather than settling for `/search.php?s=`'s
// fixed ~25-meal sample.
describe("empty query — walks categories", () => {
const CATEGORIES = {
meals: [{ strCategory: "Beef" }, { strCategory: "Chicken" }, { strCategory: "Dessert" }],
};
it("returns the first category's meals, cursor pointing at the next category", async () => {
stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Beef": {
meals: [
{ idMeal: "1", strMeal: "Beef Pie", strMealThumb: "https://img.test/1.jpg" },
{ idMeal: "2", strMeal: "Beef Stew", strMealThumb: null },
],
},
});
const result = await theMealDbAdapter.list({});
expect(result.items).to.deep.equal([
{
externalId: "1",
title: "Beef Pie",
picture: "https://img.test/1.jpg",
url: "https://www.themealdb.com/meal/1",
},
{
externalId: "2",
title: "Beef Stew",
picture: null,
url: "https://www.themealdb.com/meal/2",
},
]);
expect(result.nextCursor).to.equal("1");
});
it("follows the cursor into the next category", async () => {
const calls = stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Chicken": {
meals: [{ idMeal: "3", strMeal: "Chicken Handi", strMealThumb: null }],
},
});
const result = await theMealDbAdapter.list({ cursor: "1" });
expect(calls.some((u) => u.includes("/filter.php?c=Chicken"))).to.equal(true);
expect(result.items.map((i) => i.externalId)).to.deep.equal(["3"]);
expect(result.nextCursor).to.equal("2");
});
it("stops (null cursor) on the last category", async () => {
stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Dessert": {
meals: [{ idMeal: "9", strMeal: "Flan", strMealThumb: null }],
},
});
const result = await theMealDbAdapter.list({ cursor: "2" });
expect(result.nextCursor).to.be.null;
});
it("returns an empty final page once the cursor is past the last category", async () => {
stubFetchByUrl({ "/list.php?c=list": CATEGORIES });
const result = await theMealDbAdapter.list({ cursor: "3" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("treats a blank query the same as no query — category walk, never /search.php", async () => {
const calls = stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Beef": {
meals: [{ idMeal: "1", strMeal: "Beef Pie", strMealThumb: null }],
},
});
await theMealDbAdapter.list({ query: " " });
expect(calls.some((u) => u.includes("/search.php"))).to.equal(false);
expect(calls.some((u) => u.includes("/list.php?c=list"))).to.equal(true);
});
it("skips a titleless meal in a category page, like search does", async () => {
stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Beef": {
meals: [
{ idMeal: "1", strMeal: null, strMealThumb: null },
{ idMeal: "2", strMeal: "Beef Stew", strMealThumb: null },
],
},
});
const result = await theMealDbAdapter.list({});
expect(result.items.map((i) => i.externalId)).to.deep.equal(["2"]);
});
});
}); });
describe("fetchDetail", () => { describe("fetchDetail", () => {

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 }],
}); });
@ -883,6 +1003,55 @@ describe("Recipes", () => {
expect(orphanedTechSteps).to.deep.equal([]); expect(orphanedTechSteps).to.deep.equal([]);
}); });
it("carries a replaced step's clause temperatures through the edit, not just its technique span", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const created = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
// The pre-fix bug: `updateRecipe` persisted only each detected
// technique's span on a resave and silently dropped every
// temperature / utensil / ingredient the classifier attributed to the
// clause (only the *create* path wrote them). A recipe edited even
// once therefore had temperature-less `preheat`/`bake` steps, so
// `cooking-optimizer.ts` could never flag an oven-temperature
// conflict between two of them. Same assertion as the POST test
// "extracts a step's temperature mentions...", but reached via PATCH.
const editedDescription = "Enfourner à 180°C puis finir à feu doux";
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Gratin",
portions: 4,
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }],
});
expect(res.status).to.equal(200);
expect(res.body.steps[0].techSteps.length).to.be.greaterThan(0);
const temperatures = res.body.steps[0].techSteps.flatMap(
(techStep: { temperatures: unknown[] }) => techStep.temperatures,
);
const celsius = temperatures.find(
(temperature: { celsius: number | null }) => temperature.celsius !== null,
);
expect(celsius).to.include({
celsius: 180,
gasMark: null,
qualitative: null,
source: "auto",
});
expect(editedDescription.slice(celsius.start, celsius.end)).to.equal("180°C");
expect(
temperatures.some(
(temperature: { qualitative: string | null }) => temperature.qualitative === "low",
),
).to.equal(true);
});
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
@ -891,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" }],
}); });
@ -900,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();
@ -936,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" }],
}); });
@ -980,6 +1123,7 @@ describe("Recipes", () => {
meal: "diner", meal: "diner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
}); });

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",
@ -518,6 +583,191 @@ describe("Recipe tech-step corrections", () => {
}); });
}); });
describe("POST /recipes/:id/steps/:stepId/corrections — temperature metadata", () => {
it("attaches a manually-selected celsius temperature, deriving raw from the step's own description", async () => {
const { agent, profileId } = await signup();
const description = "Préchauffez à 180°C.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const preheatId = await techStepId("preheat");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: 11,
correctedTechStepId: preheatId,
temperatures: [{ celsius: 180, start: 14, end: 19 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null },
start: 0,
end: 11,
source: "manual",
ingredients: [],
utensils: [],
temperatures: [
{
celsius: 180,
gasMark: null,
qualitative: null,
// Not sent by the client — derived server-side from the span
// against the step's real description (see
// `ManualTemperatureMention`'s doc comment).
raw: "180°C",
start: 14,
end: 19,
source: "manual",
},
],
},
]);
});
it("attaches a manually-selected qualitative temperature", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
temperatures: [{ qualitative: "low", start: 0, end: 5 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].temperatures).to.deep.equal([
{
celsius: null,
gasMark: null,
qualitative: "low",
raw: "Faire",
start: 0,
end: 5,
source: "manual",
},
]);
});
it("attaches a manually-selected gas-mark temperature", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].temperatures[0]).to.deep.equal({
celsius: null,
gasMark: 6,
qualitative: null,
raw: "Faire",
start: 0,
end: 5,
source: "manual",
});
});
it("rejects a temperature with more than one of celsius/gasMark/qualitative set 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"),
temperatures: [{ celsius: 180, qualitative: "high", start: 0, end: 5 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a temperature with none of celsius/gasMark/qualitative set 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"),
temperatures: [{ start: 0, end: 5 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("replaces both auto-detected and previously-manual temperatures on the same occurrence — never accumulates", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
// Simulate an auto-detected temperature already sitting on the
// occurrence, exactly as `toStepTechStepCreateInputs` would have
// written one at save time.
await prisma.stepTechStepTemperature.create({
data: {
stepId,
techStepOrder: 0,
celsius: 100,
raw: "100°C",
start: 0,
end: 5,
source: "auto",
},
});
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.have.length(1);
expect(res.body.techSteps[0].temperatures).to.deep.equal([
{
celsius: null,
gasMark: 6,
qualitative: null,
raw: "Faire",
start: 0,
end: 5,
source: "manual",
},
]);
});
it("rejects a temperature span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
const { agent, profileId } = await signup();
const description = "Court.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: description.length,
correctedTechStepId: await techStepId("simmer"),
temperatures: [{ celsius: 180, start: 0, end: description.length + 10 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
});
describe("GET /recipes/:id/steps/:stepId/corrections", () => { describe("GET /recipes/:id/steps/:stepId/corrections", () => {
it("returns every correction submitted for the step, most recent first", async () => { it("returns every correction submitted for the step, most recent first", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
@ -536,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

@ -3,7 +3,12 @@ import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js"; import {
seedReferenceData,
TECH_STEPS,
TOUR_SCENARIOS,
UTENSILS,
} from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import { import {
clearRecipeSources, clearRecipeSources,
@ -143,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 () => {
@ -234,4 +262,40 @@ describe("Reference data", () => {
expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]); expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]);
}); });
}); });
describe("GET /reference/tours", () => {
it("returns every seeded scenario with its steps in order", async () => {
const res = await request(app).get("/reference/tours");
expect(res.status).to.equal(200);
expect(res.body.map((t: { key: string }) => t.key)).to.have.members(
TOUR_SCENARIOS.map((s) => s.key),
);
const general = res.body.find((t: { key: string }) => t.key === "general");
expect(general.name).to.be.a("string").and.not.empty;
expect(general.steps).to.have.length(TOUR_SCENARIOS[0].steps.length);
expect(general.steps[0]).to.have.keys(["order", "route", "targetKey", "title", "content"]);
expect(general.steps.map((s: { order: number }) => s.order)).to.deep.equal(
general.steps.map((_: unknown, i: number) => i),
);
// First step of the general tour navigates to "/" and points at the
// planning nav link (see TOUR_SCENARIOS).
expect(general.steps[0].route).to.equal("/");
expect(general.steps[0].targetKey).to.equal("nav-planning");
});
it("excludes a disabled scenario", async () => {
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await request(app).get("/reference/tours");
const keys = res.body.map((t: { key: string }) => t.key);
expect(keys).to.include("general");
expect(keys).to.not.include("techStepMetadata");
});
});
}); });

View file

@ -150,6 +150,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -157,6 +158,7 @@ describe("Shopping list", () => {
meal: "diner", meal: "diner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
], ],
}); });
@ -170,6 +172,118 @@ describe("Shopping list", () => {
expect(res.body.items[0].quantity).to.equal(400); expect(res.body.items[0].quantity).to.equal(400);
}); });
it("ignores a leftovers item — nothing new to buy for a slot that just reserves an already-cooked batch (issue #34)", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const recipe = await prisma.recipe.create({
data: {
name: "Salade de tomates",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
// One real slot at 12 prepared (10 requested), then a leftovers slot
// reusing 2 of that batch — the shopping list must still be 300g
// (100 × 12/4), not 350g.
const source = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 10,
});
await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.body.id,
portions: 2,
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
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());
@ -211,6 +325,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipeA.id, recipeId: recipeA.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -218,6 +333,7 @@ describe("Shopping list", () => {
meal: "diner", meal: "diner",
recipeId: recipeB.id, recipeId: recipeB.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
], ],
}); });
@ -272,6 +388,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipeA.id, recipeId: recipeA.id,
portions: 2, portions: 2,
portionsRequested: 2,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -279,6 +396,7 @@ describe("Shopping list", () => {
meal: "diner", meal: "diner",
recipeId: recipeB.id, recipeId: recipeB.id,
portions: 2, portions: 2,
portionsRequested: 2,
}, },
], ],
}); });
@ -323,6 +441,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 2, portions: 2,
portionsRequested: 2,
}, },
}); });

View file

@ -52,6 +52,24 @@ describe("registerAllRecipeSources", () => {
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger"); expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger");
}); });
it("registers Quitoque into the shared registry", () => {
registerAllRecipeSources();
const quitoque = getRecipeSource("quitoque");
expect(quitoque).to.not.be.undefined;
expect(quitoque?.name).to.equal("Quitoque");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("quitoque");
});
it("registers Oh mon Bento into the shared registry", () => {
registerAllRecipeSources();
const ohMonBento = getRecipeSource("ohMonBento");
expect(ohMonBento).to.not.be.undefined;
expect(ohMonBento?.name).to.equal("Oh mon Bento");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("ohMonBento");
});
it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => { it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
registerAllRecipeSources(); registerAllRecipeSources();

View file

@ -339,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,10 @@
# Template for `apps/web/.env.capacitor` — copy this file to that name and
# set the real value. Used only by `pnpm --filter web build:capacitor`
# (i.e. `vite build --mode capacitor`), the build that produces the bundle
# packaged into the Android app.
#
# Unlike the normal web build (served same-origin as the API, so a relative
# URL works), a packaged Capacitor build runs from the WebView's own local
# origin and must talk to the API over an absolute, public HTTPS URL baked
# in at build time.
VITE_API_URL=https://batch-cooking.example.com

103
apps/web/android/.gitignore vendored Normal file
View file

@ -0,0 +1,103 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files — jamais commités : le keystore de release est un secret
# généré une fois à la main (voir la doc du README, section "Application
# mobile"), décodé depuis le secret CI ANDROID_KEYSTORE_BASE64 à chaque build
# de release, jamais versionné ici (issue #64).
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
apps/web/android/app/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,81 @@
apply plugin: 'com.android.application'
android {
namespace = "fr.kyuno.batchcooking"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "fr.kyuno.batchcooking"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Surchargeables depuis la CI via `-PandroidVersionCode=... -PandroidVersionName=...`
// (release.yml, dérivés du tag `vX.Y.Z` poussé) — la valeur par
// défaut ci-dessous ne sert qu'aux builds locaux/CI qui ne passent
// pas ces propriétés (Android Studio, le job build-android de
// ci.yml sur un simple push main), jamais à une vraie release.
versionCode (project.hasProperty("androidVersionCode") ? (project.androidVersionCode as Integer) : 1)
versionName (project.hasProperty("androidVersionName") ? project.androidVersionName : "1.0")
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Lu depuis l'environnement, jamais codé en dur — même règle que les
// secrets de apps/api (JWT_SECRET, etc.) : aucune valeur par défaut
// devinable. Le keystore lui-même (ANDROID_KEYSTORE_PATH) est un fichier
// décodé à la volée depuis le secret CI ANDROID_KEYSTORE_BASE64, jamais
// commité (voir apps/web/android/.gitignore) — généré une fois à la main,
// voir la doc du README "Application mobile". N'affecte que
// `assembleRelease` : `assembleDebug` continue d'utiliser le keystore de
// debug généré automatiquement par Android, sans configuration.
signingConfigs {
release {
storeFile System.getenv("ANDROID_KEYSTORE_PATH") ? file(System.getenv("ANDROID_KEYSTORE_PATH")) : null
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("ANDROID_KEY_ALIAS")
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
}
}
buildTypes {
release {
// `signingConfig` reste posé même quand les variables d'environnement
// ci-dessus sont absentes (dev local) : Gradle accepte une
// signingConfig aux champs `null`, la build échoue seulement au
// moment de la signature effective (assembleRelease), jamais à la
// configuration — un `assembleDebug` local n'en a jamais besoin.
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View file

@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-preferences')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

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