Compare commits

..

243 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
85268729e6 feat(cooking-session): regroupement d'étapes sensible aux métadonnées d'action
All checks were successful
CI / lint (push) Successful in 1m57s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 7m59s
CI / intent-service-test (push) Successful in 18m44s
CI / test (push) Successful in 28m2s
L'optimiseur ne mutualisait que le pré-découpage. Il regroupe maintenant
aussi les actions SETUP (préchauffage, ébullition) entre recettes, mais
seulement quand elles sont compatibles : deux préchauffages ne fusionnent
que s'ils visent la même température détectée. Deux préchauffages à des
températures différentes restent séparés, ordonnés du plus froid au plus
chaud, et la mise en place porte une note « Four requis à N températures —
enchaîner … ». Défaut optimiste : deux préchauffages sans température
détectée fusionnent quand même (supposés compatibles).

- cooking-optimizer.ts : temperatureKey (comparaison exacte, aucune
  conversion inter-représentations), isPureSetupStep / setupSignature /
  extractMergedSetup en miroir du chemin merged-prep ; comparableTemp pour
  l'ordre seul ; detectApplianceTemperatureConflicts → CookingPhaseView.notes
- cooking-session.service.ts : `temperatures` dans l'include + mapping
- packages/shared : CookingTemperatureView, kind « merged-setup »,
  CookingTaskView.temperatures, CookingPhaseNoteView, CookingPhaseView.notes
- web : temperature-label.ts extrait de StepDescription et réutilisé ;
  taskHeadline gère merged-setup ; phaseNoteText ; bandeau .cooking-phase__note
- i18n : cookingSession.task.mergedSetupLabel, cookingSession.note.*
- tests : 7 cas Mocha « setup pooling » + test DB cooking-session,
  cas Cypress mode layout + scénario Gherkin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:05:44 +02:00
3d5998a535 merge(main): rattrape main (#21 CI, #22 températures)
Some checks failed
CI / lint (push) Successful in 1m55s
CI / build (push) Successful in 2m46s
CI / e2e (push) Failing after 10m49s
CI / intent-service-test (push) Failing after 18m58s
CI / test (push) Failing after 24m32s
2026-09-03 11:48:04 +02:00
81f7cb9972 Merge pull request 'feat(recipes): détection des températures dans les étapes' (#22) from feat/temperature-metadata into main
Some checks failed
CI / e2e (push) Waiting to run
CI / intent-service-test (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-09-03 11:47:24 +02:00
eb81d3f413 merge(main): rattrape main (#18 admin merge) dans feat/temperature-metadata
Some checks failed
CI / build (push) Successful in 2m56s
CI / lint (push) Successful in 2m59s
CI / e2e (push) Failing after 10m49s
CI / intent-service-test (push) Failing after 17m15s
CI / test (push) Failing after 21m54s
2026-09-03 11:45:21 +02:00
dfe0310b63 feat(cooking-session): onglets recettes/plan + stepper d'étapes cochables
Some checks failed
CI / lint (push) Successful in 2m16s
CI / build (push) Successful in 3m11s
CI / e2e (push) Successful in 7m53s
CI / intent-service-test (push) Successful in 13m57s
CI / test (push) Failing after 17m23s
Remanie /cuisiner : au lieu d'empiler toutes les phases, deux onglets
(Recettes, Plan) et un stepper qui parcourt les phases une à une. Chaque
tâche est cochable ; la progression est mémorisée par semaine dans
localStorage — aucun état serveur, même parti pris que le reste de la page.

- CookingSessionPage : barre d'onglets + PlanTab (stepper, nav
  précédent/suivant, clic direct sur un nœud) + RecipesTab (liste des
  recettes de la semaine, lien vers la fiche)
- cooking-progress.ts : CookingProgressStore (load/save localStorage,
  clé batchcooking:cookingProgress:<semaine>)
- cooking-session.ts : reconcileChecked / phaseProgress (helpers purs)
- réutilise CheckboxOption pour les cartes de tâche cochables
- i18n : cookingSession.tabs / stepper / progress / recipesTab
- tests Cypress (mode layout + Gherkin) mis à jour et étendus

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:12:08 +02:00
29a5b301c3 Merge pull request 'ci: lance Postgres en conteneur d'arrière-plan (plus en services:)' (#21) from ci/postgres-as-background-container into main
Some checks failed
CI / lint (push) Failing after 29s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 6m0s
CI / intent-service-test (push) Successful in 14m26s
CI / test (push) Successful in 23m48s
Reviewed-on: #21
2026-09-03 08:40:42 +02:00
89fedc93e2 ci: lance Postgres en conteneur d'arriere-plan, plus en services:
All checks were successful
CI / lint (push) Successful in 1m6s
CI / build (push) Successful in 1m33s
CI / e2e (push) Successful in 7m29s
CI / intent-service-test (push) Successful in 11m51s
CI / test (push) Successful in 21m11s
Le runner Forgejo `act` ne garde pas un conteneur `services:` comme *la
meme* instance d'une etape a l'autre : `prisma migrate deploy` posait le
schema dans un Postgres, puis le `beforeAll` de Mocha en frappait un vide
(« table … does not exist » juste apres « migrations successfully
applied »). Trois occurrences en une journee, dont une sur `main` apres le
merge de #18 — code identique a `ccec30b` qui, lui, etait vert.

Le job `test` demarre desormais Postgres lui-meme via `docker run -d`
(socket docker deja monte dans le conteneur de job), sur le reseau du job
decouvert dynamiquement, joignable comme `postgres` — exactement la meme
posture « vraie dependance lancee par le job » que l'etape uvicorn de
`tech-step-intent-service` juste en dessous, que `services:` ne pouvait
deja pas couvrir. Un seul conteneur, partage par toutes les etapes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 14:48:30 +02:00
d5dca82af4 Merge pull request 'refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/*' (#18) from feat/merge-admin-into-web into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build (push) Successful in 2m57s
CI / e2e (push) Successful in 9m2s
CI / intent-service-test (push) Successful in 16m55s
CI / test (push) Successful in 27m19s
Reviewed-on: #18
2026-08-29 21:15:43 +02:00
ccec30b2e8 chore: re-declenche la CI
All checks were successful
CI / build (push) Successful in 2m36s
CI / lint (push) Successful in 2m39s
CI / e2e (push) Successful in 7m45s
CI / intent-service-test (push) Successful in 12m0s
CI / test (push) Successful in 20m12s
Le job `test` du run precedent a echoue sur un flake du conteneur Postgres
`services:` du runner act (`relation "user_profile_allergy" does not exist`
juste apres « All migrations successfully applied », 0 test execute). Aucun
fichier lie a la DB n'a change dans cette PR, et le superset feat/temperature-
metadata a fait tourner `test` vert (516 passing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 20:54:49 +02:00
fd62dbbabe test(tech-steps): ajoute temperatures aux fixtures StepTechStep
All checks were successful
CI / lint (push) Successful in 2m27s
CI / build (push) Successful in 3m33s
CI / e2e (push) Successful in 8m27s
CI / intent-service-test (push) Successful in 11m14s
CI / test (push) Successful in 20m57s
CI: 7 `deep.equal` sur un `TechStepMatch` / `StepTechStepView` echouaient
(l'objet reel a une cle de plus depuis l'ajout de `temperatures`).

- tech-step-matcher.test.ts x4 : `temperatures: []`, sauf le cas
  « Faire mijoter a feu doux » ou « feu doux » [16,24) est une mention
  qualitative -> `qualitative: "low"`.
- recipe-tech-step-correction.test.ts x3 : `temperatures: []` (l'etape
  fixture « Faire mijoter la sauce. » n'a pas de mention de temperature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:59:52 +02:00
189df73b4c feat(tech-steps): detecte les temperatures dans les etapes
Some checks failed
CI / lint (push) Successful in 2m33s
CI / intent-service-test (push) Failing after 31s
CI / build (push) Successful in 3m30s
CI / e2e (push) Successful in 11m14s
CI / test (push) Failing after 24m39s
3e type de metadonnee de clause, a cote des ingredients et des ustensiles :
temperature en °C (« 180°C », « 200 degres », °F converti), numero de
thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » ->
low/medium/high).

services/tech-step-intent-service :
- `temperature_extraction.py` : `extract_temperatures(text, locale)` pur,
  a base de regex (independant du modele spaCy et de l'entrainement).
- `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif
  au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les
  extrait meme pour une locale pas encore entrainee.
- Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` :
  2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas.

apps/api :
- `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere).
- `TechStepMatch.temperatures` : filtrees par appartenance de span a la
  clause, meme regle que les ustensiles.
- `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la
  donnee) + migration manuelle `20260829120000_step_tech_step_temperature`
  (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a
  reset-db.ts). Persistance + lecture dans `recipe.service.ts`
  (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche
  du service de correction ; `sources.service.ts` (apercu d'import) les
  fait transiter.

packages/shared : `StepTechStepTemperatureView` + `temperatures` sur
`StepTechStepView`.

apps/web :
- `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et
  redecoupe les segments non-keyword autour des spans de temperature (la
  logique technique intriquee reste intacte). `?? []` tolere une payload
  d'avant `temperatures`.
- `StepDescription` : surlignage `.step-temperature` + Tooltip via
  `temperatureLabel` ; i18n `recipes.temperature.*`.
- Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les
  ustensiles).

Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le
flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ;
pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres +
intent-service requis) non lance ici.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:29:03 +02:00
1a28d67218 test(admin): fixe l'intercept monitoring (route page == chemin API)
Some checks failed
CI / build (push) Successful in 2m48s
CI / lint (push) Successful in 3m0s
CI / e2e (push) Successful in 12m51s
CI / intent-service-test (push) Successful in 16m53s
CI / test (push) Failing after 17m9s
La route page `/admin/monitoring` et le chemin API `GET /admin/monitoring`
sont identiques : un glob `**/admin/monitoring` capturait aussi la requete
document du `cy.visit()`. Le hardcode `http://localhost:3000/...` marchait
en local (VITE_API_URL dans apps/web/.env) mais pas en CI (pas de .env ->
API en meme origine sur :5173, donc collision totale) : `cy.wait
(@getMonitoring)` timeout.

`resourceType: "fetch"` epingle l'intercept sur le seul XHR d'AdminApiClient.
Verifie en simulant la CI (sans apps/web/.env) : 16/16 specs admin verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:28:53 +02:00
bbd9afe8aa refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/*
Some checks failed
CI / lint (push) Failing after 23s
CI / build (push) Successful in 3m11s
CI / e2e (push) Failing after 8m16s
CI / intent-service-test (push) Successful in 12m49s
CI / test (push) Successful in 21m34s
L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.

Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
  layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
  collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
  RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
  theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
  (les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
  ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
  un ref requestSeq.

Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.

Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
  `app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
  dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.

Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:02:29 +02:00
927051793c Merge pull request 'fix(tests): corrige 3 suites Mocha DB révélées par leur 1er run sur main' (#17) from fix/mocha-db-suites-first-run into main
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 7m29s
CI / intent-service-test (push) Successful in 11m4s
CI / test (push) Successful in 20m19s
Reviewed-on: #17
2026-08-29 12:02:03 +02:00
9532ea4e83 fix(tests): corrige 3 suites Mocha DB revelees par leur 1er run sur main
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / build (push) Has been cancelled
Ces suites ont ete ecrites pendant le dev des features cooking / admin /
hors-catalogue mais jamais executees (pas de Postgres dans ces sessions).
Leur 1re execution reelle sur `main` echouait — bugs dans les tests, pas
dans le code merge.

- reference.test.ts : `GET /reference/ingredients` renvoie desormais
  `isPlaceholder` (toujours false) et `displayName` (toujours null) depuis
  la PR #16 (champs de `IngredientView`). L'assertion `to.have.keys([...])`
  exacte est mise a jour.
- cooking-session.test.ts : la fixture "pooling merged-prep" avait 2
  recettes symetriques (chop -> simmer) ; apres mise en commun du chop les
  deux simmer tournent dans l'unique phase de cuisson, donc aucun
  `background` possible (l'optimiseur est correct, cf. le test pur
  equivalent). « Tarte » recoit une etape active `mix` de plus pour que son
  simmer flotte en background pendant que « Soupe » est en hold.
- admin-tech-steps.test.ts : une requete supertest ne part qu'a l'`await`/
  `.then` ; la 1re requete /retrain concurrente n'etait jamais lancee, donc
  le verrou process n'etait jamais tenu et la 2e recevait 200 au lieu de
  409. Ajout d'un `.then(res => res, err => err)` pour la declencher avant
  l'attente de 100 ms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:00:43 +02:00
a33f641dc7 Merge pull request 'feat(admin): application d'administration (auth, métriques, monitoring, tri des corrections)' (#15) from feat/admin-tech-steps into main
Some checks failed
CI / lint (push) Successful in 50s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 7m24s
CI / intent-service-test (push) Successful in 10m48s
CI / test (push) Has been cancelled
Reviewed-on: #15
2026-08-29 09:43:07 +02:00
f7aabd9dcc Merge pull request 'feat(recipes): ingrédients hors-catalogue (placeholders) + revue admin' (#16) from feat/off-catalog-ingredients into feat/admin-tech-steps
Some checks failed
CI / intent-service-test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / e2e (push) Has been cancelled
Reviewed-on: #16
2026-08-29 09:42:57 +02:00
16f593d5d0 feat(recipes): permet d'ajouter des ingredients hors-catalogue
Some checks failed
CI / lint (push) Successful in 1m46s
CI / build (push) Successful in 1m55s
CI / e2e (push) Successful in 8m37s
CI / intent-service-test (push) Successful in 17m3s
CI / test (push) Failing after 3h14m19s
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.

Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.

Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.

Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:42 +02:00
fcaecb4e07 feat(admin): tri des corrections + declenchement du gate F1/backfill
Some checks failed
CI / lint (push) Successful in 1m58s
CI / build (push) Successful in 2m35s
CI / e2e (push) Failing after 5m24s
CI / intent-service-test (push) Successful in 22m51s
CI / test (push) Failing after 23m21s
PR 5 (derniere) du chantier admin. Remplace le duo CLI
list-pending-training-suggestions.ts / retrain-tech-steps.ts par une UI.

API (admin-tech-steps.service.ts, routes /admin/tech-steps/*, requireAdmin) :
- GET /suggestions : TechStepTrainingSuggestion filtrees, groupees par
  technique, enrichies du contexte de la correction source.
- GET /corrections : corrections brutes filtrables, incluant les
  suppressions correctedTechStepId:null invisibles ailleurs.
- PATCH /suggestions/:id : edite synonymes/phrases et/ou status.
- GET /training-data-snippet : bloc training_data.py a coller (lecture
  seule).
- POST /retrain : runTechStepEvalSuite() (gate F1 vs MIN_OVERALL_F1) puis
  si passe backfillTechSteps() + marquage applied/rejected. Verrou memoire
  -> 409 RETRAIN_ALREADY_RUNNING. Gate echoue -> 200 gatePassed:false.
  N'edite pas le .py ni ne redemarre l'intent-service (manuel).

Shared : nouveau ErrorCode RETRAIN_ALREADY_RUNNING (4023, + cle i18n
apps/web), schemas (list*/update*/retrain*/snippet), types
(TrainingSuggestion*/Correction*/RetrainResultView...).

Front : CorrectionsPage (onglets Suggestions / Corrections brutes,
bandeau caveat permanent, cartes editables + Appliquer/Rejeter, panneau
snippet, panneau gate F1). Logique pure corrections.ts. i18n
admin.corrections.*. AdminApiClient : 5 methodes.

Tests : Mocha admin-tech-steps.test.ts (401 partout, groupement+filtre,
PATCH 400/404/ok, corrections incluant removals, snippet, retrain shape +
409 concurrent) ; Cypress corrections.cy.ts (4 verts). Admin-web Cypress
13/13. specs/backend-architecture.md : section tri + retrain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:31 +02:00
29432e5bf2 feat(admin): monitoring des microservices + heartbeat du worker LLM
PR 4 du chantier admin. Board de sante temps reel des dependances.

- POST /internal/tech-steps/heartbeat (requireInternalWorker) ->
  recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe
  "tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job".
  Schema workerHeartbeatSchema dans packages/shared.
- services/tech-step-llm-worker : api-client.postHeartbeat (best-effort,
  ne throw jamais) appele au boot (index.ts), a chaque tick et apres
  chaque job (scheduler.ts, avec job/ok/counts).
- admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) :
  sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS),
  tech-step-intent-service (/health), et le worker via son heartbeat.
  Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres
  ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down.
- MonitoringView / ServiceHealthView dans packages/shared.
- Front : MonitoringPage (grille de cartes coloree par statut, re-poll
  15 s), logique pure monitoring.ts, i18n admin.monitoring.*,
  AdminApiClient.getMonitoring.
- Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert
  job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown
  sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts).
  Worker mocha : 6/6 toujours verts.
- specs/backend-architecture.md : section monitoring. .gitignore :
  apps/admin-web/cypress/{screenshots,videos,downloads}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:31 +02:00
3a416ea955 feat(admin): metriques d'utilisation (derive DB + AnalyticsEvent)
PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.

Schema (migration admin_metrics) :
- AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json,
  index [type, created_at]) + WorkerHeartbeat (cable en PR 4).
- colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning /
  PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de
  la migration).

Instrumentation (lib/analytics.service.ts, fire-and-forget) :
- analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert
  detache, echec loggue+avale, jamais de latence sur la requete.
- points d'appel : user.signup, recipe.created, recipe.imported,
  planning.item_added, tech_step.correction_submitted, shopping_list.viewed.

API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) ->
admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans
base). MetricsView dans packages/shared.

Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes
recettes-par-source / evenements), logique pure dans dashboard.ts, i18n
admin.dashboard.*. AdminApiClient.getMetrics.

reset-db.ts truncate analytics_events + worker_heartbeats.
Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot,
series zero-remplies, event user.signup fire-and-forget) ; Cypress
dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:31 +02:00
c7301ea844 feat(admin): scaffold de l'application d'administration apps/admin-web
Nouvelle app Vite/React independante (workspace apps/*), calquee sur
apps/web : port 5174, sa propre image Docker (nginx statique), son propre
domaine/deploiement.

- AdminApiClient (VITE_ADMIN_API_URL, credentials: include) + ApiError.
- AdminAuthContext / RequireAdmin : restaure la session admin via
  GET /admin/auth/me, garde de route (miroir de AuthContext/RequireAuth).
- LoginPage (/login) : validation cliente via adminLoginSchema partage,
  erreurs traduites via ErrorMessageService.
- AdminLayout : sidebar (Tableau de bord / Monitoring / Corrections) +
  deconnexion, rendu une fois autour du groupe RequireAdmin.
- Pages Dashboard / Monitoring / Corrections en placeholder (remplies aux
  PR 3-5).
- i18n fr (bloc admin.* + sous-ensemble errors.*), tokens _theme.scss
  copies de apps/web (extraction en package partage : suivi separe).
- Dockerfile multi-stage (node build -> nginx:alpine) + nginx.conf (SPA
  fallback). Service admin-web dans docker-compose.yml (port ADMIN_WEB_PORT,
  VITE_ADMIN_API_URL en build arg).
- Cypress : login.feature (KO -> message, OK -> dashboard) + admin-layout
  .cy.ts (redirection /login sans session, nav entre sections, logout).
  Job "Run admin-web E2E tests" ajoute a ci.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:30 +02:00
c3d2ac7e8e feat(admin): fondation auth de l'application d'administration
Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.

- Table AdminUser isolee (aucune relation vers UserProfile), migration
  20260828120000_admin_user.
- lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET
  propre (jamais interchangeable avec JWT_SECRET).
- middlewares/require-admin.ts : cookie admin_session dedie, re-check
  tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture
  requireInternalWorker). res.locals.adminUser type via AdminLocals.
- modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST
  /logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de
  signup expose.
- lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash
  + tokenVersion, dates ISO).
- scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou
  ADMIN_INITIAL_*).
- CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN +
  ADMIN_CORS_ORIGIN.
- Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts
  (AdminUserView).
- Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN,
  ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml,
  ci.yml mis a jour.
- reset-db.ts truncate admin_users.
- Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu /
  mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion
  dans la reponse), /me derriere requireAdmin, logout, et un cookie
  `session` d'utilisateur normal ne donne pas acces a /admin/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:29 +02:00
86636f0ba9 Actualiser .github/workflows/ci.yml
Some checks failed
CI / build (push) Successful in 4m1s
CI / lint (push) Successful in 4m6s
CI / e2e (push) Successful in 11m36s
CI / intent-service-test (push) Successful in 19m33s
CI / test (push) Failing after 21m19s
2026-08-28 23:30:09 +02:00
46635cb76f Merge pull request 'feat(cooking): « Commencer à cuisiner » — moteur d'optimisation + endpoint + page /cuisiner' (#11) from feat/cooking-session-ui into main
Some checks failed
CI / lint (push) Failing after 1m38s
CI / build (push) Successful in 3m25s
CI / e2e (push) Successful in 9m51s
CI / intent-service-test (push) Successful in 15m38s
CI / test (push) Failing after 30m23s
Reviewed-on: #11
2026-08-28 22:53:40 +02:00
e8ac2d1629 feat(cooking): page /cuisiner + bouton "Commencer a cuisiner"
Some checks failed
CI / lint (push) Successful in 3m1s
CI / test (push) Failing after 3s
CI / build (push) Successful in 4m24s
CI / intent-service-test (push) Successful in 18m40s
CI / e2e (push) Successful in 13m15s
- Bouton "Commencer a cuisiner" dans l'en-tete du planning, actif seulement
  quand la semaine affichee contient >= 1 recette ; navigue vers
  /cuisiner?date=<semaine>.
- Page CookingSessionPage (/cuisiner) : consomme GET /cooking-session, rend
  les phases (mise en place / cuisson / dressage), les taches mutualisees
  avec badge "Mutualise" + ingredients/ustensiles resolus, et la bande
  "Pendant ce temps" pour les cuissons de fond. Semaine lue depuis ?date=,
  WeekNavigator en fallback ; recalcul a chaque visite (comme la liste de
  courses).
- Logique pure extraite dans cooking-session.ts (composition des libelles,
  formatage des quantites).
- i18n : bloc cookingSession.* + planning.startCooking.
- apiClient.getCookingPlanForWeek.
- Tests Cypress : cooking-session-page.cy.ts (layout, 3 cas), cooking-session
  .feature (parcours planning -> /cuisiner), assertions bouton dans
  planning-page.cy.ts ; step generique "button should be disabled" mutualise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
6b60c11408 feat(cooking): endpoint GET /cooking-session (plan de cuisine optimise)
Module cooking-session : charge le Planning couvrant ?date= (meme requete
"plage couvrante" + degradation "jamais null" que /shopping-list), mappe
chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/
unites/techniques/ustensiles resolus via toIngredientView/toUnitView
reutilisees de recipe.service), renvoie OptimizedCookingPlanView.

- cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude.
- Route requireAuth, contrat ?date= identique a /shopping-list.
- Monte /cooking-session dans app.ts.
- Tests d'integration Mocha (401, date invalide, plan vide sans foyer /
  sans planning, mutualisation d'une decoupe entre 2 recettes planifiees).
- specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO
  -> v1 implementee ; nouvelle section dans backend-architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
83a12d474d feat(cooking): moteur d'optimisation des etapes de batch-cooking
Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md,
jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une
semaine et les reorganise en phases ordonnees.

- Mutualisation de la mise en place : une meme technique de decoupe (chop, peel,
  mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une
  seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe).
- Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont
  poussees en tache de fond des phases suivantes pendant qu'une autre recette
  avance en actif.
- Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que
  ingredient-matcher / tech-step-matcher), testable en isolation.

Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema.
Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond,
mise a l'echelle par portions, plan vide, legende.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
9eadf3b974 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 3m58s
CI / build (push) Successful in 4m23s
CI / e2e (push) Successful in 14m18s
CI / intent-service-test (push) Successful in 18m10s
CI / test (push) Failing after 30m22s
2026-08-28 19:03:42 +02:00
422 changed files with 32192 additions and 3147 deletions

View file

@ -15,26 +15,42 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000
# --- Admin application (apps/admin-web + the /admin/* API surface) ---------
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
# building locally instead (`docker compose build`, or `up` with no image
# present) — see docker-compose.yml's `image:`/`build:` comment.
# IMAGE_TAG=latest
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
# All optional: an instance that doesn't run the admin app needs none of
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
# leaving it out simply disables every /admin/* route.
# leaving it out simply disables every /admin/* route. The admin UI is
# served by the same app/container as the rest of the frontend — no
# separate origin, so no CORS entry of its own.
#
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
# end-user token can never be replayed against /admin/*. Generate your own
# the same way as JWT_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Origin apps/admin-web is served from, added to the CORS allow-list.
# ADMIN_CORS_ORIGIN=http://localhost:5174
# Host port for the Docker `admin-web` service (static nginx serving the
# built admin frontend).
# ADMIN_WEB_PORT=3001
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
# --password / --name flags are omitted (e.g. to bootstrap the first admin
# from inside the container). Never read by the running server.
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
#
# Optional — lets an admin open a Forgejo ticket from a contact message
# (apps/web's /admin/messages, issue #81). All three needed together: the
# button stays hidden and the endpoint answers 503 unless all are set to a
# non-empty value. docker-compose.yml forwards these into the "app"
# container, so setting them here is enough (a blank value counts as
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
# scope — generate it in Forgejo, never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo
# Optional — only set this to false if THIS deployment is served over
# plain HTTP (no TLS in front of it). Left unset, the session cookie

View file

@ -12,6 +12,15 @@ on:
push:
env:
# Hostname `postgres` = the container the `test` job starts itself (see
# that job's "Start Postgres" step) — a plain `docker run`, not a
# `services:` block, for the same reason the intent-service below can't be
# one: this repo's Forgejo `act` runner does not keep a `services:`
# container as the *same* instance across a job's steps, so `migrate
# deploy` would land the schema in one Postgres and `pnpm --filter api
# test`'s `beforeAll` would then hit an empty one ("table … does not
# exist" right after "migrations successfully applied"). One long-lived
# container in the job, shared by every step, removes that failure mode.
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
# Test-only secret, never used outside CI — real deployments must set their own.
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
@ -24,11 +33,19 @@ env:
# secret configured -> 401" path.
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
# Shared between the `test` job's own uvicorn step (below) and apps/api's
# IntentServiceClient — see the `test` job for why this can't be a
# `services:` container like postgres above (GitHub Actions can only pull
# a published image, not build services/tech-step-intent-service/Dockerfile).
# IntentServiceClient — that step runs `uvicorn` as a plain background
# process (not a `services:` block or even a container) because CI can
# only pull a published image, not build
# services/tech-step-intent-service/Dockerfile from this checkout.
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
# in those tests, so no real request ever leaves CI; these values only need
# to parse (a valid URL and an `owner/repo`).
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
FORGEJO_REPO: "ci/batchcooking"
jobs:
# Five independent jobs, no needs: between them — each starts in parallel
@ -51,21 +68,97 @@ jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: ci
POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
# spent queued in "Wait for any earlier test job" below. Also a runaway
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
# leaked handle would otherwise hang the job until the runner's global
# timeout (a `test` job zombied ~1h once). If a hung job hits this
# ceiling the runner removes its container, which unblocks the queue.
timeout-minutes: 45
steps:
- name: Install curl and the docker CLI
run: |
apt-get update && apt-get install -y curl docker.io
# Serialize `test` across every concurrent run. Two `test` jobs must
# never overlap: they share one `--name postgres` container on the
# runner's shared docker network (next step), so their `prisma migrate
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
# pending migrations" when there should be some, `tech_step`/`diet` id
# shifts breaking unrelated assertions; and even past that they starve
# each other's RAM/CPU (each also trains a spaCy model) until
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
# re-trigger once the runner was idle.
#
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
# 1.26 and is silently ignored here. A lock object (network, container,
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
# trailing `if: always()` step after an earlier step fails, so there is
# no reliable in-job release. Instead, key off the one thing the runner
# *does* always clean up — the job container itself (removed on pass,
# fail, and cancel alike): wait while any lower-numbered `test` job's
# container is still running. Lower task id goes first, so two jobs
# that start together can't deadlock; nothing to release.
- name: Wait for any earlier test job
run: |
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
echo "this test job is task ${my_id:-<unknown, not serializing>}"
# ~25 min cap (75 * 20s): comfortably covers one earlier run
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
# wait exceeds this just fails and gets an empty-commit re-run.
for attempt in $(seq 1 75); do
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
if [ -z "$earlier" ]; then
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
exit 0
fi
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
sleep 20
done
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
exit 1
# Postgres as a plain background container instead of a `services:`
# block — see this workflow's top-level `env.DATABASE_URL` comment for
# why. Reachable as `postgres` from this job (the runner mounts the
# docker socket and runs jobs on a shared user-defined network, so a
# `--name postgres` sibling resolves by name); the network is
# discovered from the job container itself rather than hardcoded. The
# `docker rm -f postgres` below also clears a container left behind by
# the previous run (this runner has no reliable post-job cleanup), and
# the serialization step above means no other run is using it now.
- name: Start Postgres in the background
run: |
net="$(docker inspect "$(hostname)" \
--format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' \
| awk '{print $1}')"
echo "job network: ${net:?could not determine the job container network}"
docker rm -f postgres >/dev/null 2>&1 || true
# Also drop any `postgres-<suffix>` container. A since-reverted
# attempt at per-run Postgres naming on this branch left some
# running on this persistent runner (their `if: always()` teardown
# never fired — this runner skips trailing steps after a failure),
# and nothing else removes them. A second idle Postgres here starves
# the runner's RAM and slows every query ~5x → `resetDatabase()`
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
# more, so this only ever clears that debris.
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
docker run -d --name postgres --network "$net" \
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
postgres:16-alpine
for _ in $(seq 1 60); do
docker exec postgres pg_isready -U ci -d batchcooking_ci && exit 0
sleep 2
done
echo "Postgres did not become ready in time" >&2
docker logs postgres || true
exit 1
- uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4
@ -80,15 +173,14 @@ jobs:
python-version: "3.12"
enable-cache: true
# `services:` (like the `postgres` container above) can only pull an
# already-published image — it can't build
# services/tech-step-intent-service/Dockerfile from this checkout.
# Running `uvicorn` as a plain background step instead: it keeps
# running for the rest of this job (GitHub Actions steps in one job
# share the same runner process tree), and `pnpm --filter api test`
# below needs a real instance to talk to per this repo's "never mock
# an internal service" test convention — same reasoning as the real
# `postgres` container just above, not a mock HTTP server.
# A `services:` block can only pull an already-published image — it
# can't build services/tech-step-intent-service/Dockerfile from this
# checkout. Running `uvicorn` as a plain background step instead: it
# keeps running for the rest of this job (steps in one job share the
# same process tree), and `pnpm --filter api test` below needs a real
# instance to talk to per this repo's "never mock an internal service"
# test convention — same "real dependency, started by the job itself"
# posture as the Postgres step above, not a mock HTTP server.
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
@ -98,11 +190,14 @@ jobs:
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~540s (fr) / ~390s (en),
# ~930s combined, against the current ~74-technique corpus (see
# docker-compose.yml's healthcheck for the same reasoning and why
# this grew slightly from the original ~670s).
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
# see its own README) — measured at ~3min combined (fr+en) against
# the current ~74-technique corpus, down from ~930s once
# `spacy.TextCatBOW.v3`'s `length` stopped being left at its
# 262144 default (see `_TEXTCAT_MODEL_LENGTH` in
# `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 --filter api exec prisma migrate deploy
@ -177,10 +272,90 @@ jobs:
# `component.devServer`), unlike `e2e` above which needs the real app
# running first.
- run: pnpm --filter web cy:run:component
# The admin app's own Cypress suite (`apps/admin-web`) — its own dev
# server on :5174, all `/admin/*` calls mocked via `cy.intercept`
# (no live backend needed), same as the `web` e2e run above.
- name: Run admin-web E2E tests
# 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:
HOST: "0.0.0.0"
run: pnpm --filter admin-web e2e
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
# No image registry involved: Portainer is wired to this repo's Git remote
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
# that the image actually builds at the tagged commit, publishes a GitHub
# Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
# have to wait for its own polling interval.
# On a `vX.Y.Z` tag: build the three service images and push them to this
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
# and `latest`), build+attach a signed Android APK, publish a Forgejo
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
# pull those prebuilt images (docker-compose.yml references them via
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
# keeps a `build:` fallback next to every `image:`.
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
# (the `build-and-push-images` job below `docker login`s with it). If a
# future instance policy scopes the run token tighter, swap that login's
# password for a `write:package` PAT added as a repo secret.
packages: write
jobs:
sanity-build:
# Build the three service images at the tagged commit and push them to the
# Forgejo container registry. A successful push also *is* the "does it
# still build" check the old `sanity-build` job did (which only covered
# apps/api). One sequential job, not a per-image matrix: three concurrent
# `docker build`s hammer this single persistent runner's Docker daemon
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
# for no real gain on a release, which is rare and usually runs alone.
build-and-push-images:
runs-on: ubuntu-latest
steps:
- 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
# able to build this same Dockerfile successfully at this tag before
# anyone points it at this ref.
- run: docker build -f apps/api/Dockerfile .
- uses: https://github.com/actions/checkout@v4
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
needs: sanity-build
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:
# Auto-generated from merged PRs since the previous tag — no
# changelog tooling to maintain, consistent with there being no
# versioning tooling elsewhere in the repo yet.
generate_release_notes: true
node-version: 22
cache: pnpm
- 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:
runs-on: ubuntu-latest
needs: github-release
needs: forgejo-release
steps:
- name: Trigger Portainer redeploy
# Best-effort: does nothing (and doesn't fail the workflow) until

9
.gitignore vendored
View file

@ -70,6 +70,7 @@ web_modules/
.env.*
!.env.example
!.env.test.example
!.env.capacitor.example
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
# 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
.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-store
@ -163,3 +169,6 @@ tmp-mockups/
apps/web/cypress/screenshots/
apps/web/cypress/videos/
apps/web/cypress/downloads/
apps/admin-web/cypress/screenshots/
apps/admin-web/cypress/videos/
apps/admin-web/cypress/downloads/

View file

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

View file

@ -1,6 +0,0 @@
# Vite only exposes vars prefixed with VITE_ to client code.
# Base URL of the API's /admin/* surface. Empty string = same origin as the
# page (correct behind a shared reverse proxy). Native dev overrides it in
# apps/admin-web/.env since the Vite dev server (5174) and the API (3000)
# are different origins.
VITE_ADMIN_API_URL=http://localhost:3000

View file

@ -1,25 +0,0 @@
# Its own image (not built into apps/api's) — the admin app is deployed
# independently of the main app. Build stage compiles the Vite bundle from
# the monorepo; runtime is a plain static nginx serving that bundle.
#
# Build context is the repo root (like apps/api/Dockerfile) — the workspace
# packages (@batch-cooking/shared, @batch-cooking/date-tools) must resolve.
FROM node:22-slim AS build
RUN corepack enable
WORKDIR /repo
# Skip Cypress's Electron binary download — this image never runs it.
ENV CYPRESS_INSTALL_BINARY=0
COPY . .
RUN pnpm install --frozen-lockfile
# The admin bundle bakes in VITE_ADMIN_API_URL at build time. Default ""
# (same-origin — correct behind a shared reverse proxy); override with
# `--build-arg VITE_ADMIN_API_URL=https://api.example.com` when the admin
# app is served from a different origin than the API.
ARG VITE_ADMIN_API_URL=""
ENV VITE_ADMIN_API_URL=$VITE_ADMIN_API_URL
RUN pnpm --filter admin-web build
FROM nginx:alpine AS runtime
COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/admin-web/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -1,28 +0,0 @@
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild";
import createBundler from "@bahmutov/cypress-esbuild-preprocessor";
import { defineConfig } from "cypress";
// Disable GPU for headless/sandboxed environments where no GPU device is
// available — same helper as apps/web's cypress.config.ts.
function disableGpu(on: Cypress.PluginEvents) {
on("before:browser:launch", (browser, launchOptions) => {
if (browser.family === "chromium") {
launchOptions.args.push("--disable-gpu", "--no-sandbox");
}
return launchOptions;
});
}
export default defineConfig({
e2e: {
baseUrl: "http://localhost:5174",
specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"],
async setupNodeEvents(on, config) {
disableGpu(on);
await addCucumberPreprocessorPlugin(on, config);
on("file:preprocessor", createBundler({ plugins: [createEsbuildPlugin(config)] }));
return config;
},
},
});

View file

@ -1,24 +0,0 @@
import { Given } from "@badeball/cypress-cucumber-preprocessor";
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
Given("admin login fails with invalid credentials", () => {
cy.intercept("POST", "**/admin/auth/login", {
statusCode: 401,
body: { code: 4010, message: "Invalid email or password" },
});
});
Given("admin login succeeds as {string}", (name: string) => {
const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` };
cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body });
// After navigate("/"), RequireAdmin re-checks the session — from now on it
// must report authenticated (last matching intercept wins).
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body });
});

View file

@ -1,3 +0,0 @@
// Cypress support file — global config and custom commands go here as the
// admin app grows. Same minimal starting point as apps/web's e2e.ts.
export {};

View file

@ -1,54 +0,0 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Steps shared across admin feature specs — navigation and generic UI
// assertions. Anything specific to one feature (its own API mocks, its own
// DOM structure) lives in that feature's own `<name>.ts` step file.
//
// Every admin API call is mocked via `cy.intercept` — the Cypress suite
// never runs a live backend; apps/api's own Mocha suite covers real
// `/admin/*` behaviour.
Given("the admin session check returns unauthenticated", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } });
});
Given("I am signed in as admin {string}", (name: string) => {
cy.intercept("GET", "**/admin/auth/me", {
statusCode: 200,
body: {
id: 1,
email: `${name.toLowerCase()}@example.com`,
name,
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
},
});
});
When("I visit {string}", (path: string) => {
cy.visit(path);
});
When("I fill in the {string} field with {string}", (fieldId: string, value: string) => {
cy.get(`#${fieldId}`).clear().type(value);
});
When("I click the button {string}", (text: string) => {
cy.contains("button", text).click();
});
Then("the URL should include {string}", (fragment: string) => {
cy.url().should("include", fragment);
});
Then("the URL should not include {string}", (fragment: string) => {
cy.url().should("not.include", fragment);
});
Then("I should see {string}", (text: string) => {
cy.contains(text).should("be.visible");
});
Then("I should see the heading {string}", (text: string) => {
cy.contains("h1", text).should("be.visible");
});

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>batchCooking — Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -1,20 +0,0 @@
# Static host for the built admin SPA. Client-side routing (react-router)
# means any unknown path must fall back to index.html rather than 404 —
# same reason apps/api serves its own SPA that way.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Long-cache the fingerprinted assets Vite emits; never cache the HTML
# entry point so a new deploy is picked up immediately.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View file

@ -1,42 +0,0 @@
{
"name": "admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5174",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5174",
"test": "echo \"no unit tests yet\" && exit 0",
"cy:open": "cypress open",
"cy:run": "cypress run",
"e2e": "start-server-and-test dev http://localhost:5174 cy:run"
},
"dependencies": {
"@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"i18next": "^26.3.6",
"lucide-react": "^1.32.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "^7.18.2",
"recharts": "^2.15.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@badeball/cypress-cucumber-preprocessor": "22.2.0",
"@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
"@cypress/vite-dev-server": "5.2.1",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"cypress": "13.17.0",
"esbuild": "0.21.5",
"sass": "^1.102.0",
"start-server-and-test": "^2.0.8",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}

View file

@ -1,34 +0,0 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { RequireAdmin } from "./features/auth/RequireAdmin";
import { AdminLayout } from "./layouts/AdminLayout";
import { CorrectionsPage } from "./pages/corrections/CorrectionsPage";
import { DashboardPage } from "./pages/dashboard/DashboardPage";
import { LoginPage } from "./pages/login/LoginPage";
import { MonitoringPage } from "./pages/monitoring/MonitoringPage";
/**
* Admin app route table. `/login` is the only unauthenticated route;
* everything else is nested under one `RequireAdmin` + `AdminLayout` parent
* (the guard + sidebar chrome applied once), same shape as apps/web's
* `App.tsx`. Unknown paths fall back to `/`, which redirects to `/login`
* when there's no admin session.
*/
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAdmin>
<AdminLayout />
</RequireAdmin>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/corrections" element={<CorrectionsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View file

@ -1,102 +0,0 @@
import {
type AdminLoginInput,
type AdminUserView,
type ApiErrorResponse,
ErrorCode,
type MetricsView,
} from "@batch-cooking/shared";
/**
* Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL`
* (see `.env.example`). Defaults to `""` (same origin) — correct behind a
* shared reverse proxy; native dev overrides it to `http://localhost:3000`
* in `apps/admin-web/.env` since the Vite dev server (5174) and the API
* (3000) are different origins.
*/
const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? "";
/**
* Thrown by {@link AdminApiClient} on any non-2xx response — carries the
* same {@link ErrorCode} the API returned. Same shape as apps/web's
* `ApiError`; kept separate rather than shared so the two apps' transport
* layers stay independent.
*/
export class ApiError extends Error {
public readonly status: number;
public readonly code: ErrorCode;
public readonly fieldErrors?: Record<string, string[] | undefined>;
public constructor(status: number, body: ApiErrorResponse) {
super(body.message);
this.name = "ApiError";
this.status = status;
this.code = body.code;
this.fieldErrors = body.details;
}
}
/**
* Thin fetch wrapper around the `/admin/*` endpoints — same design as
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
* stateless instance). Every request sends credentials so the
* `admin_session` httpOnly cookie round-trips.
*/
export class AdminApiClient {
/**
* Performs a JSON request against the admin API and returns the parsed body.
*
* @throws {ApiError} if the response status is not in the 2xx range.
*/
private async _request<TResponseBody>(
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
try {
const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, {
...options,
credentials: "include",
headers: { "Content-Type": "application/json", ...options.headers },
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
throw new ApiError(
response.status,
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
);
}
if (response.status === 204) {
return undefined as TResponseBody;
}
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — callers surface it their own way; this is just the
// one place the fetch/`await` sits in a try/catch per the repo's rule.
throw err;
}
}
/** Verifies admin credentials and starts an admin session. */
public login(input: AdminLoginInput): Promise<AdminUserView> {
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
}
/** Ends the current admin session. */
public logout(): Promise<void> {
return this._request("/admin/auth/logout", { method: "POST" });
}
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<AdminUserView> {
return this._request("/admin/auth/me");
}
/** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */
public getMetrics(days: number): Promise<MetricsView> {
return this._request(`/admin/metrics?days=${days}`);
}
}
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
export const adminApiClient = new AdminApiClient();

View file

@ -1,22 +0,0 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import fr from "../locales/fr/translation.json";
/**
* i18next instance for the admin app, imported once for its side effect
* (`main.tsx`) before anything renders. Only French exists today — same
* setup as apps/web's `i18n/i18n.ts`, its own separate locale file so the
* two apps' copy never has to be kept identical. `packages/shared`'s
* `ErrorCode` member names double as keys under the `errors` namespace
* (see `services/error-message.service.ts`).
*/
void i18next.use(initReactI18next).init({
resources: {
fr: { translation: fr },
},
lng: "fr",
fallbackLng: "fr",
interpolation: { escapeValue: false },
});
export default i18next;

View file

@ -1,18 +0,0 @@
import type { ZodError } from "zod";
/**
* Flattens a zod validation error into `{ fieldName: firstMessage }` for
* inline display under each form field — verbatim copy of apps/web's
* `lib/zod-errors.ts` (only the first message per field, enough for the
* single-rule-per-field schemas used here).
*/
export function fieldErrorsFrom(error: ZodError): Record<string, string> {
const fieldErrors = error.flatten().fieldErrors;
const firstMessagePerField: Record<string, string> = {};
for (const [field, messages] of Object.entries(fieldErrors)) {
if (messages?.[0]) {
firstMessagePerField[field] = messages[0];
}
}
return firstMessagePerField;
}

View file

@ -1,69 +0,0 @@
{
"errors": {
"VALIDATION_ERROR": "Erreur de validation",
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
"NOT_AUTHENTICATED": "Vous devez être connecté",
"NOT_FOUND": "Ressource introuvable",
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"admin": {
"common": {
"comingSoon": "Section à venir.",
"loading": "Chargement…",
"loadError": "Impossible de charger les données, réessayez plus tard."
},
"login": {
"title": "Administration",
"emailLabel": "Email",
"passwordLabel": "Mot de passe",
"submit": "Se connecter",
"submitting": "Connexion…"
},
"nav": {
"dashboard": "Tableau de bord",
"monitoring": "Monitoring",
"corrections": "Corrections"
},
"layout": {
"logout": "Se déconnecter"
},
"dashboard": {
"title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.",
"windowTotal": "{{n}} sur 30 j",
"recipesBySource": "Recettes importées par source",
"noImports": "Aucune recette importée.",
"events": "Évènements enregistrés (30 j)",
"kpi": {
"users": "Utilisateurs",
"households": "Foyers",
"activeHouseholds": "Foyers actifs",
"recipes": "Recettes",
"recipesImported": "Recettes importées",
"plannings": "Plannings",
"planningItems": "Créneaux planifiés",
"favorites": "Favoris",
"corrections": "Corrections",
"correctionsUnconsumed": "Corrections à traiter",
"trainingSuggestions": "Suggestions d'entraînement",
"admins": "Administrateurs"
},
"series": {
"signups": "Inscriptions",
"recipesCreated": "Recettes créées",
"planningItemsAdded": "Ajouts au planning",
"correctionsSubmitted": "Corrections soumises",
"trainingSuggestions": "Suggestions générées"
}
},
"monitoring": {
"title": "Monitoring",
"lead": "Santé des microservices et de la base de données."
},
"corrections": {
"title": "Corrections",
"lead": "Tri des corrections utilisateur pour le ré-entraînement NLP."
}
}
}

View file

@ -1,24 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AdminAuthProvider } from "./features/auth/AdminAuthContext";
// Side-effect import: initializes i18next before anything renders.
import "./i18n/i18n";
// Global stylesheet (theme tokens + minimal reset) — the only non-colocated .scss import.
import "./styles/global.scss";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<BrowserRouter>
<AdminAuthProvider>
<App />
</AdminAuthProvider>
</BrowserRouter>
</StrictMode>,
);

View file

@ -1,19 +0,0 @@
import { useTranslation } from "react-i18next";
import "../admin-page.scss";
/**
* Tech-step correction triage — review `TechStepTrainingSuggestion` /
* `StepTechStepCorrection`, mark applied/rejected, generate the
* `training_data.py` snippet, and trigger the F1 gate + backfill. Placeholder
* until PR 5 (correction triage + retrain).
*/
export function CorrectionsPage() {
const { t } = useTranslation();
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.corrections.title")}</h1>
<p className="admin-page__lead">{t("admin.corrections.lead")}</p>
<p className="admin-page__placeholder">{t("admin.common.comingSoon")}</p>
</div>
);
}

View file

@ -1,18 +0,0 @@
import { useTranslation } from "react-i18next";
import "../admin-page.scss";
/**
* Microservice health board — active probes of Postgres, the API, the
* intent-service and the LLM worker's heartbeat, fed by
* `GET /admin/monitoring`. Placeholder until PR 4 (monitoring).
*/
export function MonitoringPage() {
const { t } = useTranslation();
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
<p className="admin-page__lead">{t("admin.monitoring.lead")}</p>
<p className="admin-page__placeholder">{t("admin.common.comingSoon")}</p>
</div>
);
}

View file

@ -1,19 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import i18n from "../i18n/i18n";
/**
* Localized label for an {@link ErrorCode} returned by the admin API —
* verbatim behaviour of apps/web's `ErrorMessageService`: reverse-maps the
* numeric enum value to its member name (`4010` → `"INVALID_CREDENTIALS"`)
* and looks it up under the `errors` namespace, falling back to
* `INTERNAL_ERROR` for a code this client doesn't recognise.
*/
export class ErrorMessageService {
public getLabel(code: ErrorCode): string {
const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR];
return i18n.t(`errors.${memberName}`);
}
}
/** Single shared instance — stateless. */
export const errorMessageService = new ErrorMessageService();

View file

@ -1,171 +0,0 @@
// NOTE: verbatim copy of apps/web/src/styles/_theme.scss. Extracting these
// tokens into a shared package (consumed by both apps) is tracked separately
// — keep the two files in sync by hand until then.
// =============================================================================
// Design tokens — the single source of truth for colors, spacing, typography
// and other reusable values across the whole app.
//
// Exposed as CSS custom properties on :root (not plain SCSS variables) so
// they're available at *runtime*, not just compile time — this is what lets
// dark mode work below by simply redefining these variables instead of
// rebuilding the stylesheet. Every other .scss file should reference
// `var(--token-name)`, never a hardcoded color/size.
//
// Palette name: "Mise en Place" — a kitchen-operations identity (batch
// cooking as logistics: everything labeled and in its place before you
// start) rather than a food-blog one. See the design proposal for the full
// rationale: https://claude.ai/code/artifact/1db63af0-cfd1-4f77-9369-71ca6accd06f
//
// Import this partial once, globally (see global.scss) — never re-import it
// from a component-level .scss file, `:root` only needs to be declared once.
// =============================================================================
:root {
// --- Surfaces & ink ---------------------------------------------------
// Neutral surface: page background ("porcelaine") vs. the card surface
// content sits on, plus a recessed variant for panels/table headers.
--color-background: #eef2ed;
--color-surface: #ffffff;
--color-surface-alt: #e2e8e0;
// Text.
--color-text: #1f2a22;
--color-text-muted: #57685a;
--color-border: #c7d0c4;
// --- Brand accents, each with one job --------------------------------
// Basil — primary actions, links, brand presence.
--color-primary: #2e6b4a;
--color-primary-hover: #244f38;
// Vermillion — secondary accent for urgency/strong calls to action
// (e.g. a timer, "start session"). Never reused for allergen alerts
// below — those need their own, unambiguous color.
--color-accent: #cc4b26;
--color-accent-hover: #a83c1c;
// Turmeric — category/classification tags.
--color-tag: #c98a1b;
--color-tag-ink: #3a2c05; // pairs with a solid --color-tag fill only.
// --- Feedback -----------------------------------------------------------
--color-success: #2e6b4a;
--color-warning: #c98a1b;
--color-error: #b3271e;
// --- Allergens / intolerances --------------------------------------------
// A 3-tier food-safety scale, kept distinct from --color-error so an
// allergen warning is never confused with a form validation error:
// - critical (declared allergen): its own color, solid/inverted fill
// - moderate (intolerance): reuses --color-warning, tinted fill
// - trace ("may contain traces of…"): neutral, dashed outline
// The severity is carried by the FILL TREATMENT, not the hue alone, so
// it stays legible for color-blind users. See the design proposal's
// "Alertes & allergènes" section for the full component set.
--color-allergen: #a8123f;
--color-allergen-ink: #ffe9ef; // pairs with a solid --color-allergen fill only.
// --- Spacing scale ---------------------------------------------------------
// Multiples of a 4px base unit — use these instead of ad hoc px values so
// spacing stays visually consistent as the app grows.
--space-xs: 0.25rem; // 4px
--space-sm: 0.5rem; // 8px
--space-md: 1rem; // 16px
--space-lg: 1.5rem; // 24px
--space-xl: 2rem; // 32px
--space-2xl: 3rem; // 48px — inter-section spacing
// --- Typography --------------------------------------------------------
// System font stacks only — no remote webfont, so there's zero loading
// latency and no flash of unstyled text, which fits an app meant to be
// used quickly under time pressure. Three roles: a condensed "label"
// face for headings/eyebrows, a humanist face for body copy, and a
// monospace for anything that lines up in columns (times, quantities).
--font-display: "Bahnschrift", "Arial Narrow", "Segoe UI", sans-serif;
--font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--font-mono: "Cascadia Mono", Consolas, "SF Mono", "Liberation Mono", monospace;
--font-size-xs: 0.75rem; // 12px — captions, meta
--font-size-sm: 0.875rem; // 14px — labels, secondary text
--font-size-base: 1rem; // 16px — body
--font-size-md: 1.125rem; // 18px — lead paragraph
--font-size-lg: 1.375rem; // 22px — H3 / card titles
--font-size-xl: 1.75rem; // 28px — H2 / section titles
--font-size-2xl: 2.25rem; // 36px — H1 / page titles
--font-size-3xl: 3rem; // 48px — display, exceptional use only
// --- Shape / elevation --------------------------------------------------
--radius-base: 4px; // controls (inputs, buttons) — deliberately flat
--radius-md: 10px; // cards
--radius-lg: 18px; // panels, modals
--radius-pill: 999px; // tags, badges
--max-width-form: 22rem;
--shadow-sm: 0 1px 2px rgba(31, 42, 34, 0.08), 0 1px 1px rgba(31, 42, 34, 0.06);
--shadow-md: 0 6px 16px rgba(31, 42, 34, 0.12), 0 2px 6px rgba(31, 42, 34, 0.08);
}
// Lets the browser pick sensible default colors (form controls, scrollbars)
// for whichever mode the user ends up in.
:root {
color-scheme: light dark;
}
// --- Dark mode ---------------------------------------------------------
// Follows the OS/browser preference by default. Guarded with
// `:root:not([data-theme="light"])` so an explicit "light" choice (see
// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all —
// this block then decides) can override a dark OS setting.
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-background: #14181a;
--color-surface: #1c221e;
--color-surface-alt: #262e27;
--color-text: #edf1ea;
--color-text-muted: #a9b5a6;
--color-border: #384038;
--color-primary: #5fae7e;
--color-primary-hover: #7cc496;
--color-accent: #ea7a48;
--color-accent-hover: #f0946c;
--color-tag: #e8b84b;
--color-tag-ink: #2a2005;
--color-success: #5fae7e;
--color-warning: #e8b84b;
--color-error: #e5675a;
--color-allergen: #e2547b;
--color-allergen-ink: #3a0416;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45);
}
}
// Mirrors the block above for an explicit "dark" choice (`ThemeContext`
// sets `data-theme="dark"` on `<html>`), so it wins over the OS setting in
// both directions.
:root[data-theme="dark"] {
--color-background: #14181a;
--color-surface: #1c221e;
--color-surface-alt: #262e27;
--color-text: #edf1ea;
--color-text-muted: #a9b5a6;
--color-border: #384038;
--color-primary: #5fae7e;
--color-primary-hover: #7cc496;
--color-accent: #ea7a48;
--color-accent-hover: #f0946c;
--color-tag: #e8b84b;
--color-tag-ink: #2a2005;
--color-success: #5fae7e;
--color-warning: #e8b84b;
--color-error: #e5675a;
--color-allergen: #e2547b;
--color-allergen-ink: #3a0416;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45);
}

View file

@ -1,149 +0,0 @@
// =============================================================================
// Global stylesheet — imported exactly once, in main.tsx. Contains only
// truly app-wide rules: the theme tokens and a minimal reset/base styling
// that every page inherits. Anything specific to one component or page
// belongs in a .scss file colocated next to that component/page instead.
// =============================================================================
@use "./theme";
// Include borders/padding in an element's declared width/height everywhere,
// rather than the browser default of adding them on top.
*,
*::before,
*::after {
box-sizing: border-box;
}
// Minimal reset: remove the default body margin so pages can control their
// own layout without fighting the browser's default 8px margin.
body {
margin: 0;
font-family: var(--font-body);
font-size: var(--font-size-base);
line-height: 1.55;
color: var(--color-text);
background: var(--color-background);
}
// Headings use the condensed "label" face app-wide — see _theme.scss for
// the rationale. `text-wrap: balance` avoids a lone short word wrapping
// onto its own line in multi-line titles.
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
text-wrap: balance;
}
// Default to the page-title size; a heading used as a smaller component
// title (e.g. the auth card's <h1>) overrides this in its own stylesheet.
h1 {
font-size: var(--font-size-2xl);
}
// A visible, consistent focus ring for keyboard navigation — the browser
// default varies a lot between elements and browsers.
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
// Checkbox/radio appearance, app-wide — "selectable card" style: the native
// control itself is visually hidden (still real, focusable and
// screen-reader-visible — see the `input[type=...]` rule below, not
// `display: none`) and the whole label row it lives in becomes the
// interactive surface instead: a flat bordered box that fills in with a
// tinted background + primary border once selected, with a checkmark
// fading in on the leading edge.
//
// The base (unselected) look below is detected structurally with `:has()`
// — safe, since "does this label contain a checkbox/radio" never changes
// after mount. The *selected* look is instead driven by the `is-selected`
// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle
// in JS from the same boolean their caller already passes to `checked` —
// chaining a second `:has(:checked)` to react to that live state turned
// out to be unreliable across browsers, so this only needs one
// always-true `:has()`.
//
// Every checkbox/radio in the app goes through this one place (the allergy
// grid, the theme picker, anywhere future) rather than each feature styling
// its own — see profile-forms.scss / settings-pages.scss, which only
// arrange these within their own layout (grid vs. stacked list) and
// intentionally don't re-style the control/label look itself.
label:has(> input[type="checkbox"]),
label:has(> input[type="radio"]) {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
// Overrides the generic `label { font-weight: 600 }` base rule
// (profile-forms.scss) — without this, an *unselected* row reads just as
// bold as a selected one (only `.allergy-select__option` happened to set
// its own 400 already; `.theme-select__option` didn't, so its rows were
// all permanently bold until this was centralized here).
font-weight: 400;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
&.is-selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
color: var(--color-primary);
font-weight: 600;
}
&:has(:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
// The control itself is removed from the visual flow — hidden the
// "sr-only" way (not `display: none`) so it stays focusable/tabbable and
// announced correctly by screen readers; the label above carries the
// entire visible selected/unchecked look.
input[type="checkbox"],
input[type="radio"] {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
// The checkmark — a real element (see components/ui/Checkbox.tsx /
// Radio.tsx) shown via the same `is-selected` class as the label's own
// look above, not a separate CSS-only trigger. Scaled in from nothing so
// toggling has a bit of motion. Same mark for both checkbox and radio: one
// consistent "selected" language app-wide rather than a checkmark here and
// a dot there. Sits first in the row (before the label text, per DOM
// order) — a classic "control on the left" layout rather than trailing.
.check-mark {
flex: none;
width: 0.9rem;
height: 0.9rem;
background: var(--color-primary);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
transform: scale(0);
transition: transform 0.1s ease;
}
.is-selected .check-mark {
transform: scale(1);
}

View file

@ -1,5 +0,0 @@
/// <reference types="vite/client" />
// Injected by `define` in vite.config.ts, sourced from package.json's
// version field — rendered in AdminLayout.tsx.
declare const __APP_VERSION__: string;

View file

@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src"]
}

View file

@ -1,8 +0,0 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View file

@ -1,19 +0,0 @@
import { readFileSync } from "node:fs";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// Read once at config-eval time — same trick as apps/web's vite.config.ts.
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
export default defineConfig({
plugins: [react()],
server: { port: 5174 },
css: {
preprocessorOptions: {
scss: { api: "modern-compiler" },
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
});

View file

@ -11,7 +11,7 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — defaults shown (see src/config/env.ts)
# JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173
# CORS_ORIGIN=http://localhost:5173,https://localhost
# INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see
@ -25,3 +25,22 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed to use the admin surface (apps/web's /admin/* routes) — every
# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a
# different value than JWT_SECRET. Generate your own the same way.
# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars
# Read only by `src/scripts/create-admin.ts` when its --email/--password/
# --name flags are omitted — never by the running server.
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
# Only needed to create a Forgejo issue from a contact message
# (apps/web's /admin/messages, issue #81). The button is hidden and the
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
# personal access token with the write:issue scope — generate it in Forgejo,
# never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo

View file

@ -60,8 +60,11 @@ COPY --from=build /repo/apps/web/dist ./apps/web/dist
WORKDIR /repo/apps/api
EXPOSE 3000
# Applies pending migrations, then seeds the reference data (Diet/Category/
# Allergy — see src/scripts/seed-runtime.ts) before starting. Both steps are
# safe to repeat on every container start: migrate deploy only applies
# pending migrations, and the seed upserts by unique name.
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js"]
# Applies pending migrations, seeds the reference data (Diet/Category/
# Allergy — see src/scripts/seed-runtime.ts), then runs any pending one-time
# data backfill (src/scripts/backfill-runtime.ts) before starting. migrate
# deploy only applies pending migrations and the seed upserts by unique
# 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:*",
"@prisma/client": "^5.22.0",
"argon2": "0.31.2",
"cookie": "^2.0.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.3",
"prisma": "^5.22.0",
"socket.io": "^4.8.3",
"zod": "^3.23.8"
},
"devDependencies": {
@ -38,6 +40,7 @@
"chai": "^5.1.2",
"cross-env": "^10.1.0",
"mocha": "^10.8.2",
"socket.io-client": "^4.8.3",
"supertest": "^7.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"

View file

@ -0,0 +1,14 @@
-- AlterTable: off-catalog ingredient "placeholder" rows. 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.
ALTER TABLE "ingredients" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ingredients" ADD COLUMN "display_name" TEXT;
ALTER TABLE "ingredients" ADD COLUMN "created_by_id" INTEGER;
ALTER TABLE "ingredients" ADD COLUMN "created_at" TIMESTAMP(3);
ALTER TABLE "ingredients" ADD COLUMN "reviewed_at" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "ingredients_is_placeholder_idx" ON "ingredients"("is_placeholder");
-- AddForeignKey
ALTER TABLE "ingredients" ADD CONSTRAINT "ingredients_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,22 @@
-- CreateTable: temperatures mentioned in the same clause as a detected
-- technique — regex-extracted (services/tech-step-intent-service), no
-- foreign key of its own (the structured value is the data). Same
-- composite FK + ON DELETE CASCADE shape as step_tech_step_ingredient /
-- step_tech_step_utensil (see 20260826121000_step_tech_step_metadata).
CREATE TABLE "step_tech_step_temperature" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"celsius" DECIMAL(6,2),
"gas_mark" INTEGER,
"qualitative" TEXT,
"raw" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_temperature_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_temperature" ADD CONSTRAINT "step_tech_step_temperature_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;

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
users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[]
@@map("diet")
@ -125,10 +124,21 @@ model UserProfile {
/// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin")
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
/// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[]
/// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
/// Placeholder `TechStep` rows this profile proposed while correcting a step
/// — see `TechStep.isPlaceholder`.
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[]
@@map("user_profiles")
}
@ -210,17 +220,41 @@ model Planning {
}
model PlanningItem {
id Int @id @default(autoincrement())
planningId Int @map("planning_id")
weekDay String @map("week_day")
id Int @id @default(autoincrement())
planningId Int @map("planning_id")
weekDay String @map("week_day")
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
/// 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.
createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id])
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
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")
}
@ -343,7 +377,6 @@ model Recipe {
steps Step[]
planningItems PlanningItem[]
favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId])
@@map("recipe")
@ -364,21 +397,6 @@ model RecipeFavorite {
@@map("recipe_favorite")
}
/// Explicit join table for the recipe <-> diet "associated regime" tags
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
/// manual reminder set by whoever creates/edits the recipe, not computed
/// from its ingredients.
model RecipeDiet {
recipeId Int @map("recipe_id")
dietId Int @map("diet_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([recipeId, dietId])
@@map("recipe_diet")
}
/// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
@ -530,6 +548,38 @@ model Ingredient {
/// ingredient↔recipe linking in the database, the UI only pre-fills the
/// catalog's own search with this ingredient's name).
reproducible Boolean @default(false)
/// `true` = a "placeholder" row: a free-text ingredient a user typed on a
/// recipe line because the seeded catalog had nothing matching (see
/// specs/batch-cooking-modele.md's "ingrédients hors-catalogue"). Such a
/// row has `displayName` non-null, a generated `key` (`placeholder:<uuid>`,
/// never an i18n label), and the default metadata (`JAR`/`dryGoods`/`other`,
/// no allergen/diet links). It is referenced by `RecipeIngredient` like any
/// other `Ingredient`, but `GET /reference/ingredients` and
/// `ingredient-matcher.ts`'s `loadIngredientCatalog` both exclude it — it is
/// never a browsable/matchable target, only a per-line stand-in a
/// maintainer later promotes into a real catalog entry by hand. The
/// `/admin/catalog/placeholders` view groups these by normalized name so
/// the maintainer sees which ingredients the catalog is missing.
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// Display name of a placeholder ingredient — the exact text the user
/// typed. `null` for a real catalog row (whose label lives in i18n under
/// `catalog.ingredients.<key>`). Invariant "non-null iff `isPlaceholder`"
/// is enforced service-side, not by the schema (same posture as other
/// cross-field invariants here).
displayName String? @map("display_name")
/// Profile that first created this placeholder — context for the admin
/// catalog-gap review. `onDelete: SetNull` so deleting an account never
/// blocks on, or cascades into, the recipes that still use its placeholder.
/// `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was created. `null` for a real catalog row (the
/// seed carries no timestamp).
createdAt DateTime? @map("created_at")
/// Stamped when an admin has triaged this catalog gap
/// (`PATCH /admin/catalog/placeholders/mark-reviewed`) — the group then
/// drops out of the default "à traiter" list. `null` while pending / for a
/// real catalog row.
reviewedAt DateTime? @map("reviewed_at")
recipes RecipeIngredient[]
allergies IngredientAllergy[]
@ -540,7 +590,10 @@ model Ingredient {
/// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[]
/// The profile that created this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("PlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("ingredients")
}
@ -631,14 +684,19 @@ model Unit {
/// quantity per recipe, so this join table carries quantity + unit
/// (project decision, not in the original spec doc).
model RecipeIngredient {
recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id")
quantity Decimal @db.Decimal(10, 2)
unitId Int @map("unit_id")
recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id")
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
/// salt, pepper… added to taste, with nothing to scale by portions or to
/// aggregate on a shopping list. The "both null or neither" invariant is
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
/// the schema — same posture as this file's other cross-field invariants.
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit @relation(fields: [unitId], references: [id])
unit Unit? @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId])
@@map("recipe_ingredient")
@ -663,6 +721,33 @@ model TechStep {
id Int @id @default(autoincrement())
key String @unique
/// `true` = a "placeholder" technique: a free-text technique a user typed
/// while correcting a step, because nothing in the seeded catalog matched
/// (issue #131 for the ingredient precedent this mirrors — see
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
/// training data of its own, and is excluded from
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
/// like any other technique (the correction that created it applies
/// immediately), but a maintainer reviews it at
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// The exact text the user typed — `null` for a real catalog row (whose
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
/// `isPlaceholder`, enforced service-side.
displayName String? @map("display_name")
/// Profile that first proposed this technique — context for the admin
/// review. `onDelete: SetNull` so deleting an account never blocks on the
/// recipes still using its placeholder. `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was proposed. `null` for a real catalog row.
createdAt DateTime? @map("created_at")
/// Stamped once a maintainer has triaged this proposal
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
/// `null` while pending / for a real catalog row.
reviewedAt DateTime? @map("reviewed_at")
steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`.
@ -673,7 +758,10 @@ model TechStep {
/// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[]
/// The profile that proposed this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("tech_step")
}
@ -772,6 +860,9 @@ model StepTechStep {
/// Utensils mentioned in the same clause as this technique occurrence —
/// see `StepTechStepUtensil`.
utensils StepTechStepUtensil[]
/// Temperatures mentioned in the same clause as this technique occurrence
/// — see `StepTechStepTemperature`.
temperatures StepTechStepTemperature[]
@@id([stepId, order])
@@map("step_tech_step")
@ -830,6 +921,38 @@ model StepTechStepUtensil {
@@map("step_tech_step_utensil")
}
/// A temperature mention found in the same *clause* as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` /
/// `StepTechStepUtensil` (see their doc comments). Detected by
/// `services/tech-step-intent-service`'s regex pass (`temperature_extraction.py`),
/// not a `PhraseMatcher` — a temperature is numeric/pattern, not a word from
/// a finite vocabulary. Carries no foreign key: the structured value *is*
/// the data. At least one of `celsius` / `gasMark` / `qualitative` is
/// non-null:
/// - `celsius` — an oven/pan temperature in °C (a °F mention is
/// converted + rounded upstream).
/// - `gasMark` — a gas-mark / thermostat number ("th. 6").
/// - `qualitative` — "low" | "medium" | "high" ("feu doux/moyen/vif").
/// `raw` is the exact recognized fragment, `start`/`end` its span in
/// `Step.description` ([start, end), same as `StepTechStep.start`/`end`).
/// `source` mirrors the others (`"auto"` only today — no correction UI).
model StepTechStepTemperature {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
start Int
end Int
celsius Decimal? @db.Decimal(6, 2)
gasMark Int? @map("gas_mark")
qualitative String?
raw String
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
@@map("step_tech_step_temperature")
}
/// One user-submitted correction to a `Step`'s detected techniques —
/// captures ADD (a missing technique the classifier didn't find),
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
@ -894,7 +1017,7 @@ model StepTechStepCorrection {
///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion {
@ -991,3 +1114,145 @@ model WorkerHeartbeat {
@@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,8 +7,11 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { contactRouter } from "./modules/contact/contact.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
@ -33,32 +36,52 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger);
// Two allowed origins: the main app (`CORS_ORIGIN`) and the separate
// admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming
// `Origin` against any entry of the list.
server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] });
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
// config/env.ts for why the packaged Android app's origin must always be
// one of them). The admin surface (`/admin/*`) is served by this same API
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
// rest of the app, so no extra CORS entry.
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
server.mountRouter("/auth", authRouter);
// Admin application surface (`apps/admin-web`) — its own auth
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
server.mountRouter("/admin", adminRouter);
// Admin application surface — its own auth (`requireAdmin`, distinct
// cookie/secret), never the end-user session. Mounted at `/admin/api`,
// *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too
// (`/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);
// In-app "signaler un bug / suggestion" form (issue #80) — session
// required, one POST. Admins read these rows via `/admin/api/contact`,
// never this router.
server.mountRouter("/contact", contactRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single
// recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
// mobile.service.ts's doc comment for why this can't just be called
// directly from the browser.
server.mountRouter("/mobile", mobileRouter);
server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/cooking-session", cookingSessionRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see

View file

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

View file

@ -191,6 +191,114 @@ export const UTENSILS: string[] = [
"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
// businesses to declare — a standard, defensible reference list rather than
// 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) {
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,734 @@
import type {
CookingBackgroundTaskView,
CookingPhaseKind,
CookingPhaseNoteView,
CookingPhaseView,
CookingSessionRecipeRef,
CookingTaskIngredientView,
CookingTaskView,
CookingTemperatureView,
TechStepView,
UtensilView,
} from "@batch-cooking/shared";
/**
* The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`):
* takes the week's planned recipes — already resolved to reference views by
* `cooking-session.service.ts` — and reorganizes their steps into an ordered
* sequence of {@link CookingPhaseView}s that pools shared preparation and
* interleaves the recipes so passive cooks (simmer, braise, bake…) run in
* the background while the cook does active work from another recipe.
*
* Pure and synchronous, no database access — same `matchXxx()` pure /
* `loadXxx()` DB-backed split as `ingredient-matcher.ts` /
* `tech-step-matcher.ts` / `shopping-list.service.ts`'s
* `aggregateShoppingList`, so the whole optimization is unit-testable
* without a Postgres round-trip.
*
* Two families of work are *merged* across recipes:
* - **Preparation** — a `chop`/`peel`/… technique applied to the same
* ingredient by two or more recipes, in a step that does nothing but
* prep, collapses into one {@link CookingTaskView} of `kind:
* "merged-prep"`.
* - **Setup** — the same `preheat`/`boil`/`bainMarie` action shared across
* recipes collapses into one `kind: "merged-setup"` task, but *only when
* the action metadata is compatible*: two recipes that both preheat the
* oven merge only when they preheat it to the **same temperature**.
* Two preheats at different temperatures stay separate (you cannot heat
* one oven to two temperatures at once), are ordered coolest-first, and
* the mise-en-place phase carries a {@link CookingPhaseNoteView} telling
* the cook to run them in sequence.
*
* Cooking steps themselves are still never merged (no shared-oven bake
* batching yet); they're only *reordered* for parallelism.
*/
/**
* Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s
* `TECH_STEPS`) that are pure knife/prep work on an ingredient — the only
* techniques v1 pools across recipes. A *step* counts as prep only when
* **every** technique it mentions is in here (see {@link isPurePrepStep}):
* "émincer les oignons" merges, "faire revenir les oignons émincés" does
* not (its `panFry` keeps it a cooking step).
*/
const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
"chop",
"peel",
"mince",
"julienne",
"brunoise",
"concasse",
"paysanne",
"mirepoix",
"zest",
"score",
"pod",
"shellEgg",
"hollowOut",
"filet",
"disgorge",
"sift",
"dustWithFlour",
"peelBlanch",
]);
/**
* How much of the cook's attention a technique needs once it's under way —
* the axis that makes parallelism possible.
*
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat
* the oven, bring a pot of water to the boil. Pooled into the first
* ("mise en place") phase so it's running before it's needed, and merged
* across recipes when compatible (see {@link extractMergedSetup}).
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
* rest…). Scheduled, then floated into every following phase's
* `background` until the step that consumes it comes up.
* - anything not listed here, or a step with no detected technique at all,
* is treated as `"ACTIVE"` — hands-on, occupies the cook.
*/
const SETUP_TECHNIQUES: ReadonlySet<string> = new Set(["preheat", "boil", "bainMarie"]);
/** See {@link SETUP_TECHNIQUES}. */
const PASSIVE_TECHNIQUES: ReadonlySet<string> = new Set([
"simmer",
"bake",
"roast",
"braise",
"marinate",
"rest",
"proof",
"confit",
"reduce",
"blindBake",
"compote",
"smother",
"setGel",
"pasteurize",
"appertize",
"poach",
"sweat",
"glaze",
]);
/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */
type Attention = "SETUP" | "PASSIVE" | "ACTIVE";
/** One technique occurrence within a step, already scaled to the planned portions. */
interface OptimizerTechStepInput {
techStep: TechStepView;
order: number;
ingredients: CookingTaskIngredientView[];
utensils: UtensilView[];
/**
* Target temperature(s) named in this same clause (`preheat` → "à 180 °C").
* The structured subset only — see {@link CookingTemperatureView}. Not
* scaled (a temperature doesn't grow with the portion count). `[]` when
* the clause named no temperature.
*/
temperatures: CookingTemperatureView[];
}
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
interface OptimizerStepInput {
stepId: number;
order: number;
description: string;
techSteps: OptimizerTechStepInput[];
}
/**
* One planned recipe, as handed to {@link optimizeCookingPlan}. `portions`
* is the planning slot's own count and `recipePortions` the recipe's
* as-written yield — quantities are scaled by `portions / recipePortions`
* (see {@link scaleOf}). The same recipe planned twice at different portion
* counts arrives as two entries with the same `recipeId`; that's
* intentional (two real cooking jobs), and merged-prep still pools their
* knife work back together.
*/
interface OptimizerRecipeInput {
recipeId: number;
name: string;
portions: number;
recipePortions: number;
steps: OptimizerStepInput[];
}
/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */
interface OptimizeCookingPlanResult {
recipes: CookingSessionRecipeRef[];
phases: CookingPhaseView[];
}
export type {
OptimizeCookingPlanResult,
OptimizerRecipeInput,
OptimizerStepInput,
OptimizerTechStepInput,
};
/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */
function scaleOf(recipe: OptimizerRecipeInput): number {
if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1;
return recipe.portions / recipe.recipePortions;
}
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils/temperatures unioned across its technique clauses. */
interface NormalizedStep {
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
taskId: string;
recipeIndex: number;
recipe: CookingSessionRecipeRef;
stepId: number;
order: number;
description: string;
techSteps: OptimizerTechStepInput[];
attention: Attention;
isPurePrep: boolean;
/** Every technique in the step is a {@link SETUP_TECHNIQUES} one — eligible to pool via {@link extractMergedSetup}. */
isPureSetup: boolean;
dominantTechnique: TechStepView | null;
ingredients: CookingTaskIngredientView[];
utensils: UtensilView[];
/** Distinct target temperatures across the step's clauses (see {@link unionTemperatures}) — drives setup pooling and the coolest-first ordering. */
temperatures: CookingTemperatureView[];
/** Set once merged-prep/merged-setup extraction absorbs this step wholesale — it then produces no standalone task. */
absorbed: boolean;
}
/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
function poolIngredient(lines: CookingTaskIngredientView[]): {
quantity: number | null;
unit: CookingTaskIngredientView["unit"];
} {
const first = lines[0];
if (!first) return { quantity: null, unit: null };
const unitId = first.unit?.id ?? null;
let total = 0;
for (const line of lines) {
if (line.quantity === null || (line.unit?.id ?? null) !== unitId) {
return { quantity: null, unit: null };
}
total += line.quantity;
}
return { quantity: total, unit: first.unit };
}
/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */
function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] {
const groups = new Map<string, CookingTaskIngredientView[]>();
for (const line of lines) {
const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`;
const group = groups.get(key);
if (group) group.push(line);
else groups.set(key, [line]);
}
const out: CookingTaskIngredientView[] = [];
for (const group of groups.values()) {
const head = group[0];
if (!head) continue;
const pooled = poolIngredient(group);
out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit });
}
return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
}
/** Unions utensils by id, keeping a stable order by key. */
function unionUtensils(utensils: UtensilView[]): UtensilView[] {
const byId = new Map<number, UtensilView>();
for (const utensil of utensils) byId.set(utensil.id, utensil);
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
}
/**
* A stable canonical string for one temperature — `c<°C>` / `g<gasMark>` /
* `q<level>`, or `""` when every field is null (shouldn't happen — the
* upstream extractor guarantees one non-null — but stays total). Two
* temperatures are "the same" iff their keys are equal: **no conversion**
* between °C, gas marks and qualitative levels is guessed for a merge
* decision (same "don't invent a conversion" stance as `ShoppingListItemView`
* on units). °C is rounded to the nearest degree so `180` and `180.00`
* (a `Decimal` artefact) collapse.
*/
function temperatureKey(temperature: CookingTemperatureView): string {
if (temperature.celsius !== null) return `c${Math.round(temperature.celsius)}`;
if (temperature.gasMark !== null) return `g${temperature.gasMark}`;
if (temperature.qualitative !== null) return `q${temperature.qualitative}`;
return "";
}
/** Unions temperatures by {@link temperatureKey}, keeping a stable order by that key. */
function unionTemperatures(temperatures: CookingTemperatureView[]): CookingTemperatureView[] {
const byKey = new Map<string, CookingTemperatureView>();
for (const temperature of temperatures) byKey.set(temperatureKey(temperature), temperature);
return [...byKey.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
}
/**
* A single number to *order* temperatures by (coolest first) — **never** used
* to decide a merge (that's {@link temperatureKey}, exact-match only). Gas
* marks map via the French `th × 30 ≈ °C` convention and qualitative levels
* via rough midpoints, purely so a mixed list still sorts sensibly; a
* temperature-less task sorts first.
*/
function comparableTemp(temperature: CookingTemperatureView): number {
if (temperature.celsius !== null) return temperature.celsius;
if (temperature.gasMark !== null) return temperature.gasMark * 30;
if (temperature.qualitative !== null) {
return { low: 120, medium: 180, high: 240 }[temperature.qualitative];
}
return Number.NEGATIVE_INFINITY;
}
/** A setup task's sort position (coolest first) — its lowest target, or "before everything" when it has no detected temperature. */
function setupTaskTemp(task: CookingTaskView): number {
if (task.temperatures.length === 0) return Number.NEGATIVE_INFINITY;
return Math.min(...task.temperatures.map(comparableTemp));
}
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
}
/** A step is pure setup only if it has techniques and every one is in {@link SETUP_TECHNIQUES} — mirror of {@link isPurePrepStep}. */
function isPureSetupStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => SETUP_TECHNIQUES.has(ts.techStep.key));
}
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
const last = techSteps[techSteps.length - 1];
if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE";
return "ACTIVE";
}
/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] {
const scale = scaleOf(recipe);
const recipeRef: CookingSessionRecipeRef = {
recipeId: recipe.recipeId,
name: recipe.name,
portions: recipe.portions,
};
return [...recipe.steps]
.sort((a, b) => a.order - b.order)
.map((step) => {
const techSteps: OptimizerTechStepInput[] = [...step.techSteps]
.sort((a, b) => a.order - b.order)
.map((ts) => ({
techStep: ts.techStep,
order: ts.order,
ingredients: ts.ingredients.map((line) => ({
ingredient: line.ingredient,
quantity: line.quantity === null ? null : line.quantity * scale,
unit: line.unit,
})),
utensils: ts.utensils,
// Temperatures are not scaled — an oven set to 180 °C for 4
// portions is still 180 °C for 8.
temperatures: ts.temperatures,
}));
const lastTech = techSteps[techSteps.length - 1];
return {
taskId: `step:${recipeIndex}:${step.stepId}`,
recipeIndex,
recipe: recipeRef,
stepId: step.stepId,
order: step.order,
description: step.description,
techSteps,
attention: attentionOf(techSteps),
isPurePrep: isPurePrepStep(techSteps),
isPureSetup: isPureSetupStep(techSteps),
dominantTechnique: lastTech ? lastTech.techStep : null,
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
temperatures: unionTemperatures(techSteps.flatMap((ts) => ts.temperatures)),
absorbed: false,
} satisfies NormalizedStep;
});
}
/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
function prepSignature(step: NormalizedStep): string {
const pairs: string[] = [];
for (const ts of step.techSteps) {
for (const line of ts.ingredients) {
pairs.push(`${ts.techStep.key}:${line.ingredient.id}`);
}
}
return [...new Set(pairs)].sort().join("+");
}
/**
* The setup signature of a pure-setup step — `<sorted technique keys>|<sorted
* temperature keys>`. Two steps with the same signature start the *same*
* appliance in the *same* state and can be pooled. The temperature part is
* `""` when no temperature was detected, which is a valid signature: two
* bare "préchauffer le four" steps then share it and merge (the optimistic
* default — an unknown temperature is assumed compatible). The appliance
* itself is deliberately not in the key (a `preheat` is the oven either
* way); its utensils are still unioned onto the pooled task.
*/
function setupSignature(step: NormalizedStep): string {
const techKeys = [...new Set(step.techSteps.map((ts) => ts.techStep.key))].sort().join("+");
const tempKeys = step.temperatures
.map(temperatureKey)
.filter((key) => key !== "")
.sort()
.join("+");
return `${techKeys}|${tempKeys}`;
}
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
function stepToTask(step: NormalizedStep): CookingTaskView {
return {
id: step.taskId,
kind: "step",
technique: step.dominantTechnique,
description: step.description,
ingredients: step.ingredients,
utensils: step.utensils,
// Only a setup step (preheat/boil/…) carries a target temperature the
// plan should reason about; every other step leaves it `[]` even if its
// text happens to mention degrees.
temperatures: step.attention === "SETUP" ? step.temperatures : [],
sourceRecipes: [step.recipe],
originalSteps: [
{
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
},
],
};
}
/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */
function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView {
return {
id: `bg:${step.taskId}`,
technique: step.dominantTechnique,
description: step.description,
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
};
}
/**
* Pools pure-prep steps that do the *exact same* knife work (same
* {@link prepSignature}) in two or more distinct recipes into one
* `merged-prep` {@link CookingTaskView}, and marks every contributing step
* `absorbed` so it produces no standalone task. A pure-prep step whose
* signature is unique (only one recipe needs it) is left untouched — it
* still lands in the mise-en-place phase, just as its own step task.
*
* Returns the merged tasks in a stable order (by id).
*/
function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
const bySignature = new Map<string, NormalizedStep[]>();
for (const step of steps) {
if (!step.isPurePrep) continue;
const signature = prepSignature(step);
if (signature === "") continue;
const group = bySignature.get(signature);
if (group) group.push(step);
else bySignature.set(signature, [step]);
}
const merged: CookingTaskView[] = [];
for (const [signature, group] of bySignature) {
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
if (recipeIndexes.size < 2) continue;
for (const step of group) step.absorbed = true;
// Every contributing clause's ingredient lines, pooled per ingredient.
const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients));
const ingredients = unionIngredients(allLines);
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
// Dominant technique of the pool = the first pair's technique (v1
// signatures are almost always a single `<technique>:<ingredient>`
// pair; a multi-pair signature just takes the earliest).
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature;
// Distinct source recipes / original step texts, in input order.
const sourceRecipes: CookingSessionRecipeRef[] = [];
const seenRecipe = new Set<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
merged.push({
id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`,
kind: "merged-prep",
technique: firstTech,
description: null,
ingredients,
utensils,
temperatures: [],
sourceRecipes,
originalSteps,
});
}
return merged.sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Pools pure-setup steps with the same {@link setupSignature} (same action,
* same detected temperature) across two or more distinct recipes into one
* `merged-setup` {@link CookingTaskView}, marking each contributing step
* `absorbed`. Two `preheat` steps at *different* temperatures have different
* signatures and are therefore never pooled — {@link detectApplianceTemperatureConflicts}
* turns that into a phase note. A signature only one recipe needs is left
* untouched (still lands in mise-en-place as its own step task).
*
* Returns the merged tasks sorted by id.
*/
function extractMergedSetup(steps: NormalizedStep[]): CookingTaskView[] {
const bySignature = new Map<string, NormalizedStep[]>();
for (const step of steps) {
if (!step.isPureSetup) continue;
const signature = setupSignature(step);
const group = bySignature.get(signature);
if (group) group.push(step);
else bySignature.set(signature, [step]);
}
const merged: CookingTaskView[] = [];
for (const group of bySignature.values()) {
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
if (recipeIndexes.size < 2) continue;
for (const step of group) step.absorbed = true;
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
const ingredients = unionIngredients(
group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients)),
);
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
const temperatures = unionTemperatures(group.flatMap((s) => s.temperatures)).sort(
(a, b) => comparableTemp(a) - comparableTemp(b),
);
// Distinct source recipes / original step texts, in input order.
const sourceRecipes: CookingSessionRecipeRef[] = [];
const seenRecipe = new Set<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
const tempKey = temperatures.map(temperatureKey).join("+");
merged.push({
id: `setup:${firstTech ? firstTech.key : "setup"}:${tempKey || "any"}`,
kind: "merged-setup",
technique: firstTech,
description: null,
ingredients,
utensils,
temperatures,
sourceRecipes,
originalSteps,
});
}
return merged.sort((a, b) => a.id.localeCompare(b.id));
}
/**
* The kitchen has one oven: if the mise-en-place ends up with two or more
* `preheat` tasks at *distinct known* temperatures, they cannot run at once.
* Emits a single {@link CookingPhaseNoteView} listing those temperatures
* coolest-first so the web layer can say "enchaîner 180 °C puis 210 °C".
* A `preheat` with no detected temperature contributes nothing to the note
* (it merged optimistically with the other unknowns and we've no number to
* show).
*/
function detectApplianceTemperatureConflicts(
setupTasks: CookingTaskView[],
): CookingPhaseNoteView[] {
const preheats = setupTasks.filter((task) => task.technique?.key === "preheat");
const distinct = unionTemperatures(preheats.flatMap((task) => task.temperatures))
.filter((temperature) => temperatureKey(temperature) !== "")
.sort((a, b) => comparableTemp(a) - comparableTemp(b));
const technique = preheats.find((task) => task.technique !== null)?.technique;
if (distinct.length < 2 || !technique) return [];
return [{ kind: "appliance-temperature-conflict", technique, temperatures: distinct }];
}
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
const seen = new Set<string>();
const out: CookingSessionRecipeRef[] = [];
for (const recipe of recipes) {
const key = `${recipe.recipeId}:${recipe.portions}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions });
}
return out;
}
/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */
function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking";
}
/**
* See the file header. Given the week's planned recipes (already resolved
* to reference views), returns the display legend plus the ordered phases:
*
* 1. **Mise en place** (`"mise-en-place"`) — all prep first (`merged-prep`
* tasks, then leftover pure-prep steps), then all setup (`merged-setup`
* tasks and leftover `SETUP` steps, ordered coolest-first). Carries an
* `appliance-temperature-conflict` note when the week needs the oven at
* two or more temperatures. Omitted entirely if it would be empty.
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
* each phase pops the next remaining step of every recipe that still has
* one (passive-cook steps first, so long cooks start early). A passive
* step scheduled in one phase is echoed in every later phase's
* `background` until that recipe's next step is popped.
*/
export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult {
const legend = toRecipeLegend(recipes);
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
const allSteps = normalized.flat();
// Merged-prep first, then merged-setup — each marks its contributing
// steps `absorbed`, so the leftover loop below only sees the solo ones.
const mergedPrep = extractMergedPrep(allSteps);
const mergedSetup = extractMergedSetup(allSteps);
const phases: CookingPhaseView[] = [];
// Phase 0 — mise en place: all prep, then all setup. Keeping the two
// groups apart (rather than input order) lets the setup group be ordered
// coolest-first without disturbing the prep order.
const prepTasks: CookingTaskView[] = [...mergedPrep];
const setupTasks: CookingTaskView[] = [...mergedSetup];
for (const step of allSteps) {
if (step.absorbed) continue;
if (step.isPurePrep) {
prepTasks.push(stepToTask(step));
step.absorbed = true; // consumed here, not again in the cooking loop
} else if (step.attention === "SETUP") {
setupTasks.push(stepToTask(step));
step.absorbed = true;
}
}
// Coolest oven first, so a temperature conflict reads "180 °C puis 210 °C";
// `id` as a stable tiebreaker keeps the plan deterministic.
setupTasks.sort((a, b) => setupTaskTemp(a) - setupTaskTemp(b) || a.id.localeCompare(b.id));
const miseTasks = [...prepTasks, ...setupTasks];
if (miseTasks.length > 0) {
phases.push({
index: 0,
kind: "mise-en-place",
tasks: miseTasks,
background: [],
notes: detectApplianceTemperatureConflicts(setupTasks),
});
}
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
// a recipe out of the *next* phase right after it starts a passive cook,
// so another recipe's active work fills that phase and the passive cook
// shows up as `background` there instead of being immediately followed by
// its own next step.
const queues = normalized.map((steps) => ({
remaining: steps.filter((s) => !s.absorbed),
cursor: 0,
hold: 0,
}));
/** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */
const runningPassive = new Map<number, NormalizedStep>();
while (queues.some((queue) => queue.cursor < queue.remaining.length)) {
const phaseSteps: NormalizedStep[] = [];
queues.forEach((queue, recipeIndex) => {
const next = queue.remaining[queue.cursor];
if (!next) return;
if (queue.hold > 0) {
// Still tending its passive cook this phase — leave it in
// `runningPassive` so it renders as background, don't advance.
queue.hold--;
return;
}
// This recipe is advancing — whatever passive cook it had going is
// now being tended to, so it stops showing as background.
runningPassive.delete(recipeIndex);
phaseSteps.push(next);
queue.cursor++;
});
// Every recipe with steps left is holding on a passive cook — break the
// stall by releasing all holds and letting the next iteration advance.
if (phaseSteps.length === 0) {
for (const queue of queues) queue.hold = 0;
continue;
}
// Background = passive cooks from earlier phases not yet resolved above.
const background = [...runningPassive.values()].map(stepToBackground);
// Start the long cooks first within the phase.
phaseSteps.sort((a, b) => {
const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1);
return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex;
});
const tasks = phaseSteps.map(stepToTask);
phases.push({
index: phases.length,
kind: cookingPhaseKind(tasks),
tasks,
background,
// Temperature conflicts only matter in mise-en-place, where all the
// preheats live; a cooking phase never carries a note today.
notes: [],
});
for (const step of phaseSteps) {
if (step.attention === "PASSIVE") {
runningPassive.set(step.recipeIndex, step);
const queue = queues[step.recipeIndex];
if (queue) queue.hold = 1;
}
}
}
// `index` was set from `phases.length` as we went; re-stamp so it always
// matches the final array position even if phase 0 was skipped.
phases.forEach((phase, index) => {
phase.index = index;
});
return { recipes: legend, phases };
}

View file

@ -427,6 +427,11 @@ export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMa
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
const ingredients = await prisma.ingredient.findMany({
// Placeholder rows (user free-text, `placeholder:<uuid>` key) have no
// authored label so they'd be skipped by the `label === undefined`
// check below anyway — filtered here too so an import never even
// considers resolving one raw line to another line's placeholder.
where: { isPlaceholder: false },
select: { id: true, key: true },
});
const catalog: IngredientMatchEntry[] = [];

View file

@ -25,11 +25,28 @@ export interface IntentServiceEntity {
kind: "technique" | "utensil";
}
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */
/**
* A temperature the service recognized in the text — mirrors
* `TemperaturePayload` (Python `schemas.py`), `gas_mark` camelCased to
* `gasMark`. A separate list from {@link IntentServiceEntity} (not another
* `kind`) because it carries structured fields that don't fit an entity's
* `uid`. At least one of `celsius` / `gasMark` / `qualitative` is non-null.
*/
export interface IntentServiceTemperature {
start: number;
end: number;
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
raw: string;
}
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). `temperatures` is regex-extracted and independent of the model/training (empty when none, not omitted). */
export interface IntentServiceProcessResult {
entities: IntentServiceEntity[];
intent: string | null;
score: number;
temperatures: IntentServiceTemperature[];
}
/**
@ -88,10 +105,26 @@ export class IntentServiceClient {
*/
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
try {
return await this._request("/v1/process", {
// The wire shape uses `gas_mark` (Python field name); everything else
// is already camelCase / single-word. Map that one key at the
// boundary so the rest of `apps/api` only ever sees `gasMark`.
const wire = await this._request<
Omit<IntentServiceProcessResult, "temperatures"> & {
temperatures: (Omit<IntentServiceTemperature, "gasMark"> & { gas_mark: number | null })[];
}
>("/v1/process", {
method: "POST",
body: JSON.stringify({ locale, text }),
});
return {
entities: wire.entities,
intent: wire.intent,
score: wire.score,
temperatures: (wire.temperatures ?? []).map(({ gas_mark, ...rest }) => ({
...rest,
gasMark: gas_mark,
})),
};
} catch (err) {
throw err;
}

View file

@ -5,7 +5,7 @@ import {
loadIngredientCatalog,
loadUnitCatalog,
} from "./ingredient-matcher.js";
import { intentServiceClient } from "./intent-service-client.js";
import { type IntentServiceTemperature, intentServiceClient } from "./intent-service-client.js";
/**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
@ -114,6 +114,7 @@ export interface TechStepMatch {
contextEnd: number;
ingredients: IngredientMention[];
utensils: UtensilMention[];
temperatures: TemperatureMention[];
}
/**
@ -129,6 +130,15 @@ export interface UtensilMention {
end: number;
}
/**
* A temperature the intent service's regex pass found (`temperatures` in
* `IntentServiceProcessResult`), attributed to whichever clause its span
* falls inside — same rule as {@link UtensilMention}. No DB id to resolve
* (unlike a technique/ingredient/utensil): the structured value *is* the
* data. Persisted verbatim as a `StepTechStepTemperature` row.
*/
export type TemperatureMention = IntentServiceTemperature;
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
export interface TechniqueCandidate {
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
@ -408,6 +418,7 @@ export class TechStepClassifierService {
.filter((entity) => entity.kind === "technique")
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
const temperatureMentions = nerResult.temperatures;
const clauses = splitIntoClauses(description, candidates);
const matches: TechStepMatch[] = [];
@ -442,6 +453,12 @@ export class TechStepClassifierService {
: [{ utensilId, start: entity.start, end: entity.end }];
});
// Temperatures carry no id to resolve — kept as-is, just attributed
// to the clause whose span contains them (same rule as utensils).
const temperatures: TemperatureMention[] = temperatureMentions.filter(
(temp) => temp.start >= clause.start && temp.end <= clause.end,
);
matches.push({
techStepId,
start: span.start,
@ -450,6 +467,7 @@ export class TechStepClassifierService {
contextEnd: clause.end,
ingredients,
utensils,
temperatures,
});
}

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 app
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
* against the database so a stateless JWT can still be invalidated
* server-side.
* Verifies an admin session JWT and resolves the `AdminUser` it belongs
* to — the transport-agnostic core both {@link requireAdmin} (HTTP) and
* `requireAdminSocket` (`require-admin-socket.ts`, the WebSocket handshake
* equivalent) build on; they only differ in *where* they read the raw
* cookie value from (`req.cookies`, already parsed by `cookie-parser`, vs
* a `socket.io` handshake's raw `Cookie` header, which isn't). Kept here
* 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
* it: an end-user session token and an admin session token are never
* interchangeable in either direction.
*
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
* every request is rejected rather than the surface left open — same
* posture as `requireInternalWorker`.
* that doesn't run the admin app) makes {@link resolveAdminFromToken}
* throw, so every request is rejected rather than the surface left open —
* same posture as `requireInternalWorker`.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
* no secret configured. Never distinguishes the reason.
* cookie or anything {@link resolveAdminFromToken} rejects on. Never
* distinguishes the reason.
*/
export async function requireAdmin(
req: Request,
@ -51,20 +75,13 @@ export async function requireAdmin(
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const payload = verifyAdminToken(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);
res.locals.adminUser = await resolveAdminFromToken(token);
next();
} catch (err) {
if (err instanceof HttpError) {
next(err);
} 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"));
}
}

View file

@ -13,16 +13,34 @@ import { toSafeProfile } from "../lib/safe-profile.js";
* typed, no cast needed.
*/
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;
}
/**
* 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
* profile. Reads the session cookie, verifies the JWT, and re-checks
* `tokenVersion` against the database — so a 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.
* profile. Reads the session cookie (falling back to an `Authorization:
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}),
* verifies the JWT, and re-checks `tokenVersion` against the database — so a
* 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`
* (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
* the codebase, whether or not it went through this middleware.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
* Never distinguishes the reason to the client.
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie
* and no usable Bearer header, malformed/expired JWT, unknown profile, or
* stale tokenVersion. Never distinguishes the reason to the client.
*/
export async function requireAuth(
req: Request,
@ -43,7 +61,11 @@ export async function requireAuth(
next: NextFunction,
) {
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") {
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 { 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();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;

View file

@ -0,0 +1,80 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
listPlaceholdersQuerySchema,
listTechStepPlaceholdersQuerySchema,
markPlaceholdersReviewedSchema,
markTechStepPlaceholdersReviewedSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
listPlaceholderGroups,
markPlaceholdersReviewed,
pruneOrphanPlaceholders,
} from "./admin-catalog.service.js";
import {
listTechStepPlaceholders,
markTechStepPlaceholdersReviewed,
} from "./admin-tech-step-catalog.service.js";
/**
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
* typed when the seeded catalog fell short — free-text ingredients on a
* recipe line, and free-text techniques while correcting a step (issue #130)
* — so a maintainer can see what's missing and mark gaps as handled.
*/
export const adminCatalogRouter = Router();
adminCatalogRouter.get(
"/placeholders",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listPlaceholderGroups(listPlaceholdersQuerySchema.parse(req.query)));
}),
);
adminCatalogRouter.patch(
"/placeholders/mark-reviewed",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await markPlaceholdersReviewed(markPlaceholdersReviewedSchema.parse(req.body)));
}),
);
/** Deletes placeholder rows no recipe references any more — see {@link pruneOrphanPlaceholders}. */
adminCatalogRouter.post(
"/placeholders/prune-orphans",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
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,156 @@
import type {
CatalogPlaceholderGroupView,
ListPlaceholdersQuery,
MarkPlaceholdersReviewedInput,
PruneOrphansResultView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* Grouping key for two placeholder spellings that mean the same missing
* ingredient — lower-cased, accent-stripped, punctuation-neutralised,
* whitespace-collapsed. "Piment d'Espelette", "piment d espelette" and
* "PIMENT D'ESPELETTE" all normalise to `"piment d espelette"`, so the
* admin view shows one gap, not three. Pure (no DB) — unit-tested on its
* own, same split convention as `matchXxx()` vs `loadXxx()` elsewhere.
*/
export function normalizePlaceholderName(raw: string): string {
return raw
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim()
.replace(/\s+/g, " ");
}
/** Prisma `include` for the placeholder query — up to a few `RecipeIngredient` links per row, each with just enough of its recipe for the "seen in…" preview and the distinct-recipe count. */
const placeholderInclude = {
recipes: {
take: 5,
include: { recipe: { select: { id: true, name: true } } },
},
} as const;
/**
* Every placeholder `Ingredient` (see `Ingredient.isPlaceholder` in
* schema.prisma), grouped by {@link normalizePlaceholderName} so a
* maintainer reviews one row per *missing ingredient* rather than one per
* recipe line. Ordered by recipe impact (most-requested gap first), then
* name.
*
* `query.reviewed` selects which side to show: omitted / `"false"` drops
* groups whose every row has already been triaged (`reviewedAt` set) — the
* default working list; `"true"` keeps only those fully-triaged groups.
*/
export async function listPlaceholderGroups(
query: ListPlaceholdersQuery,
): Promise<CatalogPlaceholderGroupView[]> {
try {
const rows = await prisma.ingredient.findMany({
where: { isPlaceholder: true },
include: placeholderInclude,
orderBy: { createdAt: "asc" },
});
/** Accumulator per normalized name — mutated in the loop, shaped into the view after. */
interface GroupAccumulator {
normalizedName: string;
displayNames: Set<string>;
ingredientIds: number[];
recipeIds: Set<number>;
sampleRecipes: Map<number, string>;
firstSeenAt: Date | null;
allReviewed: boolean;
}
const groups = new Map<string, GroupAccumulator>();
for (const row of rows) {
const name = row.displayName ?? "";
const normalizedName = normalizePlaceholderName(name);
let group = groups.get(normalizedName);
if (!group) {
group = {
normalizedName,
displayNames: new Set(),
ingredientIds: [],
recipeIds: new Set(),
sampleRecipes: new Map(),
firstSeenAt: null,
allReviewed: true,
};
groups.set(normalizedName, group);
}
if (name.length > 0) group.displayNames.add(name);
group.ingredientIds.push(row.id);
for (const link of row.recipes) {
group.recipeIds.add(link.recipe.id);
if (group.sampleRecipes.size < 5) group.sampleRecipes.set(link.recipe.id, link.recipe.name);
}
if (row.createdAt && (group.firstSeenAt === null || row.createdAt < group.firstSeenAt)) {
group.firstSeenAt = row.createdAt;
}
if (row.reviewedAt === null) group.allReviewed = false;
}
// `reviewed=true` → the archive of handled gaps; anything else → the
// working list of gaps still to look at.
const wantReviewed = query.reviewed === "true";
return [...groups.values()]
.filter((group) => group.allReviewed === wantReviewed)
.map((group) => ({
normalizedName: group.normalizedName,
displayNames: [...group.displayNames].sort((a, b) => a.localeCompare(b, "fr")),
ingredientIds: group.ingredientIds,
recipeCount: group.recipeIds.size,
sampleRecipes: [...group.sampleRecipes.entries()].map(([id, name]) => ({ id, name })),
firstSeenAt: group.firstSeenAt?.toISOString() ?? null,
allReviewed: group.allReviewed,
}))
.sort(
(a, b) => b.recipeCount - a.recipeCount || a.normalizedName.localeCompare(b.normalizedName),
);
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Stamps `reviewedAt` on the given placeholder ids — a maintainer has seen
* this gap (and, if it warranted it, added the real catalog entry by hand;
* this endpoint never touches the catalog itself). Scoped to
* `isPlaceholder: true` so a stray real id is a silent no-op, not a
* mislabel. Returns how many rows were actually stamped.
*/
export async function markPlaceholdersReviewed(
input: MarkPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
try {
const { count } = await prisma.ingredient.updateMany({
where: { id: { in: input.ingredientIds }, isPlaceholder: true },
data: { reviewedAt: new Date() },
});
return { reviewed: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Deletes placeholder rows no recipe references any more — the debris left
* when a recipe edit drops a placeholder line (the `RecipeIngredient` row
* goes, the `Ingredient` row doesn't). A manual GC (button in the admin
* catalog view, or `scripts/prune-orphan-placeholders.ts`) rather than a
* cascade: a placeholder is still evidence of a catalog gap even with no
* live recipe, so dropping it is a deliberate call, not automatic.
*/
export async function pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
try {
const { count } = await prisma.ingredient.deleteMany({
where: { isPlaceholder: true, recipes: { none: {} } },
});
return { deleted: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

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 { 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();
/**

View file

@ -0,0 +1,180 @@
import type { MonitoringView, ServiceHealthView, ServiceStatus } from "@batch-cooking/shared";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
/** How long each outbound probe (Postgres query, intent-service HTTP) is allowed to take before it counts as `down`. */
const PROBE_TIMEOUT_MS = 2000;
/**
* Heartbeat-age thresholds for the LLM worker. Its default cron is weekly
* (`TECH_STEP_WORKER_CRON`, `0 3 * * 0`), and it also pings on boot/tick —
* so no ping for **8 days** means it likely missed its last scheduled fire
* (`degraded`), and none for **3 weeks** means it's almost certainly not
* running at all (`down`).
*/
const WORKER_STALE_AFTER_MS = 8 * 24 * 60 * 60 * 1000;
const WORKER_DOWN_AFTER_MS = 21 * 24 * 60 * 60 * 1000;
const WORKER_KEY = "tech-step-llm-worker";
function nowIso(): string {
return new Date().toISOString();
}
function roundMs(value: number): number {
return Math.round(value * 10) / 10;
}
function errMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** `12500` → `"il y a 12 s"`, `90000` → `"il y a 1 min"`, `172800000` → `"il y a 2 j"`. */
function formatAgo(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `il y a ${s} s`;
const m = Math.round(s / 60);
if (m < 60) return `il y a ${m} min`;
const h = Math.round(m / 60);
if (h < 48) return `il y a ${h} h`;
return `il y a ${Math.round(h / 24)} j`;
}
/** `process.uptime()` seconds → `"3 h 12 min"` / `"5 min"` / `"42 s"`. */
function formatUptime(seconds: number): string {
const s = Math.floor(seconds);
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
return `${h} h ${m % 60} min`;
}
/** `prisma.$queryRaw\`SELECT 1\`` with a bounded timeout — the DB connectivity probe. */
async function probePostgres(): Promise<ServiceHealthView> {
const start = performance.now();
try {
// `$queryRaw` doesn't take an AbortSignal — bound it with a race instead.
await Promise.race([
prisma.$queryRaw`SELECT 1`,
new Promise((_resolve, reject) =>
setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS),
),
]);
return {
key: "postgres",
status: "up",
latencyMs: roundMs(performance.now() - start),
detail: null,
checkedAt: nowIso(),
};
} catch (err) {
return {
key: "postgres",
status: "down",
latencyMs: null,
detail: errMessage(err),
checkedAt: nowIso(),
};
}
}
/** The API itself — trivially "up" (it's answering), reported with its process uptime/memory. */
function probeApi(): ServiceHealthView {
const mem = process.memoryUsage();
return {
key: "api",
status: "up",
latencyMs: 0,
detail: `uptime ${formatUptime(process.uptime())} · RSS ${Math.round(mem.rss / 1_000_000)} Mo`,
checkedAt: nowIso(),
};
}
/** `GET {INTENT_SERVICE_BASE_URL}/health` — no secret needed on that route (see the service's `routes/health.py`). */
async function probeIntentService(): Promise<ServiceHealthView> {
const start = performance.now();
try {
const res = await fetch(`${env.INTENT_SERVICE_BASE_URL}/health`, {
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
const latencyMs = roundMs(performance.now() - start);
return {
key: "intent-service",
status: res.ok ? "up" : "degraded",
latencyMs,
detail: `HTTP ${res.status}`,
checkedAt: nowIso(),
};
} catch (err) {
return {
key: "intent-service",
status: "down",
latencyMs: null,
detail: errMessage(err),
checkedAt: nowIso(),
};
}
}
/** Reads the LLM worker's stored `WorkerHeartbeat` (it has no HTTP surface to probe directly) and grades it by age + last job outcome. */
async function probeWorker(): Promise<ServiceHealthView> {
const heartbeat = await prisma.workerHeartbeat.findUnique({ where: { workerKey: WORKER_KEY } });
if (!heartbeat) {
return {
key: WORKER_KEY,
status: "unknown",
latencyMs: null,
detail: "aucun battement reçu",
checkedAt: nowIso(),
lastRunAt: null,
lastResult: null,
};
}
const ageMs = Date.now() - heartbeat.lastSeenAt.getTime();
const lastResult = (heartbeat.lastResult ?? null) as ServiceHealthView["lastResult"];
let status: ServiceStatus = "up";
if (ageMs > WORKER_DOWN_AFTER_MS) status = "down";
else if (ageMs > WORKER_STALE_AFTER_MS || lastResult?.ok === false) status = "degraded";
return {
key: WORKER_KEY,
status,
latencyMs: null,
detail: `dernier battement ${formatAgo(ageMs)}`,
checkedAt: nowIso(),
lastRunAt: heartbeat.lastRunAt?.toISOString() ?? null,
lastResult,
};
}
/**
* Actively probes every dependency the admin monitoring board watches —
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
* LLM worker (via its stored heartbeat). Each probe is independent and
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
* 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> {
try {
const [postgres, intentService, worker] = await Promise.all([
probePostgres(),
probeIntentService(),
probeWorker(),
]);
return {
generatedAt: nowIso(),
services: [postgres, probeApi(), intentService, worker],
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -0,0 +1,87 @@
import type {
ListTechStepPlaceholdersQuery,
MarkTechStepPlaceholdersReviewedInput,
TechStepPlaceholderView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* The missing-technique review (issue #130) — the tech-step counterpart of
* `admin-catalog.service.ts`'s off-catalog *ingredient* review. Kept a flat
* list rather than grouping near-duplicate spellings the way that one does:
* correcting a technique — let alone typing one the catalog is missing — is
* rare enough that a handful of raw rows is all a maintainer ever sees here.
*/
/** Prisma `include` for the placeholder query — a bounded set of the `StepTechStep` rows pointing at this technique, each resolved to just its recipe id/name for the count + "seen in…" preview. */
const placeholderInclude = {
steps: {
take: 50,
include: { step: { select: { recipe: { select: { id: true, name: true } } } } },
},
} as const;
/**
* Every placeholder `TechStep` (see `TechStep.isPlaceholder` in
* schema.prisma), most-impactful first (most distinct recipes, then name).
*
* `query.reviewed` selects which side to show: omitted / `"false"` drops
* rows already triaged (`reviewedAt` set) — the default working list;
* `"true"` keeps only those.
*/
export async function listTechStepPlaceholders(
query: ListTechStepPlaceholdersQuery,
): Promise<TechStepPlaceholderView[]> {
try {
const rows = await prisma.techStep.findMany({
where: { isPlaceholder: true },
include: placeholderInclude,
orderBy: { createdAt: "asc" },
});
const wantReviewed = query.reviewed === "true";
return rows
.filter((row) => (row.reviewedAt !== null) === wantReviewed)
.map((row) => {
const recipeById = new Map<number, string>();
for (const link of row.steps) {
recipeById.set(link.step.recipe.id, link.step.recipe.name);
}
return {
id: row.id,
displayName: row.displayName ?? "",
createdAt: row.createdAt?.toISOString() ?? null,
reviewedAt: row.reviewedAt?.toISOString() ?? null,
recipeCount: recipeById.size,
sampleRecipes: [...recipeById.entries()].slice(0, 5).map(([id, name]) => ({ id, name })),
};
})
.sort(
(a, b) => b.recipeCount - a.recipeCount || a.displayName.localeCompare(b.displayName, "fr"),
);
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Stamps `reviewedAt` on the given placeholder `TechStep` ids — a
* maintainer has seen this proposal (and, if it warranted it, promoted it
* into `reference-seed-data.ts` + `tech-step-training-data.ts` by hand; this
* endpoint never touches the catalog itself). Scoped to
* `isPlaceholder: true` so a stray real id is a silent no-op. Returns how
* many rows were actually stamped.
*/
export async function markTechStepPlaceholdersReviewed(
input: MarkTechStepPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
try {
const { count } = await prisma.techStep.updateMany({
where: { id: { in: input.techStepIds }, isPlaceholder: true },
data: { reviewedAt: new Date() },
});
return { reviewed: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -0,0 +1,80 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
ErrorCode,
listCorrectionsQuerySchema,
listSuggestionsQuerySchema,
retrainRequestSchema,
trainingDataSnippetQuerySchema,
updateTrainingSuggestionSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
getTrainingDataSnippet,
listCorrections,
listSuggestions,
runRetrain,
updateSuggestion,
} from "./admin-tech-steps.service.js";
/** 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();
adminTechStepsRouter.get(
"/suggestions",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listSuggestions(listSuggestionsQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.get(
"/corrections",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listCorrections(listCorrectionsQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.get(
"/training-data-snippet",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await getTrainingDataSnippet(trainingDataSnippetQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.patch(
"/suggestions/:id",
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 suggestion id: ${req.params.id}`,
);
}
const input = updateTrainingSuggestionSchema.parse(req.body);
res.status(200).json(await updateSuggestion(id, input));
}),
);
/**
* Runs the F1 regression gate then (if it passes) the full step backfill,
* and marks the given suggestion ids — see {@link runRetrain}. Long-running
* and process-locked: `409 RETRAIN_ALREADY_RUNNING` if one is already
* underway.
*/
adminTechStepsRouter.post(
"/retrain",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const input = retrainRequestSchema.parse(req.body);
res.status(200).json(await runRetrain(input));
}),
);

View file

@ -0,0 +1,325 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type CorrectionAdminView,
ErrorCode,
type ListCorrectionsQuery,
type ListSuggestionsQuery,
type RetrainRequestInput,
type RetrainResultView,
type TrainingDataSnippetQuery,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView,
type UpdateTrainingSuggestionInput,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../../lib/recipe-matching/tech-step-eval-runner.js";
import { backfillTechSteps } from "../../scripts/backfill-tech-steps.js";
/** How many raw corrections `listCorrections` returns per call — the browser is a triage view, not an export. */
const CORRECTIONS_PAGE_SIZE = 200;
const suggestionInclude = {
techStep: { select: { key: true } },
sourceCorrection: {
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
},
} as const;
type SuggestionRow = Awaited<
ReturnType<
typeof prisma.techStepTrainingSuggestion.findFirstOrThrow<{ include: typeof suggestionInclude }>
>
>;
/** Shapes one Prisma suggestion row (with {@link suggestionInclude}) into its admin view. */
function toSuggestionView(row: SuggestionRow): TrainingSuggestionAdminView {
const correction = row.sourceCorrection;
return {
id: row.id,
techStepKey: row.techStep.key,
locale: row.locale,
suggestedSynonyms: row.suggestedSynonyms,
suggestedUtterances: row.suggestedUtterances,
sourceType: row.sourceType,
status: row.status,
createdAt: row.createdAt.toISOString(),
sourceCorrection: correction
? {
id: correction.id,
recipeId: correction.step.recipeId,
stepId: correction.step.id,
clauseText: correction.step.description.slice(correction.start, correction.end),
previousTechStepKey: correction.previousTechStep?.key ?? null,
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
}
: null,
};
}
/**
* Every `TechStepTrainingSuggestion` matching the (all-optional) filters,
* grouped by technique key — same "one block per technique" organisation
* as `list-pending-training-suggestions.ts`'s CLI report, which this UI
* replaces.
*/
export async function listSuggestions(
query: ListSuggestionsQuery,
): Promise<TrainingSuggestionGroupView[]> {
try {
const rows = await prisma.techStepTrainingSuggestion.findMany({
where: {
...(query.status ? { status: query.status } : {}),
...(query.sourceType ? { sourceType: query.sourceType } : {}),
...(query.locale ? { locale: query.locale } : {}),
...(query.techStepKey ? { techStep: { key: query.techStepKey } } : {}),
},
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
include: suggestionInclude,
});
const byKey = new Map<string, TrainingSuggestionAdminView[]>();
for (const row of rows) {
const view = toSuggestionView(row);
const group = byKey.get(view.techStepKey);
if (group) group.push(view);
else byKey.set(view.techStepKey, [view]);
}
return [...byKey.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([techStepKey, suggestions]) => ({ techStepKey, suggestions }));
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Raw `StepTechStepCorrection`s for the admin browser — newest first,
* capped at {@link CORRECTIONS_PAGE_SIZE}. Unlike the worker's own
* `getPendingCorrections`, this **includes** the `correctedTechStepId IS NULL`
* removals ("no technique here") that never become suggestions and are
* otherwise invisible.
*/
export async function listCorrections(query: ListCorrectionsQuery): Promise<CorrectionAdminView[]> {
try {
const consumedFilter =
query.consumed === "true"
? { consumedAt: { not: null } }
: query.consumed === "false"
? { consumedAt: null }
: {};
const correctedFilter =
query.hasCorrectedTechStep === "true"
? { correctedTechStepId: { not: null } }
: query.hasCorrectedTechStep === "false"
? { correctedTechStepId: null }
: {};
const rows = await prisma.stepTechStepCorrection.findMany({
where: { ...consumedFilter, ...correctedFilter },
orderBy: { createdAt: "desc" },
take: CORRECTIONS_PAGE_SIZE,
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
});
return rows.map((row) => ({
id: row.id,
recipeId: row.step.recipeId,
stepId: row.step.id,
stepDescription: row.step.description,
clauseText: row.step.description.slice(row.start, row.end),
start: row.start,
end: row.end,
previousTechStepKey: row.previousTechStep?.key ?? null,
correctedTechStepKey: row.correctedTechStep?.key ?? null,
createdAt: row.createdAt.toISOString(),
consumedAt: row.consumedAt?.toISOString() ?? null,
}));
} catch (err) {
throw err;
}
}
/**
* Curates one suggestion — edit its proposed synonyms/utterances and/or
* flip its `status`. At least one field must be present.
*
* @throws {HttpError} `400 VALIDATION_ERROR` if the body is empty.
* @throws {HttpError} `404 NOT_FOUND` if `id` matches no suggestion.
*/
export async function updateSuggestion(
id: number,
input: UpdateTrainingSuggestionInput,
): Promise<TrainingSuggestionAdminView> {
try {
if (
input.status === undefined &&
input.suggestedSynonyms === undefined &&
input.suggestedUtterances === undefined
) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Nothing to update");
}
const existing = await prisma.techStepTrainingSuggestion.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.NOT_FOUND, `Training suggestion ${id} not found`);
}
const updated = await prisma.techStepTrainingSuggestion.update({
where: { id },
data: {
...(input.status !== undefined ? { status: input.status } : {}),
...(input.suggestedSynonyms !== undefined
? { suggestedSynonyms: input.suggestedSynonyms }
: {}),
...(input.suggestedUtterances !== undefined
? { suggestedUtterances: input.suggestedUtterances }
: {}),
},
include: suggestionInclude,
});
return toSuggestionView(updated);
} catch (err) {
throw err;
}
}
/** Deduplicates while preserving first-seen order — for pooling synonyms/utterances across suggestions. */
function dedupe(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
/** Indents each entry as a Python list literal line (4-space, trailing comma) — the shape `training_data.py`'s blocks use. */
function pythonListBody(entries: string[]): string {
return entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n");
}
/**
* Aggregates the synonyms/utterances of every suggestion matching
* `techStepKey` + `locale` + `status` into a ready-to-paste
* `training_data.py` block. **Read-only** — editing that Python file and
* restarting the intent-service stay a manual maintainer step.
*/
export async function getTrainingDataSnippet(
query: TrainingDataSnippetQuery,
): Promise<TrainingDataSnippetView> {
try {
const rows = await prisma.techStepTrainingSuggestion.findMany({
where: {
locale: query.locale,
status: query.status,
techStep: { key: query.techStepKey },
},
select: { suggestedSynonyms: true, suggestedUtterances: true },
});
const synonyms = dedupe(rows.flatMap((row) => row.suggestedSynonyms));
const utterances = dedupe(rows.flatMap((row) => row.suggestedUtterances));
const snippet = [
`# ${query.techStepKey} (${query.locale}) — ${rows.length} suggestion(s) "${query.status}"`,
`"synonyms": [`,
pythonListBody(synonyms),
`],`,
`"utterances": [`,
pythonListBody(utterances),
`],`,
]
.filter((line) => line.length > 0)
.join("\n");
return {
techStepKey: query.techStepKey,
locale: query.locale,
status: query.status,
suggestionCount: rows.length,
synonyms,
utterances,
snippet,
};
} catch (err) {
throw err;
}
}
/** Process-wide lock — the F1 gate + full backfill is heavy and must never run twice concurrently. */
let retrainInProgress = false;
/**
* Runs the training-corpus regression gate then, if it passes, backfills
* every step and marks the given suggestion ids — the same three steps as
* `scripts/retrain-tech-steps.ts`, callable from the admin UI.
*
* **Only meaningful after** a maintainer has hand-edited
* `services/tech-step-intent-service/intent_service/training_data.py` **and
* restarted that service** (it trains once at boot) — this endpoint can do
* neither, and the admin UI states that prominently.
*
* A failed gate returns `gatePassed: false` with no backfill / no marking
* (HTTP 200 — it's an expected outcome to show the operator, not an error).
*
* @throws {HttpError} `409 RETRAIN_ALREADY_RUNNING` if a retrain is already in flight.
*/
export async function runRetrain(input: RetrainRequestInput): Promise<RetrainResultView> {
if (retrainInProgress) {
throw new HttpError(
409,
ErrorCode.RETRAIN_ALREADY_RUNNING,
"A retrain (F1 gate + backfill) is already running",
);
}
retrainInProgress = true;
try {
const { overall } = await runTechStepEvalSuite();
const gatePassed = overall.f1 >= MIN_OVERALL_F1;
let backfilled: RetrainResultView["backfilled"] = null;
const marked = { applied: 0, rejected: 0 };
if (gatePassed) {
backfilled = await backfillTechSteps();
const appliedIds = input.appliedIds ?? [];
const rejectedIds = input.rejectedIds ?? [];
if (appliedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: appliedIds } },
data: { status: "applied" },
});
marked.applied = count;
}
if (rejectedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: rejectedIds } },
data: { status: "rejected" },
});
marked.rejected = count;
}
}
return {
f1: overall.f1,
precision: overall.precision,
recall: overall.recall,
minF1: MIN_OVERALL_F1,
gatePassed,
backfilled,
marked,
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
} finally {
retrainInProgress = false;
}
}

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,15 +1,32 @@
import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminContactRouter } from "./admin-contact.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
import { adminToursRouter } from "./admin-tours.routes.js";
import { adminUsersRouter } from "./admin-users.routes.js";
/**
* Aggregator for the admin application's API surface, mounted at `/admin`
* in `app.ts`. Every sub-router here is for `apps/admin-web` only —
* `/admin/auth` is public (login), everything added later
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits
* behind `requireAdmin` (`middlewares/require-admin.ts`).
* Aggregator for the admin application's API surface, mounted at
* `/admin/api` in `app.ts` (not bare `/admin` — see that mount point's own
* comment for why: it would collide with apps/web's `/admin/*` page
* routes). Every sub-router here backs one of those pages —
* `/admin/api/auth` is public (login), everything added later
* (`/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();
adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

View file

@ -1,5 +1,10 @@
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 { Router } from "express";
import { env } from "../../config/env.js";
@ -43,7 +48,11 @@ authRouter.post(
const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input);
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 { profile, token } = await login(input);
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

@ -0,0 +1,37 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getCookingPlanForDate } from "./cooking-session.service.js";
/** Router mounted at `/cooking-session` in app.ts. */
export const cookingSessionRouter = Router();
/**
* Returns the authenticated user's household's optimized cooking plan for
* the week covering `?date=` (`YYYY-MM-DD`) — every recipe planned that
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
* Always `200`, never `null` — no household or nothing planned that week
* both come back as a normal `OptimizedCookingPlanView` with empty
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
*/
cookingSessionRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getCookingSessionSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
res.status(200).json(plan);
}),
);

View file

@ -0,0 +1,192 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
type OptimizerRecipeInput,
type OptimizerStepInput,
optimizeCookingPlan,
} from "../../lib/recipe-matching/cooking-optimizer.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/**
* Prisma `include` for a `Planning` query that needs, for every item, its
* recipe's ordered steps with the full detected-technique tree — the raw
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
* as-is; deliberately narrower than a full `RecipeView` fetch (no
* diets/favorites/recipe-level ingredient list — the optimizer reads
* quantities off the technique clauses, not the recipe header).
*/
function cookingSessionPlanningInclude() {
return {
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: {
recipe: {
select: {
id: true,
name: true,
portions: true,
steps: {
orderBy: { order: "asc" },
include: {
techSteps: {
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
temperatures: true,
},
},
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithSteps = Prisma.PlanningGetPayload<{
include: ReturnType<typeof cookingSessionPlanningInclude>;
}>;
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
/**
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
* to the optimizer's pure input shape — ingredient/unit/technique/utensil
* rows resolved to their reference views here so the optimizer itself never
* touches Prisma. `Decimal` quantities become plain numbers (same
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
* unresolved-unit line keeps `unit: null`.
*/
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
stepId: step.id,
order: step.order,
description: step.description,
techSteps: step.techSteps.map((techStep) => {
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
ingredient: toIngredientView(line.ingredient),
quantity: line.quantity === null ? null : Number(line.quantity),
unit: line.unit === null ? null : toUnitView(line.unit),
}));
return {
techStep: {
id: techStep.techStep.id,
key: techStep.techStep.key,
isPlaceholder: techStep.techStep.isPlaceholder,
displayName: techStep.techStep.displayName,
},
order: techStep.order,
ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
// Structured value only — `Decimal` °C → plain number, `qualitative`
// re-narrowed to the literal union (same posture as
// `recipe.service.ts`'s `toStepTechStepViews`).
temperatures: techStep.temperatures.map((temperature) => ({
celsius: temperature.celsius === null ? null : Number(temperature.celsius),
gasMark: temperature.gasMark,
qualitative:
temperature.qualitative === "low" ||
temperature.qualitative === "medium" ||
temperature.qualitative === "high"
? temperature.qualitative
: null,
})),
};
}),
}));
return {
recipeId: item.recipe.id,
name: item.recipe.name,
// The slot's own portion count vs. the recipe's as-written yield — the
// optimizer scales technique-clause quantities by the ratio, same
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
portions: item.portions,
recipePortions: item.recipe.portions,
steps,
};
}
/**
* Builds the household's optimized cooking plan for the week covering
* `date` — every recipe planned that week, reorganized into ordered phases
* that pool shared prep and float passive cooks into the background (see
* `cooking-optimizer.ts`). `date` follows the same convention as
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
* not necessarily a Monday).
*
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
* **never** returns `null` — no household and "no planning covers this week
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
* {@link OptimizedCookingPlanView} (the week's date range is always
* computable from `date` alone).
*/
export async function getCookingPlanForDate(
houseId: number | null,
date: DateTime,
): Promise<OptimizedCookingPlanView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyPlan: OptimizedCookingPlanView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
recipes: [],
phases: [],
};
if (houseId === null) {
return emptyPlan;
}
// Same "covering range" lookup as getShoppingListForDate — see
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: cookingSessionPlanningInclude(),
});
if (!planning) {
return emptyPlan;
}
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
recipes,
phases,
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -3,12 +3,14 @@ import {
auditBatchQuerySchema,
submitTrainingSuggestionsSchema,
workerBatchQuerySchema,
workerHeartbeatSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
import {
getAuditBatch,
getPendingCorrections,
recordWorkerHeartbeat,
submitTrainingSuggestions,
} from "./tech-step-worker.service.js";
@ -47,3 +49,19 @@ techStepWorkerRouter.post(
res.status(201).json(await submitTrainingSuggestions(input));
}),
);
/**
* Liveness ping from the worker (which has no inbound HTTP surface of its
* own) — upserts its `WorkerHeartbeat` row so the admin monitoring board
* can show it as up / stale / down and surface its last job result. Sent
* on boot, on every scheduler tick, and after each job.
*/
techStepWorkerRouter.post(
"/heartbeat",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = workerHeartbeatSchema.parse(req.body);
await recordWorkerHeartbeat(input);
res.status(200).json({ ok: true });
}),
);

View file

@ -4,7 +4,9 @@ import {
type PendingTechStepCorrectionView,
type SubmitTrainingSuggestionsInput,
type TechStepAuditClauseView,
type WorkerHeartbeatInput,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
CONFIDENCE_THRESHOLD,
@ -199,3 +201,44 @@ export async function submitTrainingSuggestions(
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* The one worker with a `WorkerHeartbeat` row today — a fixed key, not
* something the caller supplies (only one worker exists, and letting it
* name itself would just be a spoofing surface behind the same shared
* secret).
*/
const WORKER_KEY = "tech-step-llm-worker";
/**
* Upserts `services/tech-step-llm-worker`'s heartbeat row (see
* `POST /internal/tech-steps/heartbeat`). Every ping bumps `lastSeenAt`; a
* `"job"` ping also records `lastRunAt` + a small `lastResult` summary so
* the admin monitoring board can show what the worker last did and whether
* it worked.
*/
export async function recordWorkerHeartbeat(input: WorkerHeartbeatInput): Promise<void> {
try {
const now = new Date();
const jobResult: Prisma.InputJsonValue | undefined =
input.event === "job"
? { job: input.job ?? null, ok: input.ok ?? null, counts: input.counts ?? {} }
: undefined;
await prisma.workerHeartbeat.upsert({
where: { workerKey: WORKER_KEY },
create: {
workerKey: WORKER_KEY,
lastSeenAt: now,
lastRunAt: input.event === "job" ? now : null,
lastResult: jobResult,
},
update: {
lastSeenAt: now,
...(input.event === "job" ? { lastRunAt: now, lastResult: jobResult } : {}),
},
});
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -0,0 +1,31 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { 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 { HttpError } from "@batch-cooking/error-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 { 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. */
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. */
planningRouter.delete(
"/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 {
type AddPlanningItemInput,
type AssignLeftoverInput,
computeAvailableLeftover,
computeBatchedPortions,
ErrorCode,
type PlanningItemView,
type PlanningView,
@ -10,6 +13,35 @@ import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.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
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
@ -64,13 +96,7 @@ export async function getPlanningForDate(
id: planning.id,
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
items: planning.items.map((item) => ({
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
})),
items: planning.items.map(toPlanningItemView),
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
@ -144,6 +170,18 @@ export async function addPlanningItem(
}
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 planning = await findOrCreatePlanningForWeek(houseId, weekStart);
@ -153,23 +191,22 @@ export async function addPlanningItem(
weekDay: input.weekDay,
meal: input.meal,
recipeId: input.recipeId,
portions: input.portions,
portions,
portionsRequested: input.portionsRequested,
},
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.item_added", {
actorId: viewerId,
context: { recipeId: input.recipeId, portions: input.portions },
context: {
recipeId: input.recipeId,
portions,
portionsRequested: input.portionsRequested,
},
});
return {
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
};
return toPlanningItemView(item);
} catch (err) {
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
}
}
/**
* 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 {
type CompletedToursView,
completeTourSchema,
ErrorCode,
updateAllergiesSchema,
updateDietSchema,
updateDislikedIngredientsSchema,
@ -8,7 +12,9 @@ import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import {
getAllergyIds,
getCompletedTourKeys,
getDislikedIngredientIds,
markTourSeen,
updateAllergies,
updateDiet,
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. */
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). */
profileRouter.patch(
"/diet",
@ -70,3 +84,29 @@ profileRouter.patch(
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 { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { toSafeProfile } from "../../lib/safe-profile.js";
/**
@ -149,3 +150,57 @@ export async function updateDislikedIngredients(
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 {
ErrorCode,
@ -32,22 +33,24 @@ const correctionInclude = {
} satisfies Prisma.StepTechStepCorrectionInclude;
/**
* Loads `stepId`'s current `description` length (the only thing a
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
* if no such step exists, or if it exists but doesn't belong to `recipeId`
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
* request naming a real step under the wrong recipe should look identical
* to naming one that doesn't exist, same "don't leak which part was wrong"
* posture `assertRecipeVisible` already 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.
* Loads `stepId`'s current `description` (a correction needs its full text
* now, not just its length — see {@link submitTechStepCorrection}'s
* `raw` derivation for a manual temperature mention), or throws —
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
* meaningless otherwise — a request naming a real step under the wrong
* recipe should look identical to naming one that doesn't exist, same
* "don't leak which part was wrong" posture `assertRecipeVisible` already
* 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(
recipeId: number,
stepId: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<{ id: number; descriptionLength: number }> {
): Promise<{ id: number; description: string }> {
try {
const step = await prisma.step.findUnique({
where: { id: stepId },
@ -57,7 +60,7 @@ async function loadVisibleStepOrThrow(
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
}
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
return { id: step.id, descriptionLength: step.description.length };
return { id: step.id, description: step.description };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
@ -208,6 +211,21 @@ interface ManualUtensilMention {
start: 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`
@ -233,16 +251,16 @@ interface ManualUtensilMention {
*
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
* this occurrence — `source: "auto"` (the classifier's own detection) and
* any earlier `"manual"` set alike — with the newly-submitted one. This is
* "le manuel remplace tout" (confirmed with the user): the resolved
* `order` this technique ends up at (whichever branch above produced it)
* is the same `techStepOrder` both metadata tables key on, so the same
* `deleteMany` + `createMany` pair below is correct whether this call just
* updated an existing row (which may already carry auto-detected
* metadata) or created a brand new one (nothing to delete yet — a no-op
* `deleteMany`, not a special case).
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
* `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the
* classifier's own detection) and any earlier `"manual"` set alike — with
* the newly-submitted one. This is "le manuel remplace tout" (confirmed
* with the user): the resolved `order` this technique ends up at
* (whichever branch above produced it) is the same `techStepOrder` all
* three metadata tables key on, so the same `deleteMany` + `createMany`
* pair below is correct whether this call just updated an existing row
* (which may already carry auto-detected metadata) or created a brand new
* one (nothing to delete yet — a no-op `deleteMany`, not a special case).
*
* Runs inside the same transaction {@link submitTechStepCorrection} uses
* 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 },
previousTechStepId: number | null,
correctedTechStepId: number | null,
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
metadata?: {
ingredients: ManualIngredientMention[];
utensils: ManualUtensilMention[];
temperatures: ManualTemperatureMention[];
},
): Promise<void> {
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) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
@ -343,10 +381,20 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
start: correction.start,
end: correction.end,
previousTechStep: correction.previousTechStep
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
? {
id: correction.previousTechStep.id,
key: correction.previousTechStep.key,
isPlaceholder: correction.previousTechStep.isPlaceholder,
displayName: correction.previousTechStep.displayName,
}
: null,
correctedTechStep: correction.correctedTechStep
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
? {
id: correction.correctedTechStep.id,
key: correction.correctedTechStep.key,
isPlaceholder: correction.correctedTechStep.isPlaceholder,
displayName: correction.correctedTechStep.displayName,
}
: null,
createdAt: correction.createdAt.toISOString(),
};
@ -366,10 +414,11 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` (the correction's own span, or any of
* `input.ingredients`/`input.utensils`' own spans) fall outside the
* step's current `description` (it may have been edited since the user
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
* `input.ingredients`/`input.utensils`/`input.temperatures`' own spans)
* fall outside the step's current `description` (it may have been
* edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/
* `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND`
* if any referenced id
* doesn't exist.
*/
export async function submitTechStepCorrection(
@ -381,18 +430,20 @@ export async function submitTechStepCorrection(
): Promise<SubmitTechStepCorrectionResult> {
try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
const descriptionLength = step.description.length;
const spans = [
{ start: input.start, end: input.end },
...(input.ingredients ?? []),
...(input.utensils ?? []),
...(input.temperatures ?? []),
];
for (const span of spans) {
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
if (span.start >= descriptionLength || span.end > descriptionLength) {
throw new HttpError(
400,
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,69 +460,109 @@ export async function submitTechStepCorrection(
);
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
const createdCorrection = await tx.stepTechStepCorrection.create({
data: {
stepId: step.id,
correctorId,
start: input.start,
end: input.end,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: input.correctedTechStepId ?? null,
},
include: correctionInclude,
});
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;
await applyManualCorrection(
tx,
step.id,
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
input.ingredients === undefined && input.utensils === undefined
? undefined
: {
ingredients: (input.ingredients ?? []).map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity ?? null,
unitId: ingredient.unitId ?? null,
start: ingredient.start,
end: ingredient.end,
})),
utensils: (input.utensils ?? []).map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
);
// Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s
// `recipeInclude` — `toStepTechStepViews` (reused below) expects it,
// so the fresh sequence read right after a manual correction resolves
// exactly the same way a normal `GET /recipes/:id` would.
const freshTechSteps = await tx.stepTechStep.findMany({
where: { stepId: step.id },
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
const createdCorrection = await tx.stepTechStepCorrection.create({
data: {
stepId: step.id,
correctorId,
start: input.start,
end: input.end,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: effectiveCorrectedTechStepId,
},
utensils: { include: { utensil: true } },
},
});
include: correctionInclude,
});
return { correction: createdCorrection, techSteps: freshTechSteps };
});
await applyManualCorrection(
tx,
step.id,
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
effectiveCorrectedTechStepId,
input.ingredients === undefined &&
input.utensils === undefined &&
input.temperatures === undefined
? undefined
: {
ingredients: (input.ingredients ?? []).map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity ?? null,
unitId: ingredient.unitId ?? null,
start: ingredient.start,
end: ingredient.end,
})),
utensils: (input.utensils ?? []).map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
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,
})),
},
);
// Same nested `ingredients`/`utensils`/`temperatures` include as
// `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews`
// (reused below) expects it, so the fresh sequence read right after a
// manual correction resolves exactly the same way a normal
// `GET /recipes/:id` would.
const freshTechSteps = await tx.stepTechStep.findMany({
where: { stepId: step.id },
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
temperatures: true,
},
});
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId };
},
);
analytics.recordEvent("tech_step.correction_submitted", {
actorId: correctorId,
@ -479,9 +570,20 @@ export async function submitTechStepCorrection(
recipeId,
stepId,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: input.correctedTechStepId ?? null,
correctedTechStepId: correction.correctedTechStepId,
},
});
if (placeholderTechStepId !== null) {
analytics.recordEvent("tech_step.placeholder_proposed", {
actorId: correctorId,
context: {
recipeId,
stepId,
techStepId: placeholderTechStepId,
name: input.correctedTechStepName ?? "",
},
});
}
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) {

View file

@ -1,7 +1,9 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
type AllergyView,
type CreateRecipeInput,
computeRecipeDietViews,
type DietView,
ErrorCode,
type IngredientView,
@ -19,6 +21,7 @@ import {
type TechStepMatch,
techStepClassifier,
} 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
// "fr" translation file, no locale field on User/UserProfile) — steps are
@ -27,7 +30,7 @@ import {
// is a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr";
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */
function recipeInclude(viewerId: number) {
return {
ingredients: {
@ -67,22 +70,43 @@ function recipeInclude(viewerId: number) {
},
},
utensils: { include: { utensil: true } },
temperatures: true,
},
},
},
},
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude;
}
/**
* Every reference `Diet` row (`{ id, key }`), loaded once and memoized —
* `Diet` is seeded reference data that never changes at runtime, so caching
* it for the process's lifetime is safe. Used to attach a real `DietView` to
* the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to
* resolve the planning picker's `dietIds` filter / a household's members'
* regimes for the post-filter in {@link listRecipes}.
*/
let referenceDietsPromise: Promise<DietView[]> | null = null;
function loadReferenceDiets(): Promise<DietView[]> {
if (referenceDietsPromise === null) {
referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } });
}
return referenceDietsPromise;
}
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
async function loadGlutenFreeDiet(): Promise<DietView | undefined> {
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>;
}>;
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>;
/** 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 {
@ -109,13 +133,15 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
// A placeholder line round-trips as a normal `Ingredient` with a real
// id — the frontend tells it apart by this flag (badge, no
// allergen/diet info) and shows `displayName` verbatim instead of
// looking up an i18n label that doesn't exist for a `placeholder:` key.
isPlaceholder: ingredient.isPlaceholder,
displayName: ingredient.displayName,
};
}
function toDietView(diet: { id: number; key: string }): DietView {
return { id: diet.id, key: diet.key };
}
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
const byId = new Map<number, AllergyView>();
@ -127,10 +153,13 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
return [...byId.values()];
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
const allergens = aggregateAllergens(
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */
function toRecipeSummaryView(
recipe: RecipeWithDetails,
glutenFreeDiet: DietView | undefined,
): RecipeSummaryView {
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
toIngredientView(recipeIngredient.ingredient),
);
return {
id: recipe.id,
@ -140,8 +169,9 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
portions: recipe.portions,
authorId: recipe.authorId,
visibility: recipe.visibility,
allergens,
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
allergens: aggregateAllergens(ingredientViews),
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`.
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
isFavorite: recipe.favoritedBy.length > 0,
};
}
@ -168,11 +198,25 @@ export function toStepTechStepViews(
): StepTechStepView[] {
const views: StepTechStepView[] = [];
for (const stepTechStep of techSteps) {
const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
stepTechStep;
const {
start,
end,
contextStart,
contextEnd,
techStep,
source,
ingredients,
utensils,
temperatures,
} = stepTechStep;
if (start === null || end === null) continue;
views.push({
techStep: { id: techStep.id, key: techStep.key },
techStep: {
id: techStep.id,
key: techStep.key,
isPlaceholder: techStep.isPlaceholder,
displayName: techStep.displayName,
},
start,
end,
// `source` is a plain DB `String`, not a Prisma enum (see
@ -199,20 +243,39 @@ export function toStepTechStepViews(
end: stepTechStepUtensil.end,
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
})),
temperatures: temperatures.map((stepTechStepTemperature) => ({
// `celsius` comes back as a Prisma `Decimal` — plain `number` here,
// same treatment as `StepTechStepIngredient.quantity` above.
celsius:
stepTechStepTemperature.celsius === null ? null : Number(stepTechStepTemperature.celsius),
gasMark: stepTechStepTemperature.gasMark,
qualitative:
stepTechStepTemperature.qualitative === "low" ||
stepTechStepTemperature.qualitative === "medium" ||
stepTechStepTemperature.qualitative === "high"
? stepTechStepTemperature.qualitative
: null,
raw: stepTechStepTemperature.raw,
start: stepTechStepTemperature.start,
end: stepTechStepTemperature.end,
// Same narrowing posture as the technique's own `source` above.
source: stepTechStepTemperature.source === "manual" ? "manual" : "auto",
})),
});
}
return views;
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient),
quantity: Number(recipeIngredient.quantity),
unit: toUnitView(recipeIngredient.unit),
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
}));
return {
...toRecipeSummaryView(recipe),
...toRecipeSummaryView(recipe, glutenFreeDiet),
ingredients,
steps: recipe.steps.map((step) => ({
id: step.id,
@ -265,54 +328,57 @@ function visibleToViewerWhere(
}
/**
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
* and, for every member with a declared regime, are tagged with that
* regime — the planning recipe picker's "convient à tout le foyer" toggle
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
* extra query to gather the household's members' allergy/regime ids)
* rather than exposed to the client as raw per-member data: a member's
* allergies/regime are private the same way visibility already keeps a
* recipe's existence private (404, never 403) — nothing here should let
* one member infer another's medical/dietary info from the shape of a
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
* the schema already treats disliked ingredients as a taste preference,
* not a safety constraint (see that model's doc comment), so it doesn't
* belong in a filter framed around what's safe/appropriate to serve.
* The two halves of the planning recipe picker's "convient à tout le foyer"
* toggle (`suitableForHousehold` on `listRecipes`):
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that
* contains an allergen declared by a member (still expressible in SQL).
* - `requiredDietKeys` — every regime declared by a member (`omnivore`
* dropped: it constrains nothing). A recipe must satisfy *all* of them —
* a recipe suiting a vegetarian member doesn't automatically suit a
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a
* stored column to query (issue #131).
*
* Computed server-side (a small extra query for the members' allergy/regime
* data) rather than exposed to the client as raw per-member data: a
* member's allergies/regime are private the same way visibility already
* keeps a recipe's existence private. Deliberately excludes
* `UserProfileDislikedIngredient` — a taste preference, not a safety
* constraint (see that model's doc comment).
*/
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
async function suitableForHousehold(
houseId: number,
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
try {
const members = await prisma.userProfile.findMany({
where: { houseId },
select: { dietId: true, allergies: { select: { allergyId: true } } },
select: {
diet: { select: { key: true } },
allergies: { select: { allergyId: true } },
},
});
const requiredDietIds = [
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
const requiredDietKeys = [
...new Set(
members
.map((member) => member.diet?.key)
.filter((key): key is string => key !== undefined && key !== "omnivore"),
),
];
const excludedAllergyIds = [
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))),
];
const conditions: Prisma.RecipeWhereInput[] = [];
if (requiredDietIds.length > 0) {
// Every diet declared by a member must be among this recipe's tags —
// not "at least one", since a recipe suiting a vegetarian member
// doesn't automatically suit a gluten-free one too.
conditions.push({
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: {
none: {
ingredient: {
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
const allergenWhere: Prisma.RecipeWhereInput =
excludedAllergyIds.length > 0
? {
ingredients: {
none: {
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } },
},
},
},
},
});
}
return { AND: conditions };
}
: {};
return { allergenWhere, requiredDietKeys };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
@ -346,7 +412,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
).map((row) => row.sourceId);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -360,11 +426,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
export interface ListRecipesFilters {
/** Case-insensitive name substring. */
search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[];
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
/** Recipe's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */
dietIds?: number[];
}
@ -384,31 +450,32 @@ export async function listRecipes(
filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
try {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } });
}
// Regimes are computed from ingredients, not a stored column (issue
// #131), so the household-regime and `dietIds` filters can't be SQL
// anymore — they run as post-filters on the mapped summaries below.
// Everything that still *can* be a `where` stays one.
let requiredDietKeys: string[] = [];
// No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing.
if (suitableForHousehold && viewerHouseId !== null) {
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
if (suitable && viewerHouseId !== null) {
const household = await suitableForHousehold(viewerHouseId);
conditions.push(household.allergenWhere);
requiredDietKeys = household.requiredDietKeys;
}
if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of
// them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
// them, not just one.
conditions.push({
AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } },
})),
});
}
if (dietIds && dietIds.length > 0) {
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
switch (tab) {
case "favoris":
@ -428,14 +495,38 @@ export async function listRecipes(
break;
}
const recipes = await prisma.recipe.findMany({
where: { AND: conditions },
include: recipeInclude(viewerId),
orderBy: { name: "asc" },
});
return recipes.map(toRecipeSummaryView);
const [recipes, referenceDiets] = await Promise.all([
prisma.recipe.findMany({
where: { AND: conditions },
include: recipeInclude(viewerId),
orderBy: { name: "asc" },
}),
loadReferenceDiets(),
]);
const glutenFreeDiet = referenceDiets.find((diet) => diet.key === "glutenFree");
let summaries = recipes.map((recipe) => toRecipeSummaryView(recipe, glutenFreeDiet));
// Post-filters on the *computed* `diets` (see the comment where the SQL
// conditions are built). "Every one, not any one" for both.
if (requiredDietKeys.length > 0) {
summaries = summaries.filter((summary) =>
requiredDietKeys.every((key) => summary.diets.some((diet) => diet.key === key)),
);
}
if (dietIds && dietIds.length > 0) {
// `omnivore` in the filter would match nothing (it's never emitted) —
// treat it as "no constraint" rather than silently returning an empty list.
const requiredIds = dietIds.filter(
(id) => referenceDiets.find((diet) => diet.id === id)?.key !== "omnivore",
);
summaries = summaries.filter((summary) =>
requiredIds.every((id) => summary.diets.some((diet) => diet.id === id)),
);
}
return summaries;
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -454,23 +545,23 @@ export async function getRecipe(
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return toRecipeView(recipe);
return toRecipeView(recipe, await loadGlutenFreeDiet());
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
/**
* Creates a recipe with its ingredients, ordered steps and diet tags in one
* go — steps' `order` is derived from their position in `input.steps`,
* Creates a recipe with its ingredients and ordered steps in one go —
* steps' `order` is derived from their position in `input.steps`,
* ingredients reference existing reference `Ingredient` rows by id (see
* `GET /reference/ingredients`; there's no way to create one here).
* `authorId`/`authorHouseId` are fixed at creation and never change on
* later edits (see {@link updateRecipe}).
* `GET /reference/ingredients`; there's no way to create one here). Its
* regimes aren't stored — they're computed from the ingredients on read
* (issue #131). `authorId`/`authorHouseId` are fixed at creation and never
* change on later edits (see {@link updateRecipe}).
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createRecipe(
input: CreateRecipeInput,
@ -480,7 +571,7 @@ export async function createRecipe(
try {
return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -497,7 +588,6 @@ export async function createRecipe(
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createImportedRecipe(
input: CreateRecipeInput,
@ -508,7 +598,7 @@ export async function createImportedRecipe(
try {
return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -542,6 +632,73 @@ async function matchStepsTechSteps<T extends { description: string }>(
);
}
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */
interface ResolvedIngredientLine {
ingredientId: number;
quantity: number | null;
unitId: number | null;
}
/**
* Turns each `input.ingredients` line into a {@link ResolvedIngredientLine}:
* a line that already carries an `ingredientId` (a catalog pick, or an
* existing placeholder round-tripping through an edit) passes through
* unchanged; a `placeholderName` line gets a fresh placeholder `Ingredient`
* row (`isPlaceholder: true`, a generated `placeholder:<uuid>` key, the
* typed text as `displayName`, stamped with `authorId`/now) created via
* `tx` — so it rolls back together with the recipe if anything later in the
* same transaction fails, never leaving an orphan behind.
*
* Returns the created placeholders separately so the caller can emit one
* `ingredient.placeholder_created` analytics event per row *after* the
* transaction commits (the recipe id it wants in the event context doesn't
* exist yet in here).
*/
async function resolveIngredientLines(
lines: CreateRecipeInput["ingredients"],
authorId: number,
tx: Prisma.TransactionClient,
): Promise<{
resolved: ResolvedIngredientLine[];
createdPlaceholders: { id: number; name: string }[];
}> {
try {
const resolved: ResolvedIngredientLine[] = [];
const createdPlaceholders: { id: number; name: string }[] = [];
for (const line of lines) {
if (line.ingredientId !== undefined) {
resolved.push({
ingredientId: line.ingredientId,
quantity: line.quantity ?? null,
unitId: line.unitId ?? null,
});
continue;
}
// `recipeIngredientInputSchema`'s refine guarantees the other branch.
const name = (line.placeholderName ?? "").trim();
const placeholder = await tx.ingredient.create({
data: {
key: `placeholder:${randomUUID()}`,
isPlaceholder: true,
displayName: name,
createdById: authorId,
createdAt: new Date(),
},
select: { id: true },
});
resolved.push({
ingredientId: placeholder.id,
quantity: line.quantity ?? null,
unitId: line.unitId ?? null,
});
createdPlaceholders.push({ id: placeholder.id, name });
}
return { resolved, createdPlaceholders };
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
}
}
async function createRecipeInternal(
input: CreateRecipeInput,
authorId: number,
@ -549,9 +706,12 @@ async function createRecipeInternal(
source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> {
try {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
await assertIngredientsExist(
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
);
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
// Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async
// (a trained model, not a pure regex test), so its result has to
@ -562,85 +722,81 @@ async function createRecipeInternal(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
);
const created = await prisma.recipe.create({
data: {
name: input.name,
description: input.description ?? null,
picture: input.picture ?? null,
portions: input.portions,
// One transaction so the free-text placeholder `Ingredient` rows and the
// recipe that references them commit together — a failed recipe create
// must never leave orphan placeholders behind.
const { created, createdPlaceholders } = await prisma.$transaction(async (tx) => {
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
authorId,
authorHouseId,
visibility: input.visibility,
sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null,
ingredients: {
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
})),
tx,
);
const created = await tx.recipe.create({
data: {
name: input.name,
description: input.description ?? null,
picture: input.picture ?? null,
portions: input.portions,
authorId,
authorHouseId,
visibility: input.visibility,
sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null,
ingredients: {
create: resolved.map((line) => ({
ingredientId: line.ingredientId,
quantity: line.quantity,
unitId: line.unitId,
})),
},
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
// Technique rows + their clause metadata (ingredients /
// utensils / temperatures) — see `toStepTechStepCreateInputs`.
// `updateRecipe` below builds the exact same payload from the
// same helper so a resave never drops what a create persisted.
techSteps: { create: toStepTechStepCreateInputs(matches) },
})),
},
},
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
techSteps: {
create: 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,
})),
},
})),
},
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
include: recipeInclude(authorId),
include: recipeInclude(authorId),
});
return { created, createdPlaceholders };
});
analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", {
actorId: authorId,
context: { recipeId: created.id, sourceId: source?.sourceId ?? null },
});
for (const placeholder of createdPlaceholders) {
analytics.recordEvent("ingredient.placeholder_created", {
actorId: authorId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: created.id },
});
}
return toRecipeView(created);
return toRecipeView(created, await loadGlutenFreeDiet());
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
/**
* Replaces a recipe's whole content — name/description/picture/visibility
* and the complete ingredient/step/diet lists (not a partial merge: a line
* and the complete ingredient/step lists (not a partial merge: a line
* missing from `input` is removed, same contract as `PATCH
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
* never transfers ownership.
* /profile/allergies`). Regimes aren't part of the payload — they're
* recomputed from the ingredients on the next read (issue #131).
* `authorId`/`authorHouseId` are untouched — editing never transfers
* ownership.
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function updateRecipe(
id: number,
@ -650,16 +806,30 @@ export async function updateRecipe(
): Promise<RecipeView> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
await assertIngredientsExist(
input.ingredients
.map((i) => i.ingredientId)
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
);
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
await prisma.$transaction([
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
prisma.step.deleteMany({ where: { recipeId: id } }),
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
prisma.recipe.update({
// Interactive transaction (not the array form) so any new free-text
// placeholder rows are created in the same atomic unit as the
// delete+recreate of the recipe's content. An *existing* placeholder
// line round-trips by its real `ingredientId` and is left untouched;
// only a brand-new `placeholderName` line creates a row here.
const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
viewerId,
tx,
);
await tx.recipe.update({
where: { id },
data: {
name: input.name,
@ -668,10 +838,10 @@ export async function updateRecipe(
portions: input.portions,
visibility: input.visibility,
ingredients: {
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
create: resolved.map((line) => ({
ingredientId: line.ingredientId,
quantity: line.quantity,
unitId: line.unitId,
})),
},
steps: {
@ -679,26 +849,30 @@ export async function updateRecipe(
description: step.description,
picture: step.picture ?? null,
order: index,
techSteps: {
create: matches.map((match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
})),
},
// Same builder as `createRecipeInternal` — a resave must
// persist the clause metadata (ingredients / utensils /
// temperatures), not just the technique span. Omitting it
// here is what left every edited recipe with temperature-less
// `preheat` steps, so `cooking-optimizer.ts` could never spot
// an oven-temperature conflict between two of them.
techSteps: { create: toStepTechStepCreateInputs(matches) },
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
}),
]);
});
return createdPlaceholders;
});
return toRecipeView(await findRecipeOrThrow(id, viewerId));
for (const placeholder of createdPlaceholders) {
analytics.recordEvent("ingredient.placeholder_created", {
actorId: viewerId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: id },
});
}
return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet());
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -731,7 +905,7 @@ export async function deleteRecipe(
await prisma.recipe.delete({ where: { id } });
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -759,7 +933,7 @@ export async function addFavorite(
create: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -770,7 +944,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
where: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -793,7 +967,7 @@ export async function assertRecipeVisible(
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -809,7 +983,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
}
return recipe;
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -828,7 +1002,7 @@ async function assertIsAuthor(
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -850,7 +1024,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}
@ -872,29 +1046,6 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(dietIds)];
if (uniqueIds.length === 0) return;
const found = await prisma.diet.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((diet) => diet.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(
404,
ErrorCode.DIET_NOT_FOUND,
`Diet(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw err; // see suitableForHousehold()'s catch comment above
}
}

View file

@ -6,6 +6,7 @@ import {
getIngredients,
getSources,
getTechSteps,
getTours,
getUnits,
getUtensils,
} from "./reference.service.js";
@ -69,3 +70,14 @@ referenceRouter.get(
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,
SourceView,
TechStepView,
TourScenarioView,
UnitView,
UtensilView,
} from "@batch-cooking/shared";
@ -76,11 +77,21 @@ export async function getUnits(): Promise<UnitView[]> {
/**
* All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
* `TECH_STEPS`).
*
* Excludes placeholder rows (`TechStep.isPlaceholder` — the free-text
* techniques users propose when the catalog falls short, issue #130): this
* is the *browsable* catalog, and a placeholder is a per-correction
* stand-in, not a real entry anyone should pick again.
*/
export async function getTechSteps(): Promise<TechStepView[]> {
try {
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
const techSteps = await prisma.techStep.findMany({
where: { isPlaceholder: false },
select: { id: true, key: true },
orderBy: { key: "asc" },
});
return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null }));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
@ -133,10 +144,16 @@ export async function getSources(): Promise<SourceView[]> {
* and compatible diet regimes (see `IngredientDiet`) — same flattening
* approach as {@link getAllergies}. Ingredients with no linked
* allergen/diet come back with `allergens: []`/`diets: []`.
*
* Excludes placeholder rows (`Ingredient.isPlaceholder` — the free-text
* ingredients users type when the catalog falls short): this is the
* *browsable* catalog, and a placeholder is a per-recipe-line stand-in, not
* a real entry anyone should be able to pick again.
*/
export async function getIngredients(): Promise<IngredientView[]> {
try {
const ingredients = await prisma.ingredient.findMany({
where: { isPlaceholder: false },
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
@ -159,6 +176,38 @@ export async function getIngredients(): Promise<IngredientView[]> {
id: diet.id,
key: diet.key,
})),
// Always a real catalog row here (placeholders are filtered out above).
isPlaceholder: false,
displayName: null,
}));
} catch (err) {
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() {
return {
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: {
recipe: {
select: {
@ -39,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>;
}>;
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
/** 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 {
ingredient: IngredientView;
unit: UnitView;
quantity: number;
unit: UnitView | null;
quantity: number | null;
}
/**
@ -65,16 +70,31 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
for (const item of items) {
const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) {
// A "selon le goût" seasoning line (no quantity/unit) — surfaced once
// per ingredient, never scaled or summed. Its key can't collide with a
// real `(ingredient, unit)` line for the same ingredient.
if (recipeIngredient.quantity === null) {
const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`;
if (!totals.has(seasoningKey)) {
totals.set(seasoningKey, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: null,
quantity: null,
});
}
continue;
}
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key);
if (existing) {
if (existing && existing.quantity !== null) {
existing.quantity += addedQuantity;
} else {
totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: toUnitView(recipeIngredient.unit),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
quantity: addedQuantity,
});
}

View file

@ -180,9 +180,20 @@ export async function previewSourceItem(
),
loadIngredientCatalog(adapter.locale),
loadUnitCatalog(adapter.locale),
prisma.techStep.findMany({ select: { id: true, key: true } }),
// Real catalog techniques only — a draft preview is never a saved
// recipe, so it can't carry a user-proposed placeholder technique
// (issue #130); `isPlaceholder`/`displayName` are the neutral values.
prisma.techStep.findMany({
where: { isPlaceholder: false },
select: { id: true, key: true },
}),
]);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
const techStepById = new Map(
techStepsByKey.map((techStep) => [
techStep.id,
{ ...techStep, isPlaceholder: false, displayName: null },
]),
);
const translatedIngredients = translateRecipeIngredients(
parsed.ingredients,
@ -260,6 +271,12 @@ export async function previewSourceItem(
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
: [];
}),
// Regex-extracted, no id to resolve — carried through as-is
// (same `source: "auto"` reasoning as this match above).
temperatures: match.temperatures.map((mention) => ({
...mention,
source: "auto" as const,
})),
},
]
: [];

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 { prisma } from "../db/prisma.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";
/**
* 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`),
* the same way `updateRecipe` does when a user resaves a recipe through the UI —
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
* no persisted per-recipe locale to recover for a step that already
* exists, so this matches real resave behavior exactly rather than
* guessing).
* the same way `updateRecipe` does when a user resaves a recipe through the
* UI: both build the row payload from the shared
* {@link toStepTechStepCreateInputs}. Always `"fr"`
* (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's no persisted
* 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
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
* step saved before a classifier/corpus change (new vocabulary, or the
* `contextStart`/`contextEnd` columns a previous session added) keeps
* whatever it was matched with at the time until it's next resaved.
* `contextStart`/`contextEnd` columns a previous session added), or before
* 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 —
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
@ -72,18 +76,19 @@ export async function backfillTechSteps(): Promise<{ total: number; changed: num
// into a clean 0..N-1 sequence right after — these just need to
// not collide with `@@id([stepId, order])` for this insert).
const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.createMany({
data: nonOverlappingMatches.map((match, index) => ({
stepId: step.id,
techStepId: match.techStepId,
order: startOrder + index,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
source: "auto",
})),
});
// One `create` per row rather than a single `createMany`:
// `createMany` cannot write the nested `ingredients` / `utensils` /
// `temperatures` children, and silently dropping those is exactly
// the bug this script shared with the old `updateRecipe` — see
// `toStepTechStepCreateInputs`. A backfill that leaves recipes
// without their clause temperatures defeats the whole point of
// running it (`cooking-optimizer.ts` needs them).
const inputs = toStepTechStepCreateInputs(nonOverlappingMatches);
for (const [index, input] of inputs.entries()) {
await tx.stepTechStep.create({
data: { ...input, stepId: step.id, order: startOrder + index, source: "auto" },
});
}
}
await renumberStepTechSteps(tx, step.id);

View file

@ -0,0 +1,27 @@
import { prisma } from "../db/prisma.js";
import { pruneOrphanPlaceholders } from "../modules/admin/admin-catalog.service.js";
/**
* Deletes every placeholder `Ingredient` row (`Ingredient.isPlaceholder`)
* that no recipe references any more — the debris left behind when a recipe
* edit drops a placeholder line (the `RecipeIngredient` row goes, the
* `Ingredient` row stays). The admin catalog view has a button for this
* too; this script is the same operation for a cron / one-off cleanup:
*
* pnpm --filter api exec tsx src/scripts/prune-orphan-placeholders.ts
*
* Delegates to `admin-catalog.service.ts` so the "what counts as an orphan"
* rule lives in exactly one place.
*/
async function main(): Promise<void> {
const { deleted } = await pruneOrphanPlaceholders();
console.info(`Pruned ${deleted} orphan placeholder ingredient(s).`);
}
main()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -1,6 +1,8 @@
import { Server as SocketIoServer } from "socket.io";
import { createServer } from "./app.js";
import { env } from "./config/env.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 { registerAllRecipeSources } from "./sources/index.js";
@ -50,6 +52,17 @@ await warmUpTechStepClassifier();
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, () => {
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 { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js";
import { ohMonBentoAdapter } from "./ohmonbento.js";
import { quitoqueAdapter } from "./quitoque.js";
import { theMealDbAdapter } from "./the-meal-db.js";
/**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry
* points that need the registry populated:
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter`,
* `mangerBougerAdapter`, `quitoqueAdapter` and `ohMonBentoAdapter`. Called
* once, explicitly, by the two real entry points that need the registry
* populated:
*
* - `server.ts` — the running API process, before it starts listening.
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
@ -39,4 +42,6 @@ export function registerAllRecipeSources(): void {
registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter);
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
// plain GET carries the exact same Redux state the client hydrates from as
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
// has everything `list()` needs. Verified live: `?query=<free text>` really
// filters server-side (not just a client-side URL update over an
// already-fetched page), and `page`/`hasMorePages` behave as real,
// consistent pagination — the best-behaved of this adapter family's three
// sources on that front.
// has everything `list()` needs. Verified live for the *search* path
// (`?diet=ALL&page=N&query=<free text>`): `query` really filters
// server-side (not just a client-side URL update over an already-fetched
// page), and `page`/`hasMorePages` behave as real, consistent pagination.
// The *catalogue browse* path (no `query` term) must be the bare
// `?diet=ALL&page=N` — a trailing empty `&query=` made the deployed server
// repeat page 1 for every page (issue #78); `list()` below now omits it.
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/";
@ -42,7 +44,7 @@ interface MangerBougerListPageData {
initialState?: {
recipes?: {
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;
};
};
@ -284,8 +286,18 @@ export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: strin
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
// Only append `&query=` when there's an actual search term. A trailing
// empty `&query=` on the "browse everything" path (filter absent) made
// the *deployed* server serve page 1 for every `page=N` — the
// infinite scroll then stopped after 9 recipes (issue #38's
// "0 new externalIds ⇒ end of list" guard cutting in). The bare
// `?diet=ALL&page=N` catalogue request paginates correctly. The
// sibling adapters (marmiton/quitoque/ohmonbento) already make their
// search param conditional; this one didn't.
const query = (params.query ?? "").trim();
const listUrl = `${LIST_URL}?diet=ALL&page=${page}${
query ? `&query=${encodeURIComponent(query)}` : ""
}`;
let response: Response;
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}`;
}
/** 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
* publisher's own structured JSON, hence `official: true`). The first real
* `RecipeSourceAdapter` implementation, proving the generic contract
* (recipe-source-adapter.ts) end to end against a live source.
*
* `list()` is search-only — TheMealDB has no dedicated "browse everything"
* endpoint on its free tier. An omitted `query` searches for an empty
* string, which TheMealDB happens to answer with a small default sample
* (~25 meals) rather than nothing — close enough to this contract's
* "omitted `query` means browse everything" convention
* (`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`.
* `list()` has two modes (see the helpers above): a non-empty `query` is a
* name search (`/search.php?s=`, single unpaginated response,
* `nextCursor: null`); an empty/omitted `query` — this contract's "browse
* everything" — walks the category list one category per cursor step,
* since the free tier has no real catalog-wide endpoint.
*/
export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
key: SOURCE_KEY,
@ -93,22 +164,8 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/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,
};
const query = (params.query ?? "").trim();
return query.length > 0 ? await searchByName(query) : await listByCategory(params.cursor);
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}

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