fix: corrige les bugs ouverts du repo (import TheMealDB, sidebar mobile) + doc (#59)
* fix(recipes): corrige plusieurs bugs d'import TheMealDB
- Les instructions TheMealDB numérotées sur leur propre ligne ("1\n\ntexte...\n\n2\n\ntexte...") créaient des étapes parasites ne contenant qu'un chiffre — filtrées désormais (#52).
- Un ingrédient compté sans mot d'unité dans le texte source (ex. "4 Egg Yolks") laissait l'import bloqué sur "Importer" indéfiniment, sans indication visuelle de la ligne en cause — matchUnit retombe maintenant sur l'unité générique "piece" quand une quantité a été extraite, et RecipeImportForm/RecipeFormPage surlignent désormais toute ligne dont l'unité manque, avec un message explicite (#53).
- Ajout de INGREDIENT_LABEL_SYNONYMS_EN pour reconnaître des formulations alternatives fréquentes chez les sources anglophones ("vanilla pod" en plus de "vanilla bean") sans élargir INGREDIENT_LABELS_EN à un tableau pour ses ~550 entrées (#54).
- Effet de bord découvert en vérifiant #53 de bout en bout : deux lignes source résolues vers le même ingrédient catalogue (ex. "Egg Yolks"/"Eggs" -> "Œuf") faisaient planter la création en 500 (contrainte unique recipe_id+ingredient_id) au lieu d'un 400 propre. createRecipeSchema rejette maintenant les ingredientId en double, et le formulaire d'import surligne les doublons avant même de soumettre.
Vérifié de bout en bout dans le navigateur (import réel de la recette "Flan" depuis TheMealDB, jusqu'au planning) en plus des tests ajoutés.
Closes #52, #53, #54
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(layout): la sidebar réduite écrasait la barre mobile
`isCollapsed` (rail icône seule sur desktop) persiste dans localStorage
indépendamment de la largeur de fenêtre — un utilisateur ayant réduit la
sidebar sur desktop puis ouvrant la même session sur mobile (ou réduisant
la fenêtre sous 640px) gardait `.app-sidebar.collapsed` (spécificité
0,2,0 : width 4.25rem, flex-direction column), qui l'emportait sur la
règle mobile `@media (max-width: 640px)` (spécificité 0,1,0) censée passer
la sidebar en barre horizontale pleine largeur.
Le bloc `&.collapsed` est maintenant scopé sous `@media (min-width: 641px)`
— le complément exact du breakpoint mobile — donc il ne s'applique plus du
tout en dessous.
Vérifié dans le navigateur : sidebar collapsed=true dans localStorage,
viewport 375px — la sidebar calcule bien width: 375px / flex-direction:
row (barre horizontale pleine largeur) au lieu de 4.25rem/column.
Closes #27
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(readme): documente GET /planning?date=, plus /planning/current
Le README documentait encore `GET /planning/current` (401 sans session,
couvre "aujourd'hui"), une route qui n'existe plus — `planning.routes.ts`
ne définit que `GET /planning?date=YYYY-MM-DD` depuis l'introduction de la
grille de semaine complète. Sans session, `/planning/current` renvoie un
404 générique (route inexistante), pas le 401 documenté.
Documente aussi POST/DELETE /planning/items au passage, absents jusqu'ici.
Closes #55
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: met à jour README et specs/ avec l'état réel du code
Le code avait beaucoup évolué depuis la dernière mise à jour de la
documentation (sources externes, import de recettes, planning en
grille, pages de paramètres, thème, tests Cucumber...) sans que
README.md/specs/*.md ne suivent. Tour complet du code (backend +
frontend) et réécriture :
- specs/batch-cooking-modele.md : schéma de données réécrit depuis
schema.prisma (foyer/admin/invitation, sources, catalogue
ingrédients/unités, techniques détectées, visibilité des recettes).
- specs/backend-architecture.md : foyer, préférences/goûts, planning,
référence, sources externes (adaptateurs/registre/sync), matching
ingrédients/techniques, isolation base de test, suppression de compte.
- specs/frontend-architecture.md : routing complet, sidebar/paramètres,
thème, planning + picker, catalogue + import, composants UI partagés,
tests Cypress+Cucumber.
- specs/batch-cooking-architecture.md : module Import passe de TODO à
implémenté.
- specs/error-handling.md : liste complète des ~19 codes d'erreur.
- README.md : réécriture pour refléter tout ce qui précède, plus la
note (dangereusement obsolète) sur le partage base de test/dev — le
fix existe déjà (apps/api/.env.test), la doc décrivait encore le bug.
* feat(ingredients): ajoute jaune/blanc d'oeuf, coriandre en poudre, viandes hachées
Complète le catalogue d'ingrédients de référence (seed data) :
- jaune d'oeuf / blanc d'oeuf (dairyAndCheese/eggs, aux côtés d'"egg")
- coriandre en poudre (condimentsAndSpices/spices, aux côtés de
corianderSeeds/freshCilantro déjà présents)
- viandes hachées manquantes : veau, porc, agneau (meatAndSeafood/meats,
aux côtés de groundBeef déjà présent), dinde et poulet
(meatAndSeafood/poultry)
Libellés ajoutés dans apps/web/src/locales/fr/translation.json (source
d'affichage) et packages/shared/src/data/catalog-labels-en.ts (matching
anglais pour l'import de recettes depuis des sources comme TheMealDB).
Aucune icône ni régime dédiés : héritent des défauts de leur groupe
(EGG/SPICE/MEAT/POULTRY, mêmes dietUids que leurs groupes respectifs).
282 tests apps/api toujours au vert (resetDatabase() reseed le
catalogue à chaque test).
* fix(i18n): retire le œ ligaturé des libellés français de l'œuf
"Œuf"/"Œufs" (ingrédient, sous-catégorie, allergène) et "Jaune/Blanc
d'œuf" (ajoutés par #60) s'écrivaient avec le œ ligaturé — remplacé par
"oe" (deux lettres) partout où le mot apparaît. Ne touche pas "bœuf"
(mot différent, non concerné).
Le scénario Cucumber recipe-form.feature qui sélectionne l'ingrédient
par son libellé affiché est mis à jour en conséquence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(recipes): concatène les ingrédients dupliqués à l'import
Suite au retour utilisateur sur #53 (follow-up) : au lieu de bloquer
l'import et de demander à l'utilisateur de retirer une ligne en double
à la main, deux lignes source qui résolvent vers le même ingrédient
catalogue sont désormais fusionnées automatiquement, quantité
concaténée (sommée), avant même que l'écran de revue ne s'affiche.
- mergeDuplicateIngredients (recipe-translation.ts) : même unité des
deux côtés -> somme directe. Unité différente mais même UnitType
(MASS/VOLUME) -> conversion via toBaseFactor avant de sommer, exprimée
dans l'unité de la première ligne. UnitType différent, ou COUNT des
deux côtés (une "pincée" n'est pas une fraction fixe d'une "gousse",
cf. le commentaire de UnitView) -> jamais fusionnées, laissées en
double (createRecipeSchema/RecipeImportForm continuent de les
signaler, filet de sécurité déjà en place). Les lignes non résolues
(ingredientId: null) ne sont jamais fusionnées entre elles.
- rawText concaténé ("100g Sugar + 45g Sugar") pour la traçabilité.
- Branché dans previewSourceItem (sources.service.ts), juste après
translateRecipeIngredients — c'est le seul endroit où des doublons
peuvent apparaître (la création manuelle ne peut pas en produire,
IngredientPicker exclut déjà les ingrédients déjà sélectionnés).
Vérifié via l'API en local (import réel de "Flan" depuis TheMealDB) :
"100g Sugar"/"45g Sugar" -> une seule ligne Sucre, 145g.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore(lint): upgrade Biome vers 2.x, active noExplicitAny/noConsole/noFloatingPromises
`@biomejs/biome` passe de 1.9.4 à 2.5.9 (config migrée via `biome migrate
--write`) — nécessaire pour noFloatingPromises, une règle type-aware
apparue en 2.0 (nursery).
- noExplicitAny : déjà "recommended", actif depuis toujours, aucun changement.
- noConsole (biome.json) : bloque tout `console.*` sauf error/warn/info/
debug/table/assert — équivalent à "pas de console.log" sans interdire
les niveaux nommés (voir le nouveau log service dans le prochain commit,
qui centralise justement ces appels).
- noFloatingPromises (nursery) activé explicitement sous `rules.nursery`
sans avoir besoin d'activer le domaine "types" au sens large (ça aurait
aussi allumé des dizaines d'autres règles type-aware type
noUnresolvedImports/noUnnecessaryConditions, hors scope ici).
Le reste du diff, c'est soit du reformatage automatique (import sort, 2.x
ordonne différemment de 1.9.4 — `biome check --write --unsafe`), soit les
corrections des ~20 promesses flottantes que la nouvelle règle a fait
remonter :
- La plupart sont des `navigate(...)` non attendus (react-router v7 type
`navigate` en `void | Promise<void>`) — préfixés `void navigate(...)`,
aucun changement de comportement.
- Trois chargements initiaux en useEffect (OnboardingAllergensPage,
OnboardingDietPage, OnboardingHouseholdPage, HouseholdSettingsPage)
n'avaient jamais de `.catch()` du tout — ajouté (dégradation silencieuse
vers un état vide/par défaut, même raisonnement que le `.catch()` déjà
présent dans OnboardingSourcesPage).
- HouseholdSettingsPage : `loadHouse` était une fonction déclarée à chaque
render (donc une référence différente à chaque fois) utilisée comme
dépendance de useEffect ET passée en callback à des enfants — le
useEffect se re-déclenchait donc à chaque re-render provoqué par son
propre fetch, un vrai bug de boucle infinie de requêtes que
noFloatingPromises a fait remonter indirectement (via
useExhaustiveDependencies). Corrigé avec useCallback([]).
- RecipeDetailPanel : une clé de liste `${index}-...}` sur une liste
statique (draft.steps, sans id stable — DraftRecipeStepView n'en a pas)
— biome-ignore justifié, pas de bug réel.
- recipe.test.ts : variable `agent` non utilisée, retirée.
Vérifié : `pnpm --filter api test` (295/295), `pnpm lint` et `pnpm build`
clean sur tout le repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(api): ajoute un log service pour les logs de fonctionnement côté serveur
Jusqu'ici, rien ne journalisait quoi que ce soit côté serveur : aucune
trace au démarrage à part un console.log ad hoc, et surtout aucune trace
des requêtes ni des erreurs gérées par ErrorHandlerService — un 500 en
production n'aurait laissé aucune trace exploitable.
- LoggerService (apps/api/src/lib/logger.service.ts) — classe (public
debug/info/warn/error, private emit), même convention que
ErrorHandlerService (packages/error-tools) : instance unique partagée
exportée (`export const logger = new LoggerService()`). Émet une ligne
JSON structurée par appel (timestamp/level/message + meta), filtrée par
seuil selon NODE_ENV (debug complet en dev, warn+ pendant les tests
pour ne pas alourdir la sortie de Mocha, info+ en production). Seul
endroit du code autorisé à toucher `console` directement (biome-ignore
justifié), toujours via une méthode nommée — jamais un console.log nu.
- requestLogger (middlewares/request-logger.ts) — une ligne par requête
terminée (méthode/chemin/statut/durée), montée en tout premier dans
app.ts, avant même setupCore (CORS/JSON/cookies), pour englober tout le
pipeline. Niveau déduit du statut (info/warn/error).
- errorLogger (middlewares/error-logger.ts) — monté juste avant
createErrorMiddleware : réutilise errorHandlerService.handle() (pur/
sans effet de bord) pour classifier l'erreur avant que la vraie réponse
ne soit construite, log en warn les 4xx routiniers (validation, 404,
401...) et en error les 5xx/exceptions non prévues (avec la stack).
- error-handler.service.ts : retire le `console.error(error)` ad hoc de
fromUnknownError — errorLogger voit désormais chaque erreur avant que
ce service ne la mappe, donc ce console.error faisait doublon (et
loggait en texte brut, pas en JSON structuré).
- server.ts : le console.log de démarrage passe par logger.info.
Vérifié : pnpm --filter api test (303/303, dont 8 nouveaux tests sur
LoggerService), pnpm lint/build clean, testé en live (pnpm dev:api +
curl) — logs JSON corrects pour un 200, un 404, un 401.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* style: préfixe tous les membres private/protected par _
Convention demandée par l'utilisateur : `emit` -> `_emit`, sur toutes les
classes du repo, pas seulement le nouveau code. `public` reste sans
préfixe.
- LoggerService (apps/api) : _minSeverity, _emit.
- ApiClient (apps/web) : _request (39 sites d'appel mis à jour).
- ErrorHandlerService (packages/error-tools) : _fromZodError,
_fromHttpError, _fromUnknownError.
- ExpressServer (packages/express-tools) : _app, _registeredRoutes.
Aucun changement de comportement — pur renommage interne, aucune méthode
private/protected n'était appelée depuis l'extérieur de sa classe.
Vérifié : pnpm --filter api test (303/303), pnpm lint/build clean sur
tout le repo (apps/api, apps/web, packages/*).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(specs): documente les conventions de développement du repo
Nouveau specs/dev-conventions.md — jusqu'ici ces règles n'existaient que
dans l'historique de commits/PR (classes vs objets littéraux pour la
logique de service, préfixe _ sur private/protected, règles Biome
actives, log service, tests sans mocks de la DB, conventions git/PR...),
rien de centralisé pour un futur contributeur (humain ou Claude Code).
Référencé depuis README.md, section "Qualité / Tests".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(web): regroupe pages/ par section au lieu d'un dossier à plat
pages/ mélangeait 8 fichiers directement à sa racine (LoginPage,
SignupPage, PlanningPage+scss, RecipesPage, RecipeFormPage,
ImportRecipePage, ShoppingListPage, ComingSoonPage+scss) à côté de deux
sous-dossiers déjà groupés (onboarding/, settings/) — incohérent, et
difficile à parcourir une fois le nombre de pages monté. Un sous-dossier
par section routée, même règle que onboarding/settings existants :
- pages/auth/ — LoginPage, SignupPage
- pages/planning/ — PlanningPage + planning-page.scss
- pages/recipes/ — RecipesPage, RecipeFormPage, ImportRecipePage
- pages/shopping-list/ — ShoppingListPage
ComingSoonPage (+ .scss) déménage vers components/ui/ — ce n'est pas une
page routée elle-même (ShoppingListPage l'enveloppe), c'est un composant
UI générique réutilisable, sa place est aux côtés de Dialog/Tooltip/etc.,
pas dans pages/.
Chemins relatifs internes de chaque fichier déplacé mis à jour (un niveau
de profondeur en plus), imports dans App.tsx repointés, tri Biome
réappliqué. specs/frontend-architecture.md mis à jour (arborescence +
références de chemin).
Vérifié : pnpm build clean (apps/web, 1952 modules), pnpm lint clean sur
tout le repo, testé en live dans le navigateur (login/signup, planning,
recettes, nouvelle recette, liste de courses, paramètres) — aucune route
cassée.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(web): regroupe features/recipes/ par sous-domaine au lieu d'un dossier à plat
20 fichiers à plat -> badges/ (DietTagSelect, DietBadges, AllergenBadges,
ReproducibleBadge, FavoriteStarButton), ingredients/ (IngredientPicker,
IngredientRow, ingredient-icons), steps/ (StepListEditor, StepDescription,
highlight-tech-steps), sources/ (RecipeSourcesPanel, SourceItemTable,
RecipeImportForm, recipe-import-draft, useEnabledSources).
RecipeTable/RecipeTabs/RecipeDetailPanel et recipes.scss restent à la
racine (composants transverses aux sous-dossiers, partagés par plusieurs
d'entre eux). Chemins relatifs corrigés dans les fichiers déplacés et chez
tous leurs importeurs externes (pages/recipes/*, features/planning/
RecipePickerDialog.tsx, features/profile/DislikedIngredientsField.tsx),
doc mise à jour (specs/frontend-architecture.md, specs/batch-cooking-
modele.md).
Vérifié : tsc --noEmit, biome check, build complet, 303 tests API,
vérification live navigateur (planning, /recettes, /recettes/nouvelle).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(api): regroupe lib/ par sous-domaine au lieu d'un dossier à plat
9 fichiers à plat -> recipe-sources/ (recipe-source-adapter, recipe-source-
errors, recipe-source-registry) et recipe-matching/ (recipe-translation,
ingredient-matcher, tech-step-matcher). jwt.ts, safe-profile.ts et
logger.service.ts restent à la racine de lib/ (pas de sous-domaine
partagé avec les autres).
Chemins relatifs corrigés dans les fichiers déplacés (profondeur +1 vers
db/) et chez tous leurs importeurs (modules/sources, modules/recipe,
sources/*, db/recipe-source-sync.ts, 12 fichiers de test), doc mise à
jour (specs/backend-architecture.md, specs/batch-cooking-architecture.md).
Vérifié : tsc --noEmit, biome check, build complet, 303 tests API.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(api): regroupe test/ par sous-domaine, miroir de src/lib/
18 fichiers à plat -> recipe-matching/ (ingredient-matcher, recipe-
translation, tech-step-matcher — miroir de lib/recipe-matching/),
recipe-sources/ (json-ld-recipe, recipe-source, recipe-source-sync,
the-meal-db — miroir de lib/recipe-sources/), sources/ (sources,
sources-index — module + registration src/sources/index.ts).
Les tests par domaine API sans regroupement naturel (auth, health,
house, logger.service, planning, preferences, profile, recipe,
reference) restent à la racine de test/, un fichier par domaine — même
logique que jwt.ts/safe-profile.ts restés à la racine de lib/.
Chemins relatifs corrigés (../src/ -> ../../src/, ../test-support/ ->
../../test-support/ dans les fichiers déplacés qui appellent
resetDatabase). .mocharc.json ("test/**/*.test.ts") couvre déjà les
sous-dossiers, aucun changement de config nécessaire.
Vérifié : biome check, 303 tests API.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(convention): impose try/catch autour de chaque await/corps async
Nouvelle règle de dev : aucun await nu, et un corps de fonction/méthode
async doit intégralement vivre dans un try/catch (pas seulement la ou
les lignes qui awaitent). Documentée dans specs/dev-conventions.md avec
son périmètre (code applicatif — services/hooks/composants/middlewares
— routes *.routes.ts exemptées car déjà couvertes par
wrapAsyncHandler ; tests et scripts one-off exemptés aussi).
Appliqué rétroactivement à tout le code applicatif qui ne l'était pas
déjà :
- api : auth/house/profile/preferences/planning/reference/recipe/
sources .service.ts, recipe-source-sync.ts, recipe-translation.ts,
ingredient-matcher.ts, tech-step-matcher.ts, json-ld-recipe.ts,
the-meal-db.ts — un try/catch par fonction async, rethrow simple
(le middleware d'erreur logge déjà tout centralement, voir
error-logger.ts) sauf quand un catch avait déjà une logique propre
(ex. le retry de createHouse).
- web : api/client.ts (_request), AuthContext.tsx, ThemeContext.tsx,
AppLayout.tsx (handleLogout), HouseholdSettingsPage.tsx (handleCopy/
handleRemove/handleDelete/handleLeave) — la plupart des handlers de
formulaire avaient déjà ce pattern, seuls ceux qui laissaient un
await nu ont été corrigés.
lint/complexity/noUselessCatch désactivé dans biome.json (interdisait
justement le catch-qui-rethrow que cette convention impose).
Vérifié : tsc --noEmit (api+web), biome check (0 erreur, repo entier),
build complet, 303 tests API, vérification live navigateur (thème,
déconnexion, copie du code d'invitation).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(web): corrige l'import cassé de highlight-tech-steps.cy.tsx
Oubli lors du regroupement de features/recipes/ par sous-domaine
(refactor(web): regroupe features/recipes/...) : le déplacement de
highlight-tech-steps.ts vers features/recipes/steps/ n'avait pas été
répercuté dans ce test composant Cypress (hors de apps/web/src, donc
raté par la recherche de référence externe à l'époque) — faisait
planter le job e2e en CI ("Failed to fetch dynamically imported
module").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f7d7664397
commit
5d63ff9ea9
108 changed files with 4687 additions and 1713 deletions
396
README.md
396
README.md
|
|
@ -4,13 +4,21 @@
|
|||
|
||||
Monorepo pnpm workspaces :
|
||||
|
||||
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
|
||||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
|
||||
Page de connexion/inscription en place ; le reste est encore un squelette générique.
|
||||
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
|
||||
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
|
||||
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
|
||||
même règles des deux côtés, pas de risque de dérive entre front et back.
|
||||
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
|
||||
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
|
||||
recettes depuis des sources externes, préférences (thème, régime, allergies,
|
||||
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
|
||||
de dev — voir plus bas).
|
||||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
|
||||
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
|
||||
derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
|
||||
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
|
||||
(`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
|
||||
d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
|
||||
[specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
|
||||
du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
|
||||
matching de recettes importées — voir plus bas) — même règles des deux
|
||||
côtés, pas de risque de dérive entre front et back.
|
||||
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
|
||||
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
|
||||
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
|
||||
|
|
@ -19,11 +27,16 @@ Monorepo pnpm workspaces :
|
|||
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
|
||||
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
|
||||
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
|
||||
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
|
||||
"date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
|
||||
calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
|
||||
— utilisés à la fois par `apps/api` (validation de date de planning) et
|
||||
`apps/web` (grille/navigateur de semaine).
|
||||
|
||||
`packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
|
||||
build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
|
||||
TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
|
||||
volée), voir la note dans
|
||||
`packages/shared`, `packages/error-tools`, `packages/express-tools` et
|
||||
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
|
||||
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
|
||||
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
||||
|
||||
## Prérequis
|
||||
|
|
@ -49,6 +62,9 @@ sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fich
|
|||
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
|
||||
tien, voir le commentaire dans `apps/api/.env.example`).
|
||||
|
||||
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
|
||||
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.
|
||||
|
||||
### Cypress : téléchargement du binaire
|
||||
|
||||
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
|
||||
|
|
@ -80,6 +96,10 @@ docker compose up -d postgres
|
|||
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
|
||||
pnpm --filter api exec prisma migrate dev
|
||||
|
||||
# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
|
||||
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
||||
pnpm --filter api prisma:seed
|
||||
|
||||
# Backend (http://localhost:3000)
|
||||
pnpm dev:api
|
||||
|
||||
|
|
@ -104,15 +124,39 @@ pnpm dev:web
|
|||
|
||||
## Qualité / Tests
|
||||
|
||||
Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
|
||||
privés, règles Biome actives, logs côté serveur, etc.) :
|
||||
[specs/dev-conventions.md](specs/dev-conventions.md).
|
||||
|
||||
```bash
|
||||
pnpm lint # Biome (lint + format check)
|
||||
pnpm lint:fix # Biome --write
|
||||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||||
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
|
||||
pnpm build # build de tous les workspaces
|
||||
pnpm lint # Biome (lint + format check)
|
||||
pnpm lint:fix # Biome --write
|
||||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||||
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
|
||||
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
|
||||
pnpm build # build de tous les workspaces
|
||||
```
|
||||
|
||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||||
|
||||
### Base de test isolée de la base de dev (`apps/api`)
|
||||
|
||||
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
|
||||
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
|
||||
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
|
||||
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
|
||||
fichier **à créer toi-même**, pas fourni automatiquement :
|
||||
|
||||
```bash
|
||||
cp apps/api/.env.test.example apps/api/.env.test
|
||||
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
|
||||
# différente (ex. batchcooking_test) — .env.test.example documente les
|
||||
# commandes exactes pour la créer et lui appliquer le schéma.
|
||||
```
|
||||
|
||||
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
||||
seule base qu'il doit rejeter est ta vraie base de dev.
|
||||
|
||||
## Déploiement
|
||||
|
||||
|
|
@ -126,6 +170,16 @@ client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-ser
|
|||
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
|
||||
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
|
||||
|
||||
Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
|
||||
`node` : `prisma migrate deploy` (applique les migrations), puis
|
||||
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
|
||||
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
||||
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
|
||||
`server.ts` ne survit pas au changement de processus, voir
|
||||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
|
||||
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
||||
répéter à chaque redémarrage du conteneur.
|
||||
|
||||
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
|
||||
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
||||
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
|
||||
|
|
@ -166,10 +220,14 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
|
|||
l'email ou le mot de passe qui soit incorrect
|
||||
- `POST /auth/logout` — efface le cookie (204)
|
||||
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
|
||||
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
|
||||
de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
|
||||
départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)
|
||||
|
||||
Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée
|
||||
toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas
|
||||
encore implémenté).
|
||||
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
|
||||
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
|
||||
ne l'incrémente encore — pas de route de changement d'email/mot de passe
|
||||
aujourd'hui, seulement la suppression de compte.
|
||||
|
||||
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
|
||||
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
|
||||
|
|
@ -184,171 +242,178 @@ Les tests (Mocha) tournent avec un coût argon2 réduit
|
|||
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||||
`prisma migrate deploy` avant les tests.
|
||||
|
||||
> **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
|
||||
> Lancer `pnpm test` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
|
||||
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
|
||||
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
|
||||
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
|
||||
## Foyer — création, invitation, admin, sources externes (apps/api)
|
||||
|
||||
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
|
||||
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).
|
||||
|
||||
- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
|
||||
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
|
||||
{ inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
|
||||
si le profil a déjà un foyer.
|
||||
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
|
||||
l'adminship passe au membre restant le plus ancien ; si plus personne ne
|
||||
reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
|
||||
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
|
||||
par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
|
||||
seulement, pas de self-retrait par cette route, utiliser `/leave`).
|
||||
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
|
||||
(voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
|
||||
remplace (pas de fusion), opt-in (aucune source activée par défaut).
|
||||
|
||||
Détail complet (génération du code, transfert d'adminship) :
|
||||
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).
|
||||
|
||||
## Planning (apps/api)
|
||||
|
||||
- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
|
||||
planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
|
||||
dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
|
||||
résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
|
||||
cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
|
||||
erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
|
||||
batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
|
||||
reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
|
||||
- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
|
||||
consommé tel quel par `apps/web`.
|
||||
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
|
||||
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
|
||||
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
|
||||
normaux confondus, jamais une erreur).
|
||||
- `POST /planning/items` — ajoute une recette à un créneau :
|
||||
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
|
||||
indépendamment du rendement propre de la recette (`Recipe.portions`) — un
|
||||
créneau peut mettre à l'échelle.
|
||||
- `DELETE /planning/items/:id` — retire un item du planning.
|
||||
|
||||
Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
|
||||
premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui
|
||||
a mis au jour une contrainte générique trop stricte, corrigée à la source :
|
||||
[specs/backend-architecture.md](specs/backend-architecture.md).
|
||||
Le planning d'une semaine est créé à la demande (première recette ajoutée),
|
||||
jamais en avance.
|
||||
|
||||
## Données de référence — régimes & allergènes (apps/api)
|
||||
## Recettes — catalogue, favoris, import depuis une source externe (apps/api)
|
||||
|
||||
- `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées).
|
||||
- `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }`
|
||||
(le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de
|
||||
nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique
|
||||
`Allergy` sous cette catégorie).
|
||||
- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=`
|
||||
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
|
||||
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
|
||||
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
|
||||
source externe non activée pour le foyer du viewer sont masquées de tous les
|
||||
onglets.
|
||||
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
|
||||
`DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
|
||||
planning), `POST`/`DELETE /recipes/:id/favorite`.
|
||||
- **Import depuis une source externe** (`/sources`) : `GET
|
||||
/sources/:sourceKey/browse` (parcourir), `GET
|
||||
/sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
|
||||
ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
|
||||
/sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
|
||||
création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
|
||||
son ajout au planning (import transparent si tout est résolu) ou d'une revue
|
||||
manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
|
||||
"importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
|
||||
(API officielle, catalogue anglais).
|
||||
|
||||
Les deux sont **publics** (pas de `requireAuth`) : ce sont des données de référence,
|
||||
pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant
|
||||
qu'un compte (donc une session) n'existe.
|
||||
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
|
||||
synchronisation de la table `sources`) :
|
||||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
|
||||
|
||||
Données seedées via `apps/api/prisma/seed.ts` (`pnpm --filter api prisma:seed`, ou
|
||||
automatiquement après `prisma migrate reset` — config `prisma.seed` dans
|
||||
`package.json`). La logique réelle (listes + upsert idempotent) vit dans
|
||||
`src/db/reference-seed-data.ts`, partagée avec `test-support/reset-db.ts` : chaque
|
||||
test repart d'une base **avec** ces données de référence, pas de tables vides —
|
||||
nécessaire pour tester `dietId`/`allergyIds` sur de vraies lignes.
|
||||
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
|
||||
|
||||
`Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc
|
||||
spec d'origine) précisément pour permettre cet upsert idempotent par nom.
|
||||
- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`,
|
||||
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
|
||||
des données de référence, pas des données de foyer, et le wizard
|
||||
d'inscription doit pouvoir les lire avant qu'un compte n'existe.
|
||||
|
||||
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
|
||||
standard, pas inventée.
|
||||
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
|
||||
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
|
||||
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
|
||||
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
|
||||
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
|
||||
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
|
||||
(Gluten/Sulfites). Détail complet du schéma :
|
||||
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).
|
||||
|
||||
**Allergies vs intolérances** (retour fonctionnel, pas dans le doc spec d'origine) :
|
||||
`Category.kind` (`AllergenKind` — `ALLERGY` | `INTOLERANCE`) classe chaque allergène.
|
||||
Seuls `Gluten` et `Sulfites` sont en `INTOLERANCE` (réaction non-immunitaire
|
||||
documentée) ; les 12 autres en `ALLERGY` (réaction immunitaire classique). Classifié
|
||||
par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au
|
||||
lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin
|
||||
exprimé, à revoir si ça devient un problème réel. `GET /reference/allergies` renvoie
|
||||
`kind` dans chaque `AllergyView` ; `PATCH /profile/allergies` ne change pas (une
|
||||
seule liste d'IDs, `kind` ne sert qu'à grouper l'affichage côté client).
|
||||
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)
|
||||
|
||||
## Foyer & profil — nom, régime, allergènes (apps/api)
|
||||
Nécessitent tous une session (`requireAuth`) — données propres à
|
||||
l'utilisateur/au foyer, pas des données de référence.
|
||||
|
||||
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de
|
||||
référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.
|
||||
|
||||
- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie
|
||||
`null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée
|
||||
toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil
|
||||
n'a pas de foyer).
|
||||
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
|
||||
`null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
|
||||
`dietId` ne correspond à aucun régime de référence.
|
||||
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
|
||||
sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
|
||||
l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
|
||||
cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
|
||||
ne correspond à aucun allergène de référence.
|
||||
`null` efface le régime.
|
||||
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
|
||||
d'IDs, remplace (pas de fusion).
|
||||
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
|
||||
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
|
||||
sécurité, juste un rappel discret sur la fiche recette), même contrat de
|
||||
remplacement.
|
||||
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
|
||||
d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
|
||||
`SYSTEM`).
|
||||
|
||||
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
|
||||
(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
|
||||
`require-auth.ts` — `profile.service.ts` le réutilise aussi.
|
||||
(`toSafeProfile`).
|
||||
|
||||
## Page de connexion / inscription (apps/web)
|
||||
|
||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
||||
enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
|
||||
de session httpOnly parte/revienne — l'API et le front sont sur des origines
|
||||
différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
|
||||
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
|
||||
chargement pour restaurer la session depuis le cookie.
|
||||
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
|
||||
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
|
||||
`/` si on l'est déjà.
|
||||
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
|
||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée
|
||||
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
|
||||
pour que le cookie de session httpOnly parte/revienne — l'API et le front
|
||||
sont sur des origines différentes). URL configurable via `VITE_API_URL`
|
||||
(voir `.env.example`).
|
||||
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
|
||||
/auth/me` au chargement pour restaurer la session depuis le cookie ;
|
||||
`deleteAccount()` pour la suppression de compte.
|
||||
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
|
||||
de route (react-router-dom) : l'espace connecté exige d'être connecté,
|
||||
`/login` et `/signup` redirigent vers `/` si on l'est déjà.
|
||||
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
|
||||
schémas zod partagés (`packages/shared`), erreurs API traduites via
|
||||
`ErrorMessageService` (voir ci-dessous).
|
||||
|
||||
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||
|
||||
## Accueil, sidebar & sections (apps/web)
|
||||
## Sidebar, planning, recettes & sections (apps/web)
|
||||
|
||||
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
|
||||
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
|
||||
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout
|
||||
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
|
||||
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
|
||||
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
|
||||
courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
|
||||
composant `ComingSoonPage` — `Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
|
||||
est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
|
||||
parente, pourquoi un composant stub partagé) :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
|
||||
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` —
|
||||
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
|
||||
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
|
||||
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).
|
||||
|
||||
## Parcours profil — foyer, régime, allergènes (apps/web)
|
||||
- **`/` — `PlanningPage`** : grille complète de la semaine (7 jours × 5
|
||||
repas), navigation par semaine avec mini-calendrier, ajout via
|
||||
`RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
|
||||
prévisualiser avant de confirmer, import transparent en un clic si la
|
||||
recette d'une source n'est pas encore résolue automatiquement, sinon revue
|
||||
intégrée dans le même dialogue).
|
||||
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
|
||||
— `RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
|
||||
**plus un onglet par source externe activée pour le foyer**, tableau +
|
||||
panneau de détail (surlignage des techniques détectées avec infobulle, icônes
|
||||
d'ingrédients génériques, badges régime/allergènes/reproductible).
|
||||
`/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
|
||||
création/édition manuelle.
|
||||
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
|
||||
« Calcul batch-cooking » reste `TODO` (voir
|
||||
[specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
|
||||
- **`/parametres/*`** — Compte (identité + suppression), Préférences
|
||||
(régime/allergies/ingrédients détestés), Foyer (création/invitation,
|
||||
membres, sources activées), Préférences utilisateur (thème
|
||||
clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).
|
||||
|
||||
- `src/features/profile/` — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
|
||||
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
|
||||
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
|
||||
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
|
||||
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile. Prend un
|
||||
`legend` en prop (pas un libellé fixe interne) : le même composant est rendu
|
||||
**deux fois** par chaque page consommatrice — une fois pour les allergies
|
||||
(`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
|
||||
(`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
|
||||
`GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
|
||||
liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
|
||||
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
|
||||
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
|
||||
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
|
||||
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
|
||||
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
|
||||
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
|
||||
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
|
||||
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
|
||||
à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
|
||||
chaque section sauvegarde automatiquement peu après la dernière modification —
|
||||
nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
|
||||
pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
|
||||
immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
|
||||
handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
|
||||
observerait la valeur — un tel effect se déclencherait aussi au chargement
|
||||
initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
|
||||
"vient d'être chargé" de "vient d'être modifié par l'utilisateur".
|
||||
Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
|
||||
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||
|
||||
**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
|
||||
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
|
||||
vérification initiale — un `navigate()` explicite dans le gestionnaire de soumission
|
||||
d'un formulaire qu'elle protège (ex. `SignupPage` après `signup()`, qui met `user` à
|
||||
jour) entre alors en course avec le propre `<Navigate>` de la garde. Invisible tant
|
||||
que les deux ciblaient "/", devenu un vrai bug dès que `SignupPage` a dû rediriger
|
||||
ailleurs (`/onboarding/foyer`). Fix : la décision de redirection est verrouillée une
|
||||
seule fois, au moment où `isLoading` passe à `false`, plus jamais réévaluée après.
|
||||
## Parcours d'inscription — onboarding (apps/web)
|
||||
|
||||
**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
|
||||
`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
|
||||
directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
|
||||
valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
|
||||
page fetch son propre profil frais (`apiClient.me()`) au montage, et
|
||||
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
|
||||
régime pour que le reste de l'app reste cohérent aussi.
|
||||
Wizard de 4 écrans lancé une fois juste après l'inscription :
|
||||
`/onboarding/regime` → `/onboarding/foyer` → `/onboarding/sources`
|
||||
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
|
||||
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton
|
||||
"Continuer" qui envoie la valeur courante (y compris "aucune" pour
|
||||
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes
|
||||
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran
|
||||
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
|
||||
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
|
||||
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
|
||||
modification).
|
||||
|
||||
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts`) : mockent l'API via `cy.intercept`
|
||||
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne pas de
|
||||
Postgres/API, seulement le serveur de dev Vite. Le comportement réel de l'API est
|
||||
couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
||||
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
|
||||
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
|
||||
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
|
||||
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
|
||||
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
||||
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
|
||||
|
||||
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
||||
> Chromium/Electron headless plante au lancement du process GPU
|
||||
|
|
@ -363,8 +428,9 @@ couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
|||
## Gestion des erreurs (API ↔ web)
|
||||
|
||||
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
||||
**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
|
||||
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
|
||||
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
|
||||
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
|
||||
— et `ApiErrorResponse`) : l'API renvoie toujours
|
||||
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
||||
quel), et le client traduit `code` en libellé français via **i18next**
|
||||
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
||||
|
|
@ -374,8 +440,8 @@ centralisent la transformation de toute erreur levée en réponse HTTP conforme
|
|||
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
|
||||
compris dans les mocks Cypress).
|
||||
|
||||
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
|
||||
[specs/error-handling.md](specs/error-handling.md).
|
||||
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
|
||||
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).
|
||||
|
||||
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
||||
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
||||
|
|
@ -391,9 +457,21 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
|
|||
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
||||
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
|
||||
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
|
||||
en ajouter une est une question de fichier de locale, pas de code. Détail :
|
||||
en ajouter une est une question de fichier de locale, pas de code. Les
|
||||
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
|
||||
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
|
||||
`schema.prisma`), jamais stockés en base. Détail :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
|
||||
|
||||
## Thème clair / sombre / système (apps/web)
|
||||
|
||||
Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
|
||||
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
|
||||
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
|
||||
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
|
||||
`data-theme` n'est posé (choix "système", le défaut). Détail :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).
|
||||
|
||||
## Données de test (faker.js)
|
||||
|
||||
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
||||
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
|
||||
import { createErrorMiddleware, ExpressServer } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { env } from "./config/env.js";
|
||||
import { errorLogger } from "./middlewares/error-logger.js";
|
||||
import { requestLogger } from "./middlewares/request-logger.js";
|
||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||
import { houseRouter } from "./modules/house/house.routes.js";
|
||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||
|
|
@ -22,6 +24,12 @@ import { sourcesRouter } from "./modules/sources/sources.routes.js";
|
|||
*/
|
||||
export function createServer(): ExpressServer {
|
||||
const server = new ExpressServer();
|
||||
// First middleware registered, before even setupCore's own (CORS/JSON
|
||||
// body parsing/cookies) — it only reads `req`/`res`, so it doesn't need
|
||||
// to run after them, and mounting it first means it wraps the *whole*
|
||||
// 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);
|
||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||
|
||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||
|
|
@ -52,10 +60,12 @@ export function createServer(): ExpressServer {
|
|||
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
|
||||
});
|
||||
|
||||
// Final error-handling middleware: every thrown/`next(err)`-ed error in
|
||||
// the app ends up here. All the "what status/body does this error map
|
||||
// to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
|
||||
// — this stays a thin adapter.
|
||||
// Two error-handling middlewares in a row (Express runs them in
|
||||
// registration order, same as regular middleware) — errorLogger logs the
|
||||
// error, then hands it on (`next(err)`) to the real one: all the "what
|
||||
// status/body does this error map to" logic lives in ErrorHandlerService,
|
||||
// from @batch-cooking/error-tools — this stays a thin adapter.
|
||||
server.setErrorHandler(errorLogger);
|
||||
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
||||
|
||||
return server;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { PrismaClient } from "@prisma/client";
|
||||
import { listRecipeSources } from "../lib/recipe-source-registry.js";
|
||||
import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.js";
|
||||
|
||||
/**
|
||||
* Upserts one `Source` row (schema.prisma) per adapter currently in
|
||||
|
|
@ -25,17 +25,28 @@ import { listRecipeSources } from "../lib/recipe-source-registry.js";
|
|||
* themselves at startup).
|
||||
*/
|
||||
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
||||
for (const adapter of listRecipeSources()) {
|
||||
await prisma.source.upsert({
|
||||
where: { key: adapter.key },
|
||||
update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl },
|
||||
create: {
|
||||
key: adapter.key,
|
||||
name: adapter.name,
|
||||
official: adapter.official,
|
||||
iconUrl: adapter.iconUrl,
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (const adapter of listRecipeSources()) {
|
||||
await prisma.source.upsert({
|
||||
where: { key: adapter.key },
|
||||
update: {
|
||||
name: adapter.name,
|
||||
official: adapter.official,
|
||||
iconUrl: adapter.iconUrl,
|
||||
},
|
||||
create: {
|
||||
key: adapter.key,
|
||||
name: adapter.name,
|
||||
official: adapter.official,
|
||||
iconUrl: adapter.iconUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Rethrown as-is — callers (app startup, `sources.service.ts` via test
|
||||
// setup) already handle/log failures centrally; this function just
|
||||
// isn't allowed a bare `await` per the repo's async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,18 +67,24 @@ export async function findImportedRecipeIds(
|
|||
sourceKey: string,
|
||||
externalIds: string[],
|
||||
): Promise<Map<string, number>> {
|
||||
if (externalIds.length === 0) return new Map();
|
||||
try {
|
||||
if (externalIds.length === 0) return new Map();
|
||||
|
||||
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
|
||||
if (!source) return new Map();
|
||||
const source = await prisma.source.findUnique({
|
||||
where: { key: sourceKey },
|
||||
});
|
||||
if (!source) return new Map();
|
||||
|
||||
const imported = await prisma.recipe.findMany({
|
||||
where: { sourceId: source.id, externalId: { in: externalIds } },
|
||||
select: { id: true, externalId: true },
|
||||
});
|
||||
return new Map(
|
||||
imported.flatMap((recipe) =>
|
||||
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
||||
),
|
||||
);
|
||||
const imported = await prisma.recipe.findMany({
|
||||
where: { sourceId: source.id, externalId: { in: externalIds } },
|
||||
select: { id: true, externalId: true },
|
||||
});
|
||||
return new Map(
|
||||
imported.flatMap((recipe) =>
|
||||
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
throw err; // see syncRecipeSources()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,6 +551,13 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{ uid: "vealCutlet", allergenUids: [] },
|
||||
{ uid: "porkTenderloin", allergenUids: [] },
|
||||
{ uid: "porkChop", allergenUids: [] },
|
||||
// Ground/minced meats, requested alongside `groundBeef` (already
|
||||
// seeded) — one per red-meat type the catalog otherwise only offers
|
||||
// as a whole cut. Ground poultry (turkey/chicken) lives in the
|
||||
// `poultry` subcategory below, next to their whole-cut counterparts.
|
||||
{ uid: "groundVeal", allergenUids: [] },
|
||||
{ uid: "groundPork", allergenUids: [] },
|
||||
{ uid: "groundLamb", allergenUids: [] },
|
||||
{ uid: "lamb", allergenUids: [] },
|
||||
{ uid: "legOfLamb", allergenUids: [] },
|
||||
{ uid: "baconLardons", allergenUids: [] },
|
||||
|
|
@ -601,7 +608,9 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
defaultIcon: "POULTRY",
|
||||
items: [
|
||||
{ uid: "chicken", allergenUids: [] },
|
||||
{ uid: "groundChicken", allergenUids: [] },
|
||||
{ uid: "turkey", allergenUids: [] },
|
||||
{ uid: "groundTurkey", allergenUids: [] },
|
||||
{ uid: "duck", allergenUids: [] },
|
||||
{ uid: "duckBreast", allergenUids: [] },
|
||||
// Ciqual 2025 additions — see the VIANDES group above.
|
||||
|
|
@ -937,7 +946,11 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
subcategory: "eggs",
|
||||
defaultDiets: ["vegetarian", "pescatarian"],
|
||||
defaultIcon: "EGG",
|
||||
items: [{ uid: "egg", allergenUids: ["eggs"] }],
|
||||
items: [
|
||||
{ uid: "egg", allergenUids: ["eggs"] },
|
||||
{ uid: "eggYolk", allergenUids: ["eggs"] },
|
||||
{ uid: "eggWhite", allergenUids: ["eggs"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "dairyAndCheese",
|
||||
|
|
@ -997,6 +1010,7 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{ uid: "fiveSpice", allergenUids: [] },
|
||||
{ uid: "garamMasala", allergenUids: [] },
|
||||
{ uid: "corianderSeeds", allergenUids: [] },
|
||||
{ uid: "groundCoriander", allergenUids: [] },
|
||||
{ uid: "cardamom", allergenUids: [] },
|
||||
{ uid: "fenugreek", allergenUids: [] },
|
||||
{ uid: "jalapeno", allergenUids: [] },
|
||||
|
|
|
|||
102
apps/api/src/lib/logger.service.ts
Normal file
102
apps/api/src/lib/logger.service.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { env } from "../config/env.js";
|
||||
|
||||
/**
|
||||
* Server-side operational logging — the one place in this codebase allowed
|
||||
* to call `console.*` directly (see `biome.json`'s `noConsole`, which bans
|
||||
* bare `console.log` everywhere else: every log line here goes through a
|
||||
* named level instead, `logger.info(...)`/`logger.error(...)`, never an
|
||||
* unlabeled dump of text). Everything else (the request logger, `server.ts`'s
|
||||
* startup line, the error middleware) goes through this instead of touching
|
||||
* `console` itself, so there's exactly one place that decides the log
|
||||
* *shape* (structured JSON lines — one object per line, trivially
|
||||
* grep/parse-able by `docker logs`/Portainer or any log aggregator, unlike
|
||||
* free-form `console.log` text).
|
||||
*
|
||||
* A class, not a plain object literal — same convention as
|
||||
* `ErrorHandlerService` (`packages/error-tools`): `public` methods are the
|
||||
* actual API, `private` ones are internals a caller never touches directly.
|
||||
* `export const logger = new LoggerService()` below is the single shared
|
||||
* instance every caller imports — there's exactly one log stream for the
|
||||
* whole process, nothing to parameterize per call site, so nobody
|
||||
* constructs their own.
|
||||
*/
|
||||
|
||||
/** Ascending severity — mirrors the standard `debug < info < warn < error` convention. */
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
/** Arbitrary structured context attached to a log line (a request id, a duration, an error's own fields, …) — merged into the emitted JSON object, never interpolated into the message string itself. */
|
||||
export type LogMeta = Record<string, unknown>;
|
||||
|
||||
const LEVEL_SEVERITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lines below this level are dropped rather than emitted — keeps `debug`
|
||||
* out of production (verbose, meant for local troubleshooting only) and
|
||||
* out of the test suite's own output (Mocha's reporter is noisy enough
|
||||
* already), while `pnpm dev:api` sees everything. A free function, not a
|
||||
* method — exported (unlike the rest of this module's internals) purely so
|
||||
* `logger.service.test.ts` can exercise the three `NODE_ENV` cases
|
||||
* directly, without needing a `LoggerService` instance at all.
|
||||
*/
|
||||
export function minLevelFor(nodeEnv: typeof env.NODE_ENV): LogLevel {
|
||||
if (nodeEnv === "production") return "info";
|
||||
if (nodeEnv === "test") return "warn";
|
||||
return "debug";
|
||||
}
|
||||
|
||||
/** Console method each level writes through — `error`/`warn` go to stderr (their own native behavior), `debug`/`info` to stdout, the usual split log aggregators expect. */
|
||||
const CONSOLE_METHOD: Record<LogLevel, "debug" | "info" | "warn" | "error"> = {
|
||||
debug: "debug",
|
||||
info: "info",
|
||||
warn: "warn",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
/** Server-side operational logger — see the module doc comment for why this exists and what it's for. */
|
||||
export class LoggerService {
|
||||
/** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */
|
||||
private readonly _minSeverity: number;
|
||||
|
||||
public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) {
|
||||
this._minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)];
|
||||
}
|
||||
|
||||
public debug(message: string, meta?: LogMeta): void {
|
||||
this._emit("debug", message, meta);
|
||||
}
|
||||
|
||||
public info(message: string, meta?: LogMeta): void {
|
||||
this._emit("info", message, meta);
|
||||
}
|
||||
|
||||
public warn(message: string, meta?: LogMeta): void {
|
||||
this._emit("warn", message, meta);
|
||||
}
|
||||
|
||||
public error(message: string, meta?: LogMeta): void {
|
||||
this._emit("error", message, meta);
|
||||
}
|
||||
|
||||
private _emit(level: LogLevel, message: string, meta?: LogMeta): void {
|
||||
if (LEVEL_SEVERITY[level] < this._minSeverity) return;
|
||||
|
||||
// `meta` spread first so a caller accidentally passing e.g. `{ message:
|
||||
// ... }` in it can never shadow the line's own core fields.
|
||||
const line = {
|
||||
...meta,
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
};
|
||||
// biome-ignore lint/suspicious/noConsole: this is the one place allowed to — see the class doc comment above.
|
||||
console[CONSOLE_METHOD[level]](JSON.stringify(line));
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — this service has no per-call-site state to isolate, same reasoning as `errorHandlerService`. */
|
||||
export const logger = new LoggerService();
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { INGREDIENT_LABELS_EN, UNIT_LABELS_EN } from "@batch-cooking/shared";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import {
|
||||
INGREDIENT_LABEL_SYNONYMS_EN,
|
||||
INGREDIENT_LABELS_EN,
|
||||
UNIT_LABELS_EN,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { normalizeText } from "./tech-step-matcher.js";
|
||||
|
||||
/**
|
||||
|
|
@ -99,7 +103,10 @@ export function matchIngredientName(name: string, catalog: IngredientMatchEntry[
|
|||
labelTokens.length > best.tokenCount ||
|
||||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
|
||||
) {
|
||||
best = { ingredientId: entry.ingredientId, tokenCount: labelTokens.length };
|
||||
best = {
|
||||
ingredientId: entry.ingredientId,
|
||||
tokenCount: labelTokens.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best?.ingredientId ?? null;
|
||||
|
|
@ -168,24 +175,43 @@ export function extractQuantity(rawText: string): ExtractedQuantity {
|
|||
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
|
||||
}
|
||||
|
||||
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`); an ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
|
||||
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`), plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
|
||||
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
|
||||
const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } });
|
||||
const catalog: IngredientMatchEntry[] = [];
|
||||
for (const ingredient of ingredients) {
|
||||
const label = INGREDIENT_LABELS_EN[ingredient.key];
|
||||
if (label !== undefined) catalog.push({ ingredientId: ingredient.id, label });
|
||||
try {
|
||||
const ingredients = await prisma.ingredient.findMany({
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const catalog: IngredientMatchEntry[] = [];
|
||||
for (const ingredient of ingredients) {
|
||||
const label = INGREDIENT_LABELS_EN[ingredient.key];
|
||||
if (label === undefined) continue;
|
||||
catalog.push({ ingredientId: ingredient.id, label });
|
||||
for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) {
|
||||
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
||||
}
|
||||
}
|
||||
return catalog;
|
||||
} catch (err) {
|
||||
// Rethrown as-is — the caller (`sources.service.ts`/`recipe-translation.ts`)
|
||||
// already handles/logs failures centrally; this function just isn't
|
||||
// allowed a bare `await` per the repo's async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s — one entry per key with authored English synonyms (see `UNIT_LABELS_EN`); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
|
||||
export async function loadUnitCatalog(): Promise<UnitMatchEntry[]> {
|
||||
const units = await prisma.unit.findMany({ select: { id: true, key: true } });
|
||||
const catalog: UnitMatchEntry[] = [];
|
||||
for (const unit of units) {
|
||||
const synonyms = UNIT_LABELS_EN[unit.key];
|
||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
||||
try {
|
||||
const units = await prisma.unit.findMany({
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const catalog: UnitMatchEntry[] = [];
|
||||
for (const unit of units) {
|
||||
const synonyms = UNIT_LABELS_EN[unit.key];
|
||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
||||
}
|
||||
return catalog;
|
||||
} catch (err) {
|
||||
throw err; // see loadIngredientCatalog()'s catch comment above
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
|
@ -1,21 +1,22 @@
|
|||
import {
|
||||
type IngredientMatchEntry,
|
||||
type UnitMatchEntry,
|
||||
extractQuantity,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
matchIngredientName,
|
||||
matchUnit,
|
||||
} from "./ingredient-matcher.js";
|
||||
import type { UnitType } from "@batch-cooking/shared";
|
||||
import type {
|
||||
ParsedRecipe,
|
||||
ParsedRecipeIngredient,
|
||||
ParsedRecipeStep,
|
||||
} from "./recipe-source-adapter.js";
|
||||
} from "../recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
extractQuantity,
|
||||
type IngredientMatchEntry,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
matchIngredientName,
|
||||
matchUnit,
|
||||
type UnitMatchEntry,
|
||||
} from "./ingredient-matcher.js";
|
||||
import {
|
||||
type TechStepMappingRule,
|
||||
loadTechStepMappingRules,
|
||||
matchTechSteps,
|
||||
type TechStepMappingRule,
|
||||
} from "./tech-step-matcher.js";
|
||||
|
||||
/**
|
||||
|
|
@ -92,6 +93,14 @@ export function translateRecipeSteps(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* English label {@link matchUnit} is fed when a quantity was found but no
|
||||
* unit word was — see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`,
|
||||
* `packages/shared`) is the catalog's generic "counted, no further unit"
|
||||
* entry (French "unité").
|
||||
*/
|
||||
const FALLBACK_COUNT_UNIT_LABEL = "piece";
|
||||
|
||||
/**
|
||||
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
||||
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
|
||||
|
|
@ -101,6 +110,17 @@ export function translateRecipeSteps(
|
|||
* same for `unit` falling back to `extractQuantity`'s `remainder` before
|
||||
* being matched against `unitCatalog` — a source that already states a
|
||||
* clean unit/quantity is trusted over re-deriving it from `rawText`.
|
||||
*
|
||||
* When a quantity was found but nothing in the remaining text matched a
|
||||
* unit (e.g. `"4 Egg Yolks"` — quantity `4`, remainder `"Egg Yolks"`, no
|
||||
* unit word anywhere in it), `unitId` falls back to the catalog's generic
|
||||
* `piece` unit rather than staying `null`: a bare count with no explicit
|
||||
* unit word is overwhelmingly "N of them" (eggs, onions, cloves not
|
||||
* spelled out as "clove") in practice, not a genuinely missing unit — see
|
||||
* issue #53, where this previously left the import review form's submit
|
||||
* button disabled with no indication why on almost any recipe with a
|
||||
* whole-item ingredient. No fallback when `quantity` itself is `null`
|
||||
* (e.g. `"To taste"`) — there's nothing to count, so nothing to default.
|
||||
*/
|
||||
export function translateRecipeIngredients(
|
||||
ingredients: ParsedRecipeIngredient[],
|
||||
|
|
@ -112,11 +132,122 @@ export function translateRecipeIngredients(
|
|||
const extracted = extractQuantity(ingredient.rawText);
|
||||
const quantity = ingredient.quantity ?? extracted.quantity;
|
||||
const unitText = ingredient.unit ?? extracted.remainder;
|
||||
const unitId = matchUnit(unitText, unitCatalog);
|
||||
const unitId =
|
||||
matchUnit(unitText, unitCatalog) ??
|
||||
(quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null);
|
||||
return { ...ingredient, quantity, ingredientId, unitId };
|
||||
});
|
||||
}
|
||||
|
||||
/** What {@link mergeDuplicateIngredients} needs to know about a `Unit` to combine two of them — the `type`/`toBaseFactor` slice of `UnitView` (`packages/shared`). */
|
||||
export interface UnitConversionEntry {
|
||||
id: number;
|
||||
type: UnitType;
|
||||
toBaseFactor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines `a`/`b` — two lines already confirmed to resolve to the same
|
||||
* ingredient — into one, summing their quantities, or returns `null` when
|
||||
* that can't be done safely. `null` (quantity or unit missing on either
|
||||
* side, unit not found in `unitById`, mismatched `UnitType`, or either side
|
||||
* a `COUNT` unit) means "don't merge", not "error" — see
|
||||
* {@link mergeDuplicateIngredients}.
|
||||
*
|
||||
* Same unit on both sides sums directly. Different units of the same
|
||||
* measurable *type* (`MASS`/`VOLUME`) convert `b`'s quantity into `a`'s
|
||||
* unit via `toBaseFactor` first — the groundwork that field's own doc
|
||||
* comment (`UnitView`, `packages/shared`) already anticipated ("a future
|
||||
* conversion feature ... summing '500g' + '0.5kg'"). `COUNT` units are
|
||||
* never converted against each other even when `toBaseFactor` matches — a
|
||||
* "pincée" isn't a fixed fraction of a "gousse" (same doc comment) — so two
|
||||
* different `COUNT` units for the same ingredient are left unmerged.
|
||||
* Rounded to 2 decimal places (`RecipeIngredient.quantity` is
|
||||
* `Decimal(10, 2)`, schema.prisma) to avoid floating-point noise from the
|
||||
* conversion.
|
||||
*/
|
||||
function combineIngredientLines(
|
||||
a: TranslatedRecipeIngredient,
|
||||
b: TranslatedRecipeIngredient,
|
||||
unitById: Map<number, UnitConversionEntry>,
|
||||
): TranslatedRecipeIngredient | null {
|
||||
if (a.quantity === null || b.quantity === null || a.unitId === null || b.unitId === null) {
|
||||
return null;
|
||||
}
|
||||
if (a.unitId === b.unitId) {
|
||||
return {
|
||||
...a,
|
||||
quantity: a.quantity + b.quantity,
|
||||
rawText: `${a.rawText} + ${b.rawText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const unitA = unitById.get(a.unitId);
|
||||
const unitB = unitById.get(b.unitId);
|
||||
if (!unitA || !unitB) return null;
|
||||
if (unitA.type !== unitB.type || unitA.type === "COUNT") return null;
|
||||
|
||||
const combinedInBaseUnit = a.quantity * unitA.toBaseFactor + b.quantity * unitB.toBaseFactor;
|
||||
const quantity = Math.round((combinedInBaseUnit / unitA.toBaseFactor) * 100) / 100;
|
||||
return { ...a, quantity, rawText: `${a.rawText} + ${b.rawText}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds `ingredients` down to one line per resolved `ingredientId`,
|
||||
* concatenating (summing the quantity of) every duplicate into the first
|
||||
* line it matches — see issue #53's follow-up: two raw source lines (e.g.
|
||||
* TheMealDB's "Egg Yolks"/"Eggs", or "100g Sugar" used in two different
|
||||
* steps) can independently resolve to the same catalog `Ingredient`, and
|
||||
* `RecipeIngredient`'s primary key (`recipeId`, `ingredientId`) only
|
||||
* allows one row per ingredient per recipe — the review form used to
|
||||
* either crash on submit (before `createRecipeSchema` rejected it) or
|
||||
* require the person to manually delete every extra line by hand.
|
||||
*
|
||||
* Unresolved lines (`ingredientId: null`) are never merged with one
|
||||
* another or with anything else — nothing reliable to key them on. Two
|
||||
* lines that resolve to the same ingredient but can't be combined safely
|
||||
* (see {@link combineIngredientLines} — mismatched quantity/unit, or
|
||||
* genuinely incompatible units) are left as separate, still-duplicate
|
||||
* lines: `createRecipeSchema` still rejects the result, and
|
||||
* `RecipeImportForm` still highlights them, same safety net as before this
|
||||
* merge step existed — merging never *invents* a number it isn't confident
|
||||
* in.
|
||||
*
|
||||
* Pure — testable with a hand-built `unitCatalog`, no database involved.
|
||||
* Order-preserving: a merged line keeps its first occurrence's position.
|
||||
*/
|
||||
export function mergeDuplicateIngredients(
|
||||
ingredients: TranslatedRecipeIngredient[],
|
||||
unitCatalog: UnitConversionEntry[],
|
||||
): TranslatedRecipeIngredient[] {
|
||||
const unitById = new Map(unitCatalog.map((unit) => [unit.id, unit]));
|
||||
const merged: TranslatedRecipeIngredient[] = [];
|
||||
const mergedIndexByIngredientId = new Map<number, number>();
|
||||
|
||||
for (const line of ingredients) {
|
||||
const existingIndex =
|
||||
line.ingredientId !== null ? mergedIndexByIngredientId.get(line.ingredientId) : undefined;
|
||||
|
||||
const existingLine = existingIndex !== undefined ? merged[existingIndex] : undefined;
|
||||
if (existingIndex === undefined || existingLine === undefined) {
|
||||
if (line.ingredientId !== null) {
|
||||
mergedIndexByIngredientId.set(line.ingredientId, merged.length);
|
||||
}
|
||||
merged.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const combined = combineIngredientLines(existingLine, line, unitById);
|
||||
if (combined === null) {
|
||||
merged.push(line);
|
||||
} else {
|
||||
merged[existingIndex] = combined;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link translateRecipeSteps}/
|
||||
* {@link translateRecipeIngredients} that loads every catalog itself —
|
||||
|
|
@ -147,17 +278,24 @@ export async function translateRecipe(
|
|||
recipe: ParsedRecipe,
|
||||
locale: string,
|
||||
): Promise<TranslatedRecipe> {
|
||||
const techStepMappings = await loadTechStepMappingRules(locale);
|
||||
const translated = translateRecipeSteps(recipe, techStepMappings);
|
||||
try {
|
||||
const techStepMappings = await loadTechStepMappingRules(locale);
|
||||
const translated = translateRecipeSteps(recipe, techStepMappings);
|
||||
|
||||
if (locale !== "en") return translated;
|
||||
if (locale !== "en") return translated;
|
||||
|
||||
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
||||
loadIngredientCatalog(),
|
||||
loadUnitCatalog(),
|
||||
]);
|
||||
return {
|
||||
...translated,
|
||||
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
|
||||
};
|
||||
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
||||
loadIngredientCatalog(),
|
||||
loadUnitCatalog(),
|
||||
]);
|
||||
return {
|
||||
...translated,
|
||||
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
|
||||
};
|
||||
} catch (err) {
|
||||
// Rethrown as-is — the caller (`sources.service.ts`) already
|
||||
// handles/logs failures centrally; this function just isn't allowed a
|
||||
// bare `await` per the repo's async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { prisma } from "../db/prisma.js";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/**
|
||||
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
||||
|
|
@ -124,7 +124,11 @@ export function matchTechStepSpans(
|
|||
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
||||
const match = pattern.exec(normalizedDescription);
|
||||
if (match === null) continue;
|
||||
candidates.push({ ...mapping, start: match.index, end: match.index + match[0].length });
|
||||
candidates.push({
|
||||
...mapping,
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: one best candidate per techStepId.
|
||||
|
|
@ -152,7 +156,11 @@ export function matchTechStepSpans(
|
|||
|
||||
// Step 4: reading order.
|
||||
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
||||
return accepted.map(({ techStepId, start, end }) => ({ techStepId, start, end }));
|
||||
return accepted.map(({ techStepId, start, end }) => ({
|
||||
techStepId,
|
||||
start,
|
||||
end,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -179,8 +187,16 @@ export function matchTechSteps(description: string, mappings: TechStepMappingRul
|
|||
* module.
|
||||
*/
|
||||
export async function loadTechStepMappingRules(locale: string): Promise<TechStepMappingRule[]> {
|
||||
return prisma.techStepMapping.findMany({
|
||||
where: { locale },
|
||||
select: { techStepId: true, expression: true, weight: true },
|
||||
});
|
||||
try {
|
||||
return await prisma.techStepMapping.findMany({
|
||||
where: { locale },
|
||||
select: { techStepId: true, expression: true, weight: true },
|
||||
});
|
||||
} catch (err) {
|
||||
// Rethrown as-is — the caller (`recipe.service.ts`/`sources.service.ts`)
|
||||
// already handles/logs failures centrally; this function just isn't
|
||||
// allowed a bare `async` body without a try/catch per the repo's
|
||||
// convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
40
apps/api/src/middlewares/error-logger.ts
Normal file
40
apps/api/src/middlewares/error-logger.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { logger } from "../lib/logger.service.js";
|
||||
|
||||
/**
|
||||
* Logs every error that reaches Express's error-handling chain, then
|
||||
* passes it straight on (`next(err)`) to the real error-to-response
|
||||
* middleware (`createErrorMiddleware`, `@batch-cooking/express-tools`) —
|
||||
* mounted immediately after this one in `app.ts`. Reuses
|
||||
* `errorHandlerService.handle()` (`@batch-cooking/error-tools`) just to
|
||||
* classify the error for logging purposes (its `status`/`body.code`) —
|
||||
* `.handle()` is pure/stateless, so calling it here and then again in
|
||||
* `createErrorMiddleware` right after is harmless, and it's the one place
|
||||
* that already knows a bare `ZodError` maps to `400 VALIDATION_ERROR`, an
|
||||
* `HttpError` maps to its own `status`/`code`, and anything else is a
|
||||
* `500`. Doesn't build the actual response itself — that's still
|
||||
* `createErrorMiddleware`'s job.
|
||||
*
|
||||
* A resulting `4xx` is routine, expected operation (a validation failure,
|
||||
* a 404, an unauthenticated request) — logged at `warn`, not `error`, so a
|
||||
* genuine `5xx` (an unhandled exception, a bug) stands out instead of
|
||||
* being buried under normal client mistakes.
|
||||
*/
|
||||
export function errorLogger(err: unknown, req: Request, _res: Response, next: NextFunction): void {
|
||||
const meta = { method: req.method, path: req.originalUrl };
|
||||
const { status, body } = errorHandlerService.handle(err);
|
||||
|
||||
if (status >= 500) {
|
||||
logger.error(err instanceof Error ? err.message : body.message, {
|
||||
...meta,
|
||||
status,
|
||||
code: body.code,
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
});
|
||||
} else {
|
||||
logger.warn(body.message, { ...meta, status, code: body.code });
|
||||
}
|
||||
|
||||
next(err);
|
||||
}
|
||||
45
apps/api/src/middlewares/request-logger.ts
Normal file
45
apps/api/src/middlewares/request-logger.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { NextFunction, Request, Response } from "express";
|
||||
import { logger } from "../lib/logger.service.js";
|
||||
|
||||
/**
|
||||
* Logs one line per request once it finishes — method, path, status code,
|
||||
* and duration. Mounted first in `app.ts` (before every route, and before
|
||||
* the error handler) so it wraps the whole request/response cycle,
|
||||
* including requests that end in a 404 or an error response.
|
||||
*
|
||||
* Listens on `res`'s `"finish"` event rather than wrapping `next()` in a
|
||||
* `try`/`finally`: this middleware calls `next()` immediately and returns,
|
||||
* so it never itself sits on the stack waiting for the rest of the
|
||||
* pipeline to resolve — `"finish"` fires once Express has actually flushed
|
||||
* the response, whichever handler (or the error middleware) produced it.
|
||||
*
|
||||
* `4xx`/`5xx` responses log at `warn`/`error` respectively (status alone
|
||||
* decides the level — this middleware has no idea *why* a request failed,
|
||||
* just that it did); everything else logs at `info`. A route's own
|
||||
* handler/the error middleware may log more detail about *why* separately
|
||||
* (see `error-middleware` wiring in `app.ts`) — this line is just the
|
||||
* "a request happened, here's the outcome" operational trace.
|
||||
*/
|
||||
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
|
||||
res.on("finish", () => {
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||
const meta = {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
durationMs: Math.round(durationMs * 100) / 100,
|
||||
};
|
||||
|
||||
if (res.statusCode >= 500) {
|
||||
logger.error("Request completed", meta);
|
||||
} else if (res.statusCode >= 400) {
|
||||
logger.warn("Request completed", meta);
|
||||
} else {
|
||||
logger.info("Request completed", meta);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import type { CookieOptions, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { env } from "../../config/env.js";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { deleteAccount, login, signup } from "./auth.service.js";
|
||||
|
|
|
|||
|
|
@ -35,28 +35,41 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
|||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||
*/
|
||||
export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||
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");
|
||||
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 passwordHash = await argon2.hash(input.password, hashOptions);
|
||||
|
||||
// No household is created here — it's now an optional step of the
|
||||
// onboarding wizard (create or join one, or skip — see
|
||||
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
||||
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
||||
const profile = await prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
const token = signAuthToken({
|
||||
userProfileId: profile.id,
|
||||
tokenVersion: profile.tokenVersion,
|
||||
});
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which already
|
||||
// logs it, see `error-logger.ts`) is what actually handles it, this
|
||||
// service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||
|
||||
// No household is created here — it's now an optional step of the
|
||||
// onboarding wizard (create or join one, or skip — see
|
||||
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
||||
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
||||
const profile = await prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,15 +87,21 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
|
|||
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
||||
*/
|
||||
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
||||
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||
}
|
||||
try {
|
||||
const profile = await prisma.userProfile.findUnique({
|
||||
where: { id: profileId },
|
||||
});
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||
}
|
||||
|
||||
if (profile.houseId !== null) {
|
||||
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||
if (profile.houseId !== null) {
|
||||
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||
}
|
||||
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,12 +112,21 @@ export async function deleteAccount(profileId: number, password: string): Promis
|
|||
* caller can never learn whether a given email has an account.
|
||||
*/
|
||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
try {
|
||||
const profile = await prisma.userProfile.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||
}
|
||||
|
||||
const token = signAuthToken({
|
||||
userProfileId: profile.id,
|
||||
tokenVersion: profile.tokenVersion,
|
||||
});
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
|
||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
createHouseSchema,
|
||||
ErrorCode,
|
||||
joinHouseSchema,
|
||||
renameHouseSchema,
|
||||
updateHouseSourcesSchema,
|
||||
|
|
|
|||
|
|
@ -44,10 +44,18 @@ const houseWithMembers = {
|
|||
|
||||
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
|
||||
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
try {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,16 +66,20 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
|
|||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
*/
|
||||
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
await findHouseOrThrow(houseId);
|
||||
const house = await prisma.house.update({
|
||||
where: { id: houseId },
|
||||
data: { name },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
return toHouseView(house);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
await findHouseOrThrow(houseId);
|
||||
const house = await prisma.house.update({
|
||||
where: { id: houseId },
|
||||
data: { name },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
return toHouseView(house);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,30 +93,45 @@ export async function createHouse(
|
|||
houseId: number | null,
|
||||
name: string,
|
||||
): Promise<HouseView> {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||
}
|
||||
|
||||
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
||||
// rather than assumed — a `@unique` constraint failure is the only fully
|
||||
// reliable way to detect it.
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const house = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.house.create({
|
||||
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
|
||||
});
|
||||
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
|
||||
return created;
|
||||
});
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
||||
throw err;
|
||||
try {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.ALREADY_HAS_HOUSE,
|
||||
"Profile already belongs to a household",
|
||||
);
|
||||
}
|
||||
|
||||
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
||||
// rather than assumed — a `@unique` constraint failure is the only fully
|
||||
// reliable way to detect it.
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const house = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.house.create({
|
||||
data: {
|
||||
name,
|
||||
adminId: profileId,
|
||||
inviteCode: generateInviteCode(),
|
||||
},
|
||||
});
|
||||
await tx.userProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { houseId: created.id },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
return await getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to generate a unique invite code after several attempts");
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
throw new Error("Failed to generate a unique invite code after several attempts");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,21 +145,32 @@ export async function joinHouse(
|
|||
houseId: number | null,
|
||||
inviteCode: string,
|
||||
): Promise<HouseView> {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||
}
|
||||
try {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.ALREADY_HAS_HOUSE,
|
||||
"Profile already belongs to a household",
|
||||
);
|
||||
}
|
||||
|
||||
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
||||
if (!house) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INVITE_CODE_NOT_FOUND,
|
||||
"No household matches this invite code",
|
||||
);
|
||||
}
|
||||
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
||||
if (!house) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INVITE_CODE_NOT_FOUND,
|
||||
"No household matches this invite code",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
await prisma.userProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { houseId: house.id },
|
||||
});
|
||||
return await getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,28 +187,38 @@ export async function joinHouse(
|
|||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
|
||||
*/
|
||||
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { houseId: null },
|
||||
});
|
||||
|
||||
if (house.adminId !== profileId) {
|
||||
return;
|
||||
}
|
||||
if (remainingMembers.length === 0) {
|
||||
await tx.house.delete({ where: { id: house.id } });
|
||||
return;
|
||||
}
|
||||
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
||||
member.id < oldest.id ? member : oldest,
|
||||
);
|
||||
await tx.house.update({
|
||||
where: { id: house.id },
|
||||
data: { adminId: nextAdmin.id },
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
|
||||
|
||||
if (house.adminId !== profileId) {
|
||||
return;
|
||||
}
|
||||
if (remainingMembers.length === 0) {
|
||||
await tx.house.delete({ where: { id: house.id } });
|
||||
return;
|
||||
}
|
||||
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
||||
member.id < oldest.id ? member : oldest,
|
||||
);
|
||||
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -183,21 +231,32 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
|
|||
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
|
||||
*/
|
||||
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
|
||||
}
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
ErrorCode.NOT_HOUSE_ADMIN,
|
||||
"Only the household's admin can delete it",
|
||||
);
|
||||
}
|
||||
|
||||
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
||||
// but clearing it explicitly first keeps the outcome obvious without
|
||||
// relying on that FK behavior being read alongside this function.
|
||||
await prisma.$transaction([
|
||||
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
|
||||
prisma.house.delete({ where: { id: house.id } }),
|
||||
]);
|
||||
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
||||
// but clearing it explicitly first keeps the outcome obvious without
|
||||
// relying on that FK behavior being read alongside this function.
|
||||
await prisma.$transaction([
|
||||
prisma.userProfile.updateMany({
|
||||
where: { houseId: house.id },
|
||||
data: { houseId: null },
|
||||
}),
|
||||
prisma.house.delete({ where: { id: house.id } }),
|
||||
]);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,30 +274,37 @@ export async function removeMember(
|
|||
houseId: number | null,
|
||||
targetMemberId: number,
|
||||
): Promise<HouseView> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
ErrorCode.NOT_HOUSE_ADMIN,
|
||||
"Only the household's admin can remove a member",
|
||||
);
|
||||
}
|
||||
if (targetMemberId === profileId) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"Use POST /house/leave to remove yourself",
|
||||
);
|
||||
}
|
||||
if (!house.members.some((member) => member.id === targetMemberId)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
||||
}
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
ErrorCode.NOT_HOUSE_ADMIN,
|
||||
"Only the household's admin can remove a member",
|
||||
);
|
||||
}
|
||||
if (targetMemberId === profileId) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"Use POST /house/leave to remove yourself",
|
||||
);
|
||||
}
|
||||
if (!house.members.some((member) => member.id === targetMemberId)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
||||
}
|
||||
|
||||
await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } });
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
await prisma.userProfile.update({
|
||||
where: { id: targetMemberId },
|
||||
data: { houseId: null },
|
||||
});
|
||||
return await getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -249,14 +315,18 @@ export async function removeMember(
|
|||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
*/
|
||||
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const rows = await prisma.houseSource.findMany({
|
||||
where: { houseId },
|
||||
select: { sourceId: true },
|
||||
});
|
||||
return rows.map((row) => row.sourceId);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
const rows = await prisma.houseSource.findMany({
|
||||
where: { houseId },
|
||||
select: { sourceId: true },
|
||||
});
|
||||
return rows.map((row) => row.sourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -273,36 +343,46 @@ export async function updateHouseSources(
|
|||
houseId: number | null,
|
||||
sourceIds: number[],
|
||||
): Promise<number[]> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
if (sourceIds.length > 0) {
|
||||
const found = await prisma.source.findMany({
|
||||
where: { id: { in: sourceIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((source) => source.id));
|
||||
const missing = sourceIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Unknown source id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
if (sourceIds.length > 0) {
|
||||
const found = await prisma.source.findMany({
|
||||
where: { id: { in: sourceIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((source) => source.id));
|
||||
const missing = sourceIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Unknown source id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.houseSource.deleteMany({ where: { houseId } }),
|
||||
prisma.houseSource.createMany({
|
||||
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return sourceIds;
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.houseSource.deleteMany({ where: { houseId } }),
|
||||
prisma.houseSource.createMany({ data: sourceIds.map((sourceId) => ({ houseId, sourceId })) }),
|
||||
]);
|
||||
|
||||
return sourceIds;
|
||||
}
|
||||
|
||||
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
||||
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
try {
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
|
||||
|
|
@ -324,12 +404,16 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
|
|||
* `HOUSE_NOT_FOUND` HttpError.
|
||||
*/
|
||||
async function findHouseOrThrow(houseId: number) {
|
||||
const house = await prisma.house.findUnique({
|
||||
where: { id: houseId },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
if (!house) {
|
||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
||||
try {
|
||||
const house = await prisma.house.findUnique({
|
||||
where: { id: houseId },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
if (!house) {
|
||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
||||
}
|
||||
return house;
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
return house;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode, addPlanningItemSchema, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||
import { addPlanningItemSchema, 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";
|
||||
|
|
|
|||
|
|
@ -27,49 +27,57 @@ export async function getPlanningForDate(
|
|||
houseId: number | null,
|
||||
date: DateTime,
|
||||
): Promise<PlanningView | null> {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||
// how Postgres stores/returns them, regardless of the server's local
|
||||
// timezone.
|
||||
const dateOnly = toDateOnly(date).toJSDate();
|
||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||
// how Postgres stores/returns them, regardless of the server's local
|
||||
// timezone.
|
||||
const dateOnly = toDateOnly(date).toJSDate();
|
||||
|
||||
const planning = await prisma.planning.findFirst({
|
||||
where: {
|
||||
houseId,
|
||||
startDate: { lte: dateOnly },
|
||||
finishDate: { gte: dateOnly },
|
||||
},
|
||||
// A household should never have two plannings covering the same day,
|
||||
// but nothing in the schema enforces that yet — pick the most recently
|
||||
// started one rather than letting the query fail if it ever happens.
|
||||
orderBy: { startDate: "desc" },
|
||||
include: {
|
||||
items: {
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
const planning = await prisma.planning.findFirst({
|
||||
where: {
|
||||
houseId,
|
||||
startDate: { lte: dateOnly },
|
||||
finishDate: { gte: dateOnly },
|
||||
},
|
||||
},
|
||||
});
|
||||
// A household should never have two plannings covering the same day,
|
||||
// but nothing in the schema enforces that yet — pick the most recently
|
||||
// started one rather than letting the query fail if it ever happens.
|
||||
orderBy: { startDate: "desc" },
|
||||
include: {
|
||||
items: {
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!planning) {
|
||||
return null;
|
||||
if (!planning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,14 +96,24 @@ export async function getPlanningForDate(
|
|||
* scale rather than adding a migration + retry-on-conflict loop for it.
|
||||
*/
|
||||
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
||||
const startDate = weekStart.toJSDate();
|
||||
const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
|
||||
if (existing) {
|
||||
return existing;
|
||||
try {
|
||||
const startDate = weekStart.toJSDate();
|
||||
const existing = await prisma.planning.findFirst({
|
||||
where: { houseId, startDate },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate,
|
||||
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
return prisma.planning.create({
|
||||
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -119,32 +137,36 @@ export async function addPlanningItem(
|
|||
date: DateTime,
|
||||
input: AddPlanningItemInput,
|
||||
): Promise<PlanningItemView> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
||||
|
||||
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: input.recipeId,
|
||||
portions: input.portions,
|
||||
},
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
weekDay: item.weekDay,
|
||||
meal: item.meal,
|
||||
portions: item.portions,
|
||||
recipe: item.recipe,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
||||
|
||||
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: input.recipeId,
|
||||
portions: input.portions,
|
||||
},
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
weekDay: item.weekDay,
|
||||
meal: item.meal,
|
||||
portions: item.portions,
|
||||
recipe: item.recipe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -156,12 +178,16 @@ export async function addPlanningItem(
|
|||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
|
||||
*/
|
||||
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
|
||||
const item = await prisma.planningItem.findUnique({
|
||||
where: { id },
|
||||
include: { planning: true },
|
||||
});
|
||||
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
||||
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
||||
try {
|
||||
const item = await prisma.planningItem.findUnique({
|
||||
where: { id },
|
||||
include: { planning: true },
|
||||
});
|
||||
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
||||
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
||||
}
|
||||
await prisma.planningItem.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
await prisma.planningItem.delete({ where: { id } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,18 @@ import { prisma } from "../../db/prisma.js";
|
|||
* just to read it.
|
||||
*/
|
||||
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
|
||||
return { theme: preferences?.theme ?? "SYSTEM" };
|
||||
try {
|
||||
const preferences = await prisma.userPreference.findUnique({
|
||||
where: { userProfileId },
|
||||
});
|
||||
return { theme: preferences?.theme ?? "SYSTEM" };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -22,10 +32,14 @@ export async function updatePreferences(
|
|||
userProfileId: number,
|
||||
theme: ThemePreference,
|
||||
): Promise<PreferencesView> {
|
||||
const preferences = await prisma.userPreference.upsert({
|
||||
where: { userProfileId },
|
||||
create: { userProfileId, theme },
|
||||
update: { theme },
|
||||
});
|
||||
return { theme: preferences.theme };
|
||||
try {
|
||||
const preferences = await prisma.userPreference.upsert({
|
||||
where: { userProfileId },
|
||||
create: { userProfileId, theme },
|
||||
update: { theme },
|
||||
});
|
||||
return { theme: preferences.theme };
|
||||
} catch (err) {
|
||||
throw err; // see getPreferences()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,27 +14,39 @@ export async function updateDiet(
|
|||
userProfileId: number,
|
||||
dietId: number | null,
|
||||
): Promise<SafeUserProfile> {
|
||||
if (dietId !== null) {
|
||||
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
||||
if (!diet) {
|
||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
||||
try {
|
||||
if (dietId !== null) {
|
||||
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
||||
if (!diet) {
|
||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profile = await prisma.userProfile.update({
|
||||
where: { id: userProfileId },
|
||||
data: { dietId },
|
||||
});
|
||||
return toSafeProfile(profile);
|
||||
const profile = await prisma.userProfile.update({
|
||||
where: { id: userProfileId },
|
||||
data: { dietId },
|
||||
});
|
||||
return toSafeProfile(profile);
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
|
||||
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
|
||||
const rows = await prisma.userProfileAllergy.findMany({
|
||||
where: { userProfileId },
|
||||
select: { allergyId: true },
|
||||
});
|
||||
return rows.map((row) => row.allergyId);
|
||||
try {
|
||||
const rows = await prisma.userProfileAllergy.findMany({
|
||||
where: { userProfileId },
|
||||
select: { allergyId: true },
|
||||
});
|
||||
return rows.map((row) => row.allergyId);
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,39 +61,47 @@ export async function updateAllergies(
|
|||
userProfileId: number,
|
||||
allergyIds: number[],
|
||||
): Promise<number[]> {
|
||||
if (allergyIds.length > 0) {
|
||||
const found = await prisma.allergy.findMany({
|
||||
where: { id: { in: allergyIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((allergy) => allergy.id));
|
||||
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.ALLERGY_NOT_FOUND,
|
||||
`Unknown allergy id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
try {
|
||||
if (allergyIds.length > 0) {
|
||||
const found = await prisma.allergy.findMany({
|
||||
where: { id: { in: allergyIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((allergy) => allergy.id));
|
||||
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.ALLERGY_NOT_FOUND,
|
||||
`Unknown allergy id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
|
||||
prisma.userProfileAllergy.createMany({
|
||||
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return allergyIds;
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
|
||||
prisma.userProfileAllergy.createMany({
|
||||
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return allergyIds;
|
||||
}
|
||||
|
||||
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
|
||||
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
|
||||
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
||||
where: { userProfileId },
|
||||
select: { ingredientId: true },
|
||||
});
|
||||
return rows.map((row) => row.ingredientId);
|
||||
try {
|
||||
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
||||
where: { userProfileId },
|
||||
select: { ingredientId: true },
|
||||
});
|
||||
return rows.map((row) => row.ingredientId);
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -95,28 +115,37 @@ export async function updateDislikedIngredients(
|
|||
userProfileId: number,
|
||||
dislikedIngredientIds: number[],
|
||||
): Promise<number[]> {
|
||||
if (dislikedIngredientIds.length > 0) {
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: dislikedIngredientIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
try {
|
||||
if (dislikedIngredientIds.length > 0) {
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: dislikedIngredientIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileDislikedIngredient.deleteMany({
|
||||
where: { userProfileId },
|
||||
}),
|
||||
prisma.userProfileDislikedIngredient.createMany({
|
||||
data: dislikedIngredientIds.map((ingredientId) => ({
|
||||
userProfileId,
|
||||
ingredientId,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
|
||||
return dislikedIngredientIds;
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
|
||||
prisma.userProfileDislikedIngredient.createMany({
|
||||
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return dislikedIngredientIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
createRecipeSchema,
|
||||
ErrorCode,
|
||||
listRecipesSchema,
|
||||
updateRecipeSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import {
|
|||
} from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
|
||||
import {
|
||||
loadTechStepMappingRules,
|
||||
matchTechStepSpans,
|
||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||
|
||||
// No user-language preference exists anywhere in the app yet (a single
|
||||
// "fr" translation file, no locale field on User/UserProfile) — steps are
|
||||
|
|
@ -30,7 +33,9 @@ function recipeInclude(viewerId: number) {
|
|||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
allergies: {
|
||||
include: { allergy: { include: { category: true } } },
|
||||
},
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
|
|
@ -39,20 +44,29 @@ function recipeInclude(viewerId: number) {
|
|||
},
|
||||
steps: {
|
||||
orderBy: { order: "asc" },
|
||||
include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } },
|
||||
include: {
|
||||
techSteps: { orderBy: { order: "asc" }, include: { techStep: true } },
|
||||
},
|
||||
},
|
||||
diets: { include: { diet: true } },
|
||||
favoritedBy: { where: { userProfileId: viewerId } },
|
||||
} satisfies Prisma.RecipeInclude;
|
||||
}
|
||||
|
||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||
include: ReturnType<typeof recipeInclude>;
|
||||
}>;
|
||||
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||
|
||||
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */
|
||||
function toUnitView(unit: UnitWithDetails): UnitView {
|
||||
return { id: unit.id, key: unit.key, type: unit.type, toBaseFactor: Number(unit.toBaseFactor) };
|
||||
return {
|
||||
id: unit.id,
|
||||
key: unit.key,
|
||||
type: unit.type,
|
||||
toBaseFactor: Number(unit.toBaseFactor),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
||||
|
|
@ -121,7 +135,10 @@ function toStepTechStepViews(
|
|||
for (const stepTechStep of techSteps) {
|
||||
if (stepTechStep.start === null || stepTechStep.end === null) continue;
|
||||
views.push({
|
||||
techStep: { id: stepTechStep.techStep.id, key: stepTechStep.techStep.key },
|
||||
techStep: {
|
||||
id: stepTechStep.techStep.id,
|
||||
key: stepTechStep.techStep.key,
|
||||
},
|
||||
start: stepTechStep.start,
|
||||
end: stepTechStep.end,
|
||||
});
|
||||
|
|
@ -157,7 +174,11 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
|||
* `RecipeVisibility` in schema.prisma.
|
||||
*/
|
||||
function canView(
|
||||
recipe: { authorId: number; authorHouseId: number | null; visibility: string },
|
||||
recipe: {
|
||||
authorId: number;
|
||||
authorHouseId: number | null;
|
||||
visibility: string;
|
||||
},
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): boolean {
|
||||
|
|
@ -201,32 +222,46 @@ function visibleToViewerWhere(
|
|||
* belong in a filter framed around what's safe/appropriate to serve.
|
||||
*/
|
||||
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
|
||||
const members = await prisma.userProfile.findMany({
|
||||
where: { houseId },
|
||||
select: { dietId: true, allergies: { select: { allergyId: true } } },
|
||||
});
|
||||
const requiredDietIds = [
|
||||
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
|
||||
];
|
||||
const excludedAllergyIds = [
|
||||
...new Set(members.flatMap((m) => m.allergies.map((a) => a.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 } } } } },
|
||||
},
|
||||
try {
|
||||
const members = await prisma.userProfile.findMany({
|
||||
where: { houseId },
|
||||
select: { dietId: true, allergies: { select: { allergyId: true } } },
|
||||
});
|
||||
const requiredDietIds = [
|
||||
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
|
||||
];
|
||||
const excludedAllergyIds = [
|
||||
...new Set(members.flatMap((m) => m.allergies.map((a) => a.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 } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return { AND: conditions };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await`/`async` body
|
||||
// without a try/catch per the repo's convention.
|
||||
throw err;
|
||||
}
|
||||
return { AND: conditions };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -241,13 +276,20 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
|
|||
* is hidden for them until they join or create one and configure it.
|
||||
*/
|
||||
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
|
||||
const enabledSourceIds =
|
||||
houseId === null
|
||||
? []
|
||||
: (await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true } })).map(
|
||||
(row) => row.sourceId,
|
||||
);
|
||||
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
||||
try {
|
||||
const enabledSourceIds =
|
||||
houseId === null
|
||||
? []
|
||||
: (
|
||||
await prisma.houseSource.findMany({
|
||||
where: { houseId },
|
||||
select: { sourceId: true },
|
||||
})
|
||||
).map((row) => row.sourceId);
|
||||
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -283,52 +325,60 @@ export async function listRecipes(
|
|||
tab: RecipeTab,
|
||||
filters: ListRecipesFilters = {},
|
||||
): Promise<RecipeSummaryView[]> {
|
||||
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||
if (search) {
|
||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||
}
|
||||
// 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 (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.
|
||||
conditions.push({
|
||||
AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })),
|
||||
try {
|
||||
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||
if (search) {
|
||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||
}
|
||||
// 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 (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.
|
||||
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":
|
||||
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
|
||||
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
|
||||
break;
|
||||
case "perso":
|
||||
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
|
||||
break;
|
||||
case "foyer":
|
||||
// No household — nothing can carry this viewer's authorHouseId.
|
||||
if (viewerHouseId === null) return [];
|
||||
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
|
||||
break;
|
||||
case "publique":
|
||||
conditions.push({ visibility: "PUBLIC" });
|
||||
break;
|
||||
}
|
||||
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
where: { AND: conditions },
|
||||
include: recipeInclude(viewerId),
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return recipes.map(toRecipeSummaryView);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
if (dietIds && dietIds.length > 0) {
|
||||
conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
|
||||
}
|
||||
|
||||
switch (tab) {
|
||||
case "favoris":
|
||||
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
|
||||
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
|
||||
break;
|
||||
case "perso":
|
||||
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
|
||||
break;
|
||||
case "foyer":
|
||||
// No household — nothing can carry this viewer's authorHouseId.
|
||||
if (viewerHouseId === null) return [];
|
||||
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
|
||||
break;
|
||||
case "publique":
|
||||
conditions.push({ visibility: "PUBLIC" });
|
||||
break;
|
||||
}
|
||||
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
where: { AND: conditions },
|
||||
include: recipeInclude(viewerId),
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return recipes.map(toRecipeSummaryView);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -341,11 +391,15 @@ export async function getRecipe(
|
|||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
return toRecipeView(recipe);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
return toRecipeView(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -365,7 +419,11 @@ export async function createRecipe(
|
|||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
return createRecipeInternal(input, authorId, authorHouseId, null);
|
||||
try {
|
||||
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -389,7 +447,11 @@ export async function createImportedRecipe(
|
|||
authorHouseId: number | null,
|
||||
source: { sourceId: number; externalId: string; locale: string },
|
||||
): Promise<RecipeView> {
|
||||
return createRecipeInternal(input, authorId, authorHouseId, source);
|
||||
try {
|
||||
return await createRecipeInternal(input, authorId, authorHouseId, source);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecipeInternal(
|
||||
|
|
@ -398,90 +460,25 @@ async function createRecipeInternal(
|
|||
authorHouseId: number | null,
|
||||
source: { sourceId: number; externalId: string; locale: string } | null,
|
||||
): Promise<RecipeView> {
|
||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||
await assertDietsExist(input.dietIds);
|
||||
const techStepMappings = await loadTechStepMappingRules(
|
||||
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
||||
);
|
||||
try {
|
||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||
await assertDietsExist(input.dietIds);
|
||||
const techStepMappings = await loadTechStepMappingRules(
|
||||
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,
|
||||
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,
|
||||
})),
|
||||
},
|
||||
steps: {
|
||||
create: input.steps.map((step, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({
|
||||
techStepId: match.techStepId,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
order,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
include: recipeInclude(authorId),
|
||||
});
|
||||
return toRecipeView(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||
* and the complete ingredient/step/diet 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.
|
||||
*
|
||||
* @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,
|
||||
input: UpdateRecipeInput,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
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);
|
||||
const techStepMappings = await loadTechStepMappingRules(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({
|
||||
where: { id },
|
||||
const created = await prisma.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: input.ingredients.map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
|
|
@ -508,10 +505,85 @@ export async function updateRecipe(
|
|||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
include: recipeInclude(authorId),
|
||||
});
|
||||
return toRecipeView(created);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||
/**
|
||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||
* and the complete ingredient/step/diet 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.
|
||||
*
|
||||
* @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,
|
||||
input: UpdateRecipeInput,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): 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);
|
||||
const techStepMappings = await loadTechStepMappingRules(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({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
picture: input.picture ?? null,
|
||||
portions: input.portions,
|
||||
visibility: input.visibility,
|
||||
ingredients: {
|
||||
create: input.ingredients.map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity,
|
||||
unitId: ingredient.unitId,
|
||||
})),
|
||||
},
|
||||
steps: {
|
||||
create: input.steps.map((step, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: matchTechStepSpans(step.description, techStepMappings).map(
|
||||
(match, order) => ({
|
||||
techStepId: match.techStepId,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
order,
|
||||
}),
|
||||
),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -527,18 +599,24 @@ export async function deleteRecipe(
|
|||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||
try {
|
||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||
|
||||
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
|
||||
if (usedInPlanning) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.RECIPE_IN_USE,
|
||||
"Recipe is still used by at least one planning item",
|
||||
);
|
||||
const usedInPlanning = await prisma.planningItem.findFirst({
|
||||
where: { recipeId: id },
|
||||
});
|
||||
if (usedInPlanning) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.RECIPE_IN_USE,
|
||||
"Recipe is still used by at least one planning item",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.recipe.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
|
||||
await prisma.recipe.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -552,20 +630,32 @@ export async function addFavorite(
|
|||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
await prisma.recipeFavorite.upsert({
|
||||
where: {
|
||||
userProfileId_recipeId: { userProfileId: viewerId, recipeId: id },
|
||||
},
|
||||
update: {},
|
||||
create: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
await prisma.recipeFavorite.upsert({
|
||||
where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id } },
|
||||
update: {},
|
||||
create: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
}
|
||||
|
||||
/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
|
||||
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
|
||||
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
|
||||
try {
|
||||
await prisma.recipeFavorite.deleteMany({
|
||||
where: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -581,22 +671,30 @@ export async function assertRecipeVisible(
|
|||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
|
||||
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
include: recipeInclude(viewerId),
|
||||
});
|
||||
if (!recipe) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
try {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
include: recipeInclude(viewerId),
|
||||
});
|
||||
if (!recipe) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
return recipe;
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
return recipe;
|
||||
}
|
||||
|
||||
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
|
||||
|
|
@ -605,58 +703,82 @@ async function assertIsAuthor(
|
|||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
if (recipe.authorId !== viewerId) {
|
||||
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
if (recipe.authorId !== viewerId) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
|
||||
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||
const uniqueIds = [...new Set(ingredientIds)];
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Ingredient(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
try {
|
||||
const uniqueIds = [...new Set(ingredientIds)];
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Ingredient(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
|
||||
async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
||||
const uniqueIds = [...new Set(unitIds)];
|
||||
const found = await prisma.unit.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((unit) => unit.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(404, ErrorCode.UNIT_NOT_FOUND, `Unit(s) not found: ${missing.join(", ")}`);
|
||||
try {
|
||||
const uniqueIds = [...new Set(unitIds)];
|
||||
const found = await prisma.unit.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((unit) => unit.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.UNIT_NOT_FOUND,
|
||||
`Unit(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} 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> {
|
||||
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(", ")}`);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,15 @@ import { prisma } from "../../db/prisma.js";
|
|||
* client-side (`apps/web`'s `locales/fr/translation.json`).
|
||||
*/
|
||||
export async function getDiets(): Promise<DietView[]> {
|
||||
return prisma.diet.findMany({ orderBy: { key: "asc" } });
|
||||
try {
|
||||
return await prisma.diet.findMany({ orderBy: { key: "asc" } });
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `async` body without a
|
||||
// try/catch per the repo's convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -28,15 +36,19 @@ export async function getDiets(): Promise<DietView[]> {
|
|||
* callers.
|
||||
*/
|
||||
export async function getAllergies(): Promise<AllergyView[]> {
|
||||
const allergies = await prisma.allergy.findMany({
|
||||
include: { category: { select: { key: true, kind: true } } },
|
||||
orderBy: { category: { key: "asc" } },
|
||||
});
|
||||
return allergies.map((allergy) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
}));
|
||||
try {
|
||||
const allergies = await prisma.allergy.findMany({
|
||||
include: { category: { select: { key: true, kind: true } } },
|
||||
orderBy: { category: { key: "asc" } },
|
||||
});
|
||||
return allergies.map((allergy) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,13 +59,17 @@ export async function getAllergies(): Promise<AllergyView[]> {
|
|||
* `RecipeIngredient.quantity`.
|
||||
*/
|
||||
export async function getUnits(): Promise<UnitView[]> {
|
||||
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
|
||||
return units.map((unit) => ({
|
||||
id: unit.id,
|
||||
key: unit.key,
|
||||
type: unit.type,
|
||||
toBaseFactor: Number(unit.toBaseFactor),
|
||||
}));
|
||||
try {
|
||||
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
|
||||
return units.map((unit) => ({
|
||||
id: unit.id,
|
||||
key: unit.key,
|
||||
type: unit.type,
|
||||
toBaseFactor: Number(unit.toBaseFactor),
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,7 +78,11 @@ export async function getUnits(): Promise<UnitView[]> {
|
|||
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
||||
*/
|
||||
export async function getTechSteps(): Promise<TechStepView[]> {
|
||||
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||
try {
|
||||
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,13 +94,23 @@ export async function getTechSteps(): Promise<TechStepView[]> {
|
|||
* `recipe-source-sync.ts`'s `syncRecipeSources`).
|
||||
*/
|
||||
export async function getSources(): Promise<SourceView[]> {
|
||||
// Explicit `select` — `url` exists on the `Source` row but isn't part of
|
||||
// `SourceView` yet, so it must not leak into the response the way a bare
|
||||
// `findMany()` would let it.
|
||||
return prisma.source.findMany({
|
||||
select: { id: true, key: true, name: true, official: true, iconUrl: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
try {
|
||||
// Explicit `select` — `url` exists on the `Source` row but isn't part of
|
||||
// `SourceView` yet, so it must not leak into the response the way a bare
|
||||
// `findMany()` would let it.
|
||||
return await prisma.source.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
name: true,
|
||||
official: true,
|
||||
iconUrl: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,25 +121,32 @@ export async function getSources(): Promise<SourceView[]> {
|
|||
* allergen/diet come back with `allergens: []`/`diets: []`.
|
||||
*/
|
||||
export async function getIngredients(): Promise<IngredientView[]> {
|
||||
const ingredients = await prisma.ingredient.findMany({
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
return ingredients.map((ingredient) => ({
|
||||
id: ingredient.id,
|
||||
key: ingredient.key,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
reproducible: ingredient.reproducible,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
|
||||
}));
|
||||
try {
|
||||
const ingredients = await prisma.ingredient.findMany({
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
return ingredients.map((ingredient) => ({
|
||||
id: ingredient.id,
|
||||
key: ingredient.key,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
reproducible: ingredient.reproducible,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
diets: ingredient.diets.map(({ diet }) => ({
|
||||
id: diet.id,
|
||||
key: diet.key,
|
||||
})),
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared";
|
||||
import { browseSourceSchema, createRecipeSchema, ErrorCode } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js";
|
||||
|
|
|
|||
|
|
@ -12,15 +12,24 @@ import { prisma } from "../../db/prisma.js";
|
|||
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
|
||||
import {
|
||||
type IngredientMatchEntry,
|
||||
type UnitMatchEntry,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
} from "../../lib/ingredient-matcher.js";
|
||||
import { type RecipeSourceAdapter, markAlreadyImported } from "../../lib/recipe-source-adapter.js";
|
||||
import { RecipeSourceError } from "../../lib/recipe-source-errors.js";
|
||||
import { getRecipeSource } from "../../lib/recipe-source-registry.js";
|
||||
import { translateRecipeIngredients } from "../../lib/recipe-translation.js";
|
||||
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
|
||||
type UnitMatchEntry,
|
||||
} from "../../lib/recipe-matching/ingredient-matcher.js";
|
||||
import {
|
||||
mergeDuplicateIngredients,
|
||||
translateRecipeIngredients,
|
||||
} from "../../lib/recipe-matching/recipe-translation.js";
|
||||
import {
|
||||
loadTechStepMappingRules,
|
||||
matchTechStepSpans,
|
||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||
import {
|
||||
markAlreadyImported,
|
||||
type RecipeSourceAdapter,
|
||||
} from "../../lib/recipe-sources/recipe-source-adapter.js";
|
||||
import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors.js";
|
||||
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
|
||||
import { getHouseSourceIds } from "../house/house.service.js";
|
||||
import { createImportedRecipe } from "../recipe/recipe.service.js";
|
||||
import { getIngredients, getUnits } from "../reference/reference.service.js";
|
||||
|
|
@ -55,24 +64,34 @@ async function assertSourceEnabled(
|
|||
houseId: number | null,
|
||||
sourceKey: string,
|
||||
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
|
||||
const enabledSourceIds = await getHouseSourceIds(houseId);
|
||||
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
|
||||
if (!source || !enabledSourceIds.includes(source.id)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Source "${sourceKey}" is not enabled for this household`,
|
||||
);
|
||||
try {
|
||||
const enabledSourceIds = await getHouseSourceIds(houseId);
|
||||
const source = await prisma.source.findUnique({
|
||||
where: { key: sourceKey },
|
||||
});
|
||||
if (!source || !enabledSourceIds.includes(source.id)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Source "${sourceKey}" is not enabled for this household`,
|
||||
);
|
||||
}
|
||||
const adapter = getRecipeSource(sourceKey);
|
||||
if (!adapter) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Source "${sourceKey}" has no registered adapter`,
|
||||
);
|
||||
}
|
||||
return { adapter, sourceId: source.id };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
const adapter = getRecipeSource(sourceKey);
|
||||
if (!adapter) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Source "${sourceKey}" has no registered adapter`,
|
||||
);
|
||||
}
|
||||
return { adapter, sourceId: source.id };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,27 +107,34 @@ export async function browseSource(
|
|||
houseId: number | null,
|
||||
params: { query?: string; cursor?: string },
|
||||
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||
const result = await adapter.list({ query: params.query, cursor: params.cursor });
|
||||
try {
|
||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||
const result = await adapter.list({
|
||||
query: params.query,
|
||||
cursor: params.cursor,
|
||||
});
|
||||
|
||||
const importedRecipeIds = await findImportedRecipeIds(
|
||||
prisma,
|
||||
sourceKey,
|
||||
result.items.map((item) => item.externalId),
|
||||
);
|
||||
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
|
||||
const importedRecipeIds = await findImportedRecipeIds(
|
||||
prisma,
|
||||
sourceKey,
|
||||
result.items.map((item) => item.externalId),
|
||||
);
|
||||
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
|
||||
|
||||
return {
|
||||
items: marked.map((item) => ({
|
||||
externalId: item.externalId,
|
||||
title: item.title,
|
||||
picture: item.picture,
|
||||
url: item.url,
|
||||
alreadyImported: item.alreadyImported,
|
||||
recipeId: importedRecipeIds.get(item.externalId) ?? null,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
return {
|
||||
items: marked.map((item) => ({
|
||||
externalId: item.externalId,
|
||||
title: item.title,
|
||||
picture: item.picture,
|
||||
url: item.url,
|
||||
alreadyImported: item.alreadyImported,
|
||||
recipeId: importedRecipeIds.get(item.externalId) ?? null,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -132,66 +158,80 @@ export async function previewSourceItem(
|
|||
externalId: string,
|
||||
houseId: number | null,
|
||||
): Promise<RecipeImportDraftView> {
|
||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||
|
||||
let parsed: ReturnType<typeof adapter.parse>;
|
||||
try {
|
||||
const raw = await adapter.fetchDetail(externalId);
|
||||
parsed = adapter.parse(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof RecipeSourceError) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
|
||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||
|
||||
let parsed: ReturnType<typeof adapter.parse>;
|
||||
try {
|
||||
const raw = await adapter.fetchDetail(externalId);
|
||||
parsed = adapter.parse(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof RecipeSourceError) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
|
||||
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
|
||||
loadTechStepMappingRules(adapter.locale),
|
||||
adapter.locale === "en"
|
||||
? loadIngredientCatalog()
|
||||
: Promise.resolve<IngredientMatchEntry[]>([]),
|
||||
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||
]);
|
||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||
|
||||
const translatedIngredients = translateRecipeIngredients(
|
||||
parsed.ingredients,
|
||||
ingredientCatalog,
|
||||
unitCatalog,
|
||||
);
|
||||
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
||||
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
||||
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
||||
|
||||
// A source's raw ingredient lines aren't deduplicated by the matcher —
|
||||
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
|
||||
// catalog ingredient. Folded into one line per ingredient (quantities
|
||||
// summed where that's safe) before the draft ever reaches the review
|
||||
// screen, rather than surfacing the recipe with two rows for "Œuf" and
|
||||
// making the person sort it out — see issue #53's follow-up.
|
||||
const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews);
|
||||
|
||||
const ingredients: DraftRecipeIngredientView[] = mergedIngredients.map((ingredient) => ({
|
||||
rawText: ingredient.rawText,
|
||||
quantity: ingredient.quantity,
|
||||
ingredient:
|
||||
ingredient.ingredientId !== null
|
||||
? (ingredientById.get(ingredient.ingredientId) ?? null)
|
||||
: null,
|
||||
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
||||
}));
|
||||
|
||||
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
|
||||
description: step.description,
|
||||
picture: step.picture,
|
||||
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
|
||||
const techStep = techStepById.get(match.techStepId);
|
||||
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
externalId,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
picture: parsed.picture,
|
||||
portions: parsed.portions,
|
||||
sourceUrl: parsed.sourceUrl,
|
||||
ingredients,
|
||||
steps,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
|
||||
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
|
||||
loadTechStepMappingRules(adapter.locale),
|
||||
adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve<IngredientMatchEntry[]>([]),
|
||||
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||
]);
|
||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||
|
||||
const translatedIngredients = translateRecipeIngredients(
|
||||
parsed.ingredients,
|
||||
ingredientCatalog,
|
||||
unitCatalog,
|
||||
);
|
||||
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
||||
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
||||
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
||||
|
||||
const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({
|
||||
rawText: ingredient.rawText,
|
||||
quantity: ingredient.quantity,
|
||||
ingredient:
|
||||
ingredient.ingredientId !== null
|
||||
? (ingredientById.get(ingredient.ingredientId) ?? null)
|
||||
: null,
|
||||
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
||||
}));
|
||||
|
||||
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
|
||||
description: step.description,
|
||||
picture: step.picture,
|
||||
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
|
||||
const techStep = techStepById.get(match.techStepId);
|
||||
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
externalId,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
picture: parsed.picture,
|
||||
portions: parsed.portions,
|
||||
sourceUrl: parsed.sourceUrl,
|
||||
ingredients,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,20 +261,24 @@ export async function importSourceItem(
|
|||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
|
||||
try {
|
||||
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
|
||||
|
||||
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
|
||||
if (alreadyImported.has(externalId)) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.RECIPE_ALREADY_IMPORTED,
|
||||
`"${externalId}" from source "${sourceKey}" is already imported`,
|
||||
);
|
||||
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
|
||||
if (alreadyImported.has(externalId)) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.RECIPE_ALREADY_IMPORTED,
|
||||
`"${externalId}" from source "${sourceKey}" is already imported`,
|
||||
);
|
||||
}
|
||||
|
||||
return await createImportedRecipe(input, authorId, authorHouseId, {
|
||||
sourceId,
|
||||
externalId,
|
||||
locale: adapter.locale,
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
|
||||
return createImportedRecipe(input, authorId, authorHouseId, {
|
||||
sourceId,
|
||||
externalId,
|
||||
locale: adapter.locale,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createServer } from "./app.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { logger } from "./lib/logger.service.js";
|
||||
import { registerAllRecipeSources } from "./sources/index.js";
|
||||
|
||||
// Populates the recipe-source registry (recipe-source-registry.ts) before
|
||||
|
|
@ -10,5 +11,5 @@ registerAllRecipeSources();
|
|||
const server = createServer();
|
||||
|
||||
server.listen(env.PORT, () => {
|
||||
console.log(`API listening on http://localhost:${env.PORT}`);
|
||||
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { registerRecipeSource } from "../lib/recipe-source-registry.js";
|
||||
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
|
||||
import { theMealDbAdapter } from "./the-meal-db.js";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import type {
|
|||
ParsedRecipe,
|
||||
ParsedRecipeIngredient,
|
||||
RecipeSourceAdapter,
|
||||
} from "../lib/recipe-source-adapter.js";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
|
||||
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../lib/recipe-sources/recipe-source-errors.js";
|
||||
|
||||
const SOURCE_KEY = "jsonLdRecipe";
|
||||
|
||||
|
|
@ -165,7 +168,10 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
|
|||
* from a prior `list()` call. A future "import from URL" flow would call
|
||||
* `fetchDetail(pastedUrl)` directly.
|
||||
*/
|
||||
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
|
||||
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
|
||||
html: string;
|
||||
url: string;
|
||||
}> = {
|
||||
key: SOURCE_KEY,
|
||||
name: "Import générique (JSON-LD)",
|
||||
official: false,
|
||||
|
|
@ -179,21 +185,33 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
|
|||
locale: "fr",
|
||||
|
||||
async list() {
|
||||
return { items: [], nextCursor: null };
|
||||
try {
|
||||
return { items: [], nextCursor: null };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — 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.
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchDetail(url: string): Promise<{ html: string; url: string }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch (cause) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch (cause) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
return { html, url };
|
||||
} catch (err) {
|
||||
throw err; // see list()'s catch comment above
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
return { html, url };
|
||||
},
|
||||
|
||||
parse({ html, url }): ParsedRecipe {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ import type {
|
|||
RecipeSourceAdapter,
|
||||
RecipeSourceListParams,
|
||||
RecipeSourceListResult,
|
||||
} from "../lib/recipe-source-adapter.js";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
|
||||
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../lib/recipe-sources/recipe-source-errors.js";
|
||||
|
||||
const SOURCE_KEY = "theMealDb";
|
||||
|
||||
|
|
@ -35,21 +38,28 @@ interface TheMealDbMealsResponse {
|
|||
}
|
||||
|
||||
async function fetchTheMealDb<T>(path: string): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`);
|
||||
} catch (cause) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, {
|
||||
cause,
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`);
|
||||
} catch (cause) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RecipeSourceFetchError(
|
||||
SOURCE_KEY,
|
||||
`TheMealDB responded ${response.status} (${path})`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (err) {
|
||||
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
|
||||
// already handles/logs failures centrally; this method just isn't
|
||||
// allowed a bare `await` per the repo's async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RecipeSourceFetchError(
|
||||
SOURCE_KEY,
|
||||
`TheMealDB responded ${response.status} (${path})`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function detailUrl(idMeal: string): string {
|
||||
|
|
@ -82,31 +92,39 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
|
|||
locale: "en",
|
||||
|
||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see fetchTheMealDb()'s catch comment above
|
||||
}
|
||||
},
|
||||
|
||||
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
|
||||
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
|
||||
const meal = data.meals?.[0];
|
||||
if (!meal) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
|
||||
try {
|
||||
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
|
||||
const meal = data.meals?.[0];
|
||||
if (!meal) {
|
||||
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
|
||||
}
|
||||
return meal;
|
||||
} catch (err) {
|
||||
throw err; // see fetchTheMealDb()'s catch comment above
|
||||
}
|
||||
return meal;
|
||||
},
|
||||
|
||||
parse(meal: TheMealDbMeal): ParsedRecipe {
|
||||
|
|
@ -129,10 +147,17 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
|
|||
|
||||
// Free-text instructions, usually one step per line — splitting on
|
||||
// blank/newlines is the closest this source gets to discrete steps.
|
||||
// TheMealDB frequently numbers each step on its own line ahead of the
|
||||
// paragraph that follows (e.g. "…melted.\n\n2\n\nPreheat oven…") rather
|
||||
// than inline ("1. Preheat oven…") — the blank-line split above turns
|
||||
// that lone number into its own "line", which would otherwise become a
|
||||
// bogus step containing nothing but a digit. Drop those rather than
|
||||
// keep them as steps in their own right (see issue #52).
|
||||
const steps = (meal.strInstructions ?? "")
|
||||
.split(/\r?\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.filter((line) => !/^\d+\.?$/.test(line))
|
||||
.map((description) => ({ description, picture: null }));
|
||||
if (steps.length === 0) {
|
||||
throw new RecipeSourceParseError(
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ import request from "supertest";
|
|||
import { createApp } from "../src/app.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
registerRecipeSource,
|
||||
} from "../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
function buildSignupPayload(): SignupInput {
|
||||
|
|
|
|||
105
apps/api/test/logger.service.test.ts
Normal file
105
apps/api/test/logger.service.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { expect } from "chai";
|
||||
import { LoggerService, logger, minLevelFor } from "../src/lib/logger.service.js";
|
||||
|
||||
/**
|
||||
* Stubs one `console` method for one test, capturing every call instead of
|
||||
* actually writing to stdout/stderr — same "stub the one thing that
|
||||
* touches the outside world" approach `the-meal-db.test.ts` uses for
|
||||
* `fetch`. Restored by the caller (`afterEach` below) regardless of which
|
||||
* test used it.
|
||||
*/
|
||||
function stubConsoleMethod(method: "debug" | "info" | "warn" | "error") {
|
||||
const calls: unknown[][] = [];
|
||||
// biome-ignore lint/suspicious/noConsole: this *is* the console-stubbing helper — it has to read the real method to be able to restore it. Never calls it for real.
|
||||
const original = console[method];
|
||||
console[method] = (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
restore: () => (console[method] = original),
|
||||
};
|
||||
}
|
||||
|
||||
describe("logger.service", () => {
|
||||
describe("minLevelFor", () => {
|
||||
it("only lets warn/error through in test — Mocha's own output shouldn't get any noisier", () => {
|
||||
expect(minLevelFor("test")).to.equal("warn");
|
||||
});
|
||||
|
||||
it("only lets info/warn/error through in production — debug is too verbose to ship", () => {
|
||||
expect(minLevelFor("production")).to.equal("info");
|
||||
});
|
||||
|
||||
it("lets everything through, including debug, in development", () => {
|
||||
expect(minLevelFor("development")).to.equal("debug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logger", () => {
|
||||
let stub: ReturnType<typeof stubConsoleMethod>;
|
||||
|
||||
afterEach(() => {
|
||||
stub?.restore();
|
||||
});
|
||||
|
||||
it("emits a warn line as a single JSON object via console.warn, with a timestamp/level/message and any extra meta merged in", () => {
|
||||
stub = stubConsoleMethod("warn");
|
||||
|
||||
logger.warn("Something routine failed", { status: 404, code: 4049 });
|
||||
|
||||
expect(stub.calls).to.have.length(1);
|
||||
const [line] = stub.calls[0];
|
||||
const parsed = JSON.parse(line as string);
|
||||
expect(parsed.level).to.equal("warn");
|
||||
expect(parsed.message).to.equal("Something routine failed");
|
||||
expect(parsed.status).to.equal(404);
|
||||
expect(parsed.code).to.equal(4049);
|
||||
expect(new Date(parsed.timestamp).toString()).to.not.equal("Invalid Date");
|
||||
});
|
||||
|
||||
it("emits an error line via console.error", () => {
|
||||
stub = stubConsoleMethod("error");
|
||||
|
||||
logger.error("Something broke");
|
||||
|
||||
expect(stub.calls).to.have.length(1);
|
||||
const parsed = JSON.parse(stub.calls[0][0] as string);
|
||||
expect(parsed.level).to.equal("error");
|
||||
});
|
||||
|
||||
it('drops debug/info under the test suite\'s own NODE_ENV=test (minLevelFor("test") === "warn")', () => {
|
||||
const debugStub = stubConsoleMethod("debug");
|
||||
const infoStub = stubConsoleMethod("info");
|
||||
|
||||
logger.debug("Should not appear");
|
||||
logger.info("Should not appear either");
|
||||
|
||||
expect(debugStub.calls).to.have.length(0);
|
||||
expect(infoStub.calls).to.have.length(0);
|
||||
debugStub.restore();
|
||||
infoStub.restore();
|
||||
});
|
||||
|
||||
it("never lets meta override the line's own timestamp/level/message keys", () => {
|
||||
stub = stubConsoleMethod("warn");
|
||||
|
||||
logger.warn("Real message", { message: "spoofed", level: "spoofed", timestamp: "spoofed" });
|
||||
|
||||
const parsed = JSON.parse(stub.calls[0][0] as string);
|
||||
expect(parsed.message).to.equal("Real message");
|
||||
expect(parsed.level).to.equal("warn");
|
||||
expect(new Date(parsed.timestamp).toString()).to.not.equal("Invalid Date");
|
||||
});
|
||||
|
||||
it('a separate instance constructed with nodeEnv="development" lets debug through, independently of the shared logger\'s own NODE_ENV=test threshold', () => {
|
||||
const debugStub = stubConsoleMethod("debug");
|
||||
const devLogger = new LoggerService("development");
|
||||
|
||||
devLogger.debug("Visible in dev");
|
||||
|
||||
expect(debugStub.calls).to.have.length(1);
|
||||
debugStub.restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared";
|
||||
import { expect } from "chai";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { prisma } from "../../src/db/prisma.js";
|
||||
import {
|
||||
type IngredientMatchEntry,
|
||||
type UnitMatchEntry,
|
||||
extractQuantity,
|
||||
type IngredientMatchEntry,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
matchIngredientName,
|
||||
matchUnit,
|
||||
} from "../src/lib/ingredient-matcher.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
type UnitMatchEntry,
|
||||
} from "../../src/lib/recipe-matching/ingredient-matcher.js";
|
||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||
|
||||
describe("ingredient-matcher", () => {
|
||||
describe("matchIngredientName", () => {
|
||||
|
|
@ -75,6 +76,15 @@ describe("ingredient-matcher", () => {
|
|||
expect(matchIngredientName("", catalog)).to.equal(null);
|
||||
});
|
||||
|
||||
it("matches an alternate wording of the same ingredient via a second catalog entry sharing its ingredientId (issue #54)", () => {
|
||||
const vanillaBean: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla bean" };
|
||||
const vanillaBeanSynonym: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla pod" };
|
||||
const synonymCatalog = [vanillaBean, vanillaBeanSynonym];
|
||||
|
||||
expect(matchIngredientName("1 vanilla pod", synonymCatalog)).to.equal(8);
|
||||
expect(matchIngredientName("1 vanilla bean", synonymCatalog)).to.equal(8);
|
||||
});
|
||||
|
||||
it("breaks a same-specificity tie by the lowest ingredientId", () => {
|
||||
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
|
||||
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
|
||||
|
|
@ -190,18 +200,33 @@ describe("ingredient-matcher", () => {
|
|||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("loads one entry per Ingredient that has an English label, keyed by real ingredientId", async () => {
|
||||
it("loads one entry per Ingredient that has an English label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_EN), keyed by real ingredientId", async () => {
|
||||
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
|
||||
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
|
||||
where: { key: "vanillaBean" },
|
||||
});
|
||||
const ingredientCount = await prisma.ingredient.count();
|
||||
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_EN).reduce(
|
||||
(sum, synonyms) => sum + synonyms.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const catalog = await loadIngredientCatalog();
|
||||
|
||||
// Every seeded ingredient has an authored English label (verified at
|
||||
// generation time — see packages/shared/src/data/catalog-labels-en.ts),
|
||||
// so nothing should be silently skipped.
|
||||
expect(catalog).to.have.length(ingredientCount);
|
||||
// so nothing should be silently skipped — plus one extra entry per
|
||||
// synonym (issue #54), sharing the same ingredientId as the primary
|
||||
// label's entry.
|
||||
expect(catalog).to.have.length(ingredientCount + synonymCount);
|
||||
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
|
||||
expect(tomatoEntry?.label).to.equal("Tomato");
|
||||
|
||||
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
|
||||
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
|
||||
"Vanilla bean",
|
||||
"Vanilla pod",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads one entry per Unit that has English synonyms, keyed by real unitId", async () => {
|
||||
|
|
@ -1,14 +1,23 @@
|
|||
import { expect } from "chai";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import type { IngredientMatchEntry, UnitMatchEntry } from "../src/lib/ingredient-matcher.js";
|
||||
import type { ParsedRecipe, ParsedRecipeIngredient } from "../src/lib/recipe-source-adapter.js";
|
||||
import { prisma } from "../../src/db/prisma.js";
|
||||
import type {
|
||||
IngredientMatchEntry,
|
||||
UnitMatchEntry,
|
||||
} from "../../src/lib/recipe-matching/ingredient-matcher.js";
|
||||
import {
|
||||
mergeDuplicateIngredients,
|
||||
type TranslatedRecipeIngredient,
|
||||
translateRecipe,
|
||||
translateRecipeIngredients,
|
||||
translateRecipeSteps,
|
||||
} from "../src/lib/recipe-translation.js";
|
||||
import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
type UnitConversionEntry,
|
||||
} from "../../src/lib/recipe-matching/recipe-translation.js";
|
||||
import type { TechStepMappingRule } from "../../src/lib/recipe-matching/tech-step-matcher.js";
|
||||
import type {
|
||||
ParsedRecipe,
|
||||
ParsedRecipeIngredient,
|
||||
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||
|
||||
/** A minimal fixture `ParsedRecipe` — only `steps` (built from `descriptions`) matters for most tests here, the rest is filler to prove it survives translation untouched. */
|
||||
function buildParsedRecipe(descriptions: string[]): ParsedRecipe {
|
||||
|
|
@ -196,6 +205,32 @@ describe("recipe-translation", () => {
|
|||
expect(translated[0].unitId).to.equal(gram.unitId);
|
||||
});
|
||||
|
||||
it("falls back to the generic 'piece' unit when a quantity was found but no unit word was (issue #53)", () => {
|
||||
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
|
||||
|
||||
const translated = translateRecipeIngredients(
|
||||
[buildIngredient({ rawText: "4 Egg Yolks", name: "Egg Yolks" })],
|
||||
[],
|
||||
[gram, cup, piece],
|
||||
);
|
||||
|
||||
expect(translated[0].quantity).to.equal(4);
|
||||
expect(translated[0].unitId).to.equal(piece.unitId);
|
||||
});
|
||||
|
||||
it("does not fall back to 'piece' when there's no quantity at all to count", () => {
|
||||
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
|
||||
|
||||
const translated = translateRecipeIngredients(
|
||||
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
|
||||
[],
|
||||
[piece],
|
||||
);
|
||||
|
||||
expect(translated[0].quantity).to.equal(null);
|
||||
expect(translated[0].unitId).to.equal(null);
|
||||
});
|
||||
|
||||
it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => {
|
||||
const translated = translateRecipeIngredients(
|
||||
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
|
||||
|
|
@ -217,6 +252,111 @@ describe("recipe-translation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("mergeDuplicateIngredients", () => {
|
||||
const gram: UnitConversionEntry = { id: 1, type: "MASS", toBaseFactor: 1 };
|
||||
const kilogram: UnitConversionEntry = { id: 2, type: "MASS", toBaseFactor: 1000 };
|
||||
const milliliter: UnitConversionEntry = { id: 3, type: "VOLUME", toBaseFactor: 1 };
|
||||
const piece: UnitConversionEntry = { id: 4, type: "COUNT", toBaseFactor: 1 };
|
||||
const slice: UnitConversionEntry = { id: 5, type: "COUNT", toBaseFactor: 1 };
|
||||
|
||||
function buildLine(
|
||||
overrides: Partial<TranslatedRecipeIngredient> & { rawText: string },
|
||||
): TranslatedRecipeIngredient {
|
||||
return {
|
||||
name: overrides.rawText,
|
||||
quantity: null,
|
||||
unit: null,
|
||||
ingredientId: null,
|
||||
unitId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("sums the quantity of two lines resolving to the same ingredient, same unit (issue #53 follow-up)", () => {
|
||||
const merged = mergeDuplicateIngredients(
|
||||
[
|
||||
buildLine({ rawText: "100g Sugar", ingredientId: 1, quantity: 100, unitId: gram.id }),
|
||||
buildLine({ rawText: "45g Sugar", ingredientId: 1, quantity: 45, unitId: gram.id }),
|
||||
],
|
||||
[gram, kilogram, milliliter, piece, slice],
|
||||
);
|
||||
|
||||
expect(merged).to.have.length(1);
|
||||
expect(merged[0].quantity).to.equal(145);
|
||||
expect(merged[0].unitId).to.equal(gram.id);
|
||||
expect(merged[0].rawText).to.equal("100g Sugar + 45g Sugar");
|
||||
});
|
||||
|
||||
it("converts through toBaseFactor when the duplicate uses a different unit of the same type", () => {
|
||||
const merged = mergeDuplicateIngredients(
|
||||
[
|
||||
buildLine({ rawText: "500g Flour", ingredientId: 1, quantity: 500, unitId: gram.id }),
|
||||
buildLine({
|
||||
rawText: "0.5kg Flour",
|
||||
ingredientId: 1,
|
||||
quantity: 0.5,
|
||||
unitId: kilogram.id,
|
||||
}),
|
||||
],
|
||||
[gram, kilogram, milliliter, piece, slice],
|
||||
);
|
||||
|
||||
expect(merged).to.have.length(1);
|
||||
expect(merged[0].quantity).to.equal(1000);
|
||||
expect(merged[0].unitId).to.equal(gram.id);
|
||||
});
|
||||
|
||||
it("keeps duplicate COUNT-unit lines separate rather than guessing — a slice isn't a piece", () => {
|
||||
const merged = mergeDuplicateIngredients(
|
||||
[
|
||||
buildLine({ rawText: "2 piece Bread", ingredientId: 1, quantity: 2, unitId: piece.id }),
|
||||
buildLine({ rawText: "3 slice Bread", ingredientId: 1, quantity: 3, unitId: slice.id }),
|
||||
],
|
||||
[gram, kilogram, milliliter, piece, slice],
|
||||
);
|
||||
|
||||
expect(merged).to.have.length(2);
|
||||
});
|
||||
|
||||
it("keeps duplicate lines with incompatible unit types separate (MASS vs VOLUME)", () => {
|
||||
const merged = mergeDuplicateIngredients(
|
||||
[
|
||||
buildLine({ rawText: "200g Milk", ingredientId: 1, quantity: 200, unitId: gram.id }),
|
||||
buildLine({
|
||||
rawText: "200ml Milk",
|
||||
ingredientId: 1,
|
||||
quantity: 200,
|
||||
unitId: milliliter.id,
|
||||
}),
|
||||
],
|
||||
[gram, kilogram, milliliter, piece, slice],
|
||||
);
|
||||
|
||||
expect(merged).to.have.length(2);
|
||||
});
|
||||
|
||||
it("never merges two unresolved lines (ingredientId: null) together", () => {
|
||||
const merged = mergeDuplicateIngredients(
|
||||
[
|
||||
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
|
||||
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
|
||||
],
|
||||
[gram, kilogram, milliliter, piece, slice],
|
||||
);
|
||||
|
||||
expect(merged).to.have.length(2);
|
||||
});
|
||||
|
||||
it("leaves non-duplicate lines untouched and preserves order", () => {
|
||||
const sugar = buildLine({ rawText: "Sugar", ingredientId: 1, quantity: 1, unitId: gram.id });
|
||||
const salt = buildLine({ rawText: "Salt", ingredientId: 2, quantity: 1, unitId: gram.id });
|
||||
|
||||
const merged = mergeDuplicateIngredients([sugar, salt], [gram, kilogram, milliliter]);
|
||||
|
||||
expect(merged).to.deep.equal([sugar, salt]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateRecipe", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { expect } from "chai";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { prisma } from "../../src/db/prisma.js";
|
||||
import {
|
||||
type TechStepMappingRule,
|
||||
loadTechStepMappingRules,
|
||||
matchTechStepSpans,
|
||||
matchTechSteps,
|
||||
normalizeText,
|
||||
} from "../src/lib/tech-step-matcher.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
type TechStepMappingRule,
|
||||
} from "../../src/lib/recipe-matching/tech-step-matcher.js";
|
||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||
|
||||
describe("tech-step-matcher", () => {
|
||||
describe("normalizeText", () => {
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { expect } from "chai";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
|
||||
import { jsonLdRecipeAdapter } from "../src/sources/json-ld-recipe.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
||||
import { jsonLdRecipeAdapter } from "../../src/sources/json-ld-recipe.js";
|
||||
|
||||
/** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */
|
||||
function stubFetchHtml(html: string, status = 200) {
|
||||
|
|
@ -2,12 +2,15 @@ import type { SignupInput } from "@batch-cooking/shared";
|
|||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { findImportedRecipeIds, syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
import { createApp } from "../../src/app.js";
|
||||
import { prisma } from "../../src/db/prisma.js";
|
||||
import { findImportedRecipeIds, syncRecipeSources } from "../../src/db/recipe-source-sync.js";
|
||||
import type { RecipeSourceAdapter } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
registerRecipeSource,
|
||||
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||
|
||||
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
||||
function buildSignupPayload(): SignupInput {
|
||||
|
|
@ -5,19 +5,19 @@ import type {
|
|||
RecipeSourceListItem,
|
||||
RecipeSourceListParams,
|
||||
RecipeSourceListResult,
|
||||
} from "../src/lib/recipe-source-adapter.js";
|
||||
import { markAlreadyImported } from "../src/lib/recipe-source-adapter.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import { markAlreadyImported } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
RecipeSourceError,
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../src/lib/recipe-source-errors.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
getRecipeSource,
|
||||
listRecipeSources,
|
||||
registerRecipeSource,
|
||||
} from "../src/lib/recipe-source-registry.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
|
||||
interface FakeRawRecipe {
|
||||
externalId: string;
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { expect } from "chai";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
|
||||
import { type TheMealDbMeal, theMealDbAdapter } from "../src/sources/the-meal-db.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
||||
import { type TheMealDbMeal, theMealDbAdapter } from "../../src/sources/the-meal-db.js";
|
||||
|
||||
/**
|
||||
* Stubs `globalThis.fetch` for one test — no HTTP-mocking library exists
|
||||
|
|
@ -154,6 +157,20 @@ describe("theMealDbAdapter", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("drops lone step-number lines instead of turning them into bogus steps (issue #52)", () => {
|
||||
const parsed = theMealDbAdapter.parse({
|
||||
...baseMeal,
|
||||
strInstructions:
|
||||
"For the caramel, melt the sugar.\r\n\r\n2\r\n\r\nPreheat the oven.\r\n\r\n3\r\n\r\nBake it.",
|
||||
});
|
||||
|
||||
expect(parsed.steps).to.deep.equal([
|
||||
{ description: "For the caramel, melt the sugar.", picture: null },
|
||||
{ description: "Preheat the oven.", picture: null },
|
||||
{ description: "Bake it.", picture: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws RecipeSourceParseError when the meal has no name", () => {
|
||||
expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw(
|
||||
RecipeSourceParseError,
|
||||
|
|
@ -6,8 +6,11 @@ import request from "supertest";
|
|||
import { createApp } from "../src/app.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
registerRecipeSource,
|
||||
} from "../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
|
||||
|
|
@ -60,7 +63,7 @@ async function techStepId(key: string): Promise<number> {
|
|||
return techStep.id;
|
||||
}
|
||||
|
||||
/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/tech-step-matcher.js`) so tests can assert on it directly. */
|
||||
/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/recipe-matching/tech-step-matcher.js`) so tests can assert on it directly. */
|
||||
async function stepTechStepIds(stepId: number): Promise<number[]> {
|
||||
const links = await prisma.stepTechStep.findMany({
|
||||
where: { stepId },
|
||||
|
|
@ -568,6 +571,32 @@ describe("Recipes", () => {
|
|||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
|
||||
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — a
|
||||
// manual creation can't reach this via the web UI (`IngredientPicker`
|
||||
// hides an already-picked ingredient), but nothing stops a raw
|
||||
// request (or a source import, whose lines aren't deduplicated) from
|
||||
// sending it — must fail cleanly instead of crashing on the DB's
|
||||
// unique-constraint violation.
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 1, unitId: piece },
|
||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||
],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
|
|
@ -747,8 +776,8 @@ describe("Recipes", () => {
|
|||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
const chop = await techStepId("chop");
|
||||
const mince = await techStepId("mince");
|
||||
const melt = await techStepId("melt");
|
||||
const _mince = await techStepId("mince");
|
||||
const _melt = await techStepId("melt");
|
||||
const simmer = await techStepId("simmer");
|
||||
const bake = await techStepId("bake");
|
||||
|
||||
|
|
@ -860,7 +889,7 @@ describe("Recipes", () => {
|
|||
});
|
||||
|
||||
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { profileId } = await signup();
|
||||
const { agent: otherAgent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import { createApp } from "../src/app.js";
|
|||
import { prisma } from "../src/db/prisma.js";
|
||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
registerRecipeSource,
|
||||
} from "../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import {
|
|||
clearRecipeSources,
|
||||
getRecipeSource,
|
||||
listRecipeSources,
|
||||
} from "../src/lib/recipe-source-registry.js";
|
||||
import { registerAllRecipeSources } from "../src/sources/index.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
import { registerAllRecipeSources } from "../../src/sources/index.js";
|
||||
|
||||
// Not exercised by any other test file — `registerAllRecipeSources` is
|
||||
// deliberately never imported by `app.ts` (see its own doc comment), so
|
||||
|
|
@ -3,18 +3,21 @@ import { ErrorCode } from "@batch-cooking/shared";
|
|||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||
import { createApp } from "../../src/app.js";
|
||||
import { prisma } from "../../src/db/prisma.js";
|
||||
import { syncRecipeSources } from "../../src/db/recipe-source-sync.js";
|
||||
import type {
|
||||
ParsedRecipe,
|
||||
RecipeSourceAdapter,
|
||||
RecipeSourceListParams,
|
||||
RecipeSourceListResult,
|
||||
} from "../src/lib/recipe-source-adapter.js";
|
||||
import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import { RecipeSourceFetchError } from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
registerRecipeSource,
|
||||
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||
|
||||
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
||||
function buildSignupPayload(): SignupInput {
|
||||
|
|
@ -78,6 +81,45 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId:
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake adapter whose two ingredient lines both resolve to the same real
|
||||
* seeded ingredient ("onion"), in the same unit (grams) — exercises
|
||||
* `previewSourceItem`'s duplicate-merging (`mergeDuplicateIngredients`,
|
||||
* see issue #53's follow-up) through the real HTTP endpoint/catalog,
|
||||
* rather than only as a pure unit test of the merge function itself.
|
||||
*/
|
||||
function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSourceAdapter<{
|
||||
externalId: string;
|
||||
}> {
|
||||
return {
|
||||
key,
|
||||
name: "Fake Source With Duplicates",
|
||||
official: true,
|
||||
iconUrl: null,
|
||||
locale: "en",
|
||||
async list(): Promise<RecipeSourceListResult> {
|
||||
return { items: [], nextCursor: null };
|
||||
},
|
||||
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
|
||||
return { externalId };
|
||||
},
|
||||
parse(raw: { externalId: string }): ParsedRecipe {
|
||||
return {
|
||||
name: `Fake recipe ${raw.externalId}`,
|
||||
description: null,
|
||||
picture: null,
|
||||
portions: 4,
|
||||
sourceUrl: `https://fake.test/${raw.externalId}`,
|
||||
ingredients: [
|
||||
{ rawText: "100g Onion", quantity: null, unit: null, name: "onion" },
|
||||
{ rawText: "50g Onion", quantity: null, unit: null, name: "onion" },
|
||||
],
|
||||
steps: [{ description: "Chop the onions finely", picture: null }],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
|
||||
async function ingredientId(key: string): Promise<number> {
|
||||
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
||||
|
|
@ -262,6 +304,28 @@ describe("Sources", () => {
|
|||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => {
|
||||
const { agent } = await signupWithHouse();
|
||||
registerRecipeSource(buildDuplicateIngredientAdapter());
|
||||
await syncRecipeSources(prisma);
|
||||
const source = await prisma.source.findUniqueOrThrow({
|
||||
where: { key: "duplicateFakeSource" },
|
||||
});
|
||||
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
|
||||
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
|
||||
const gram = await prisma.unit.findFirstOrThrow({ where: { key: "gram" } });
|
||||
|
||||
const res = await agent.get("/sources/duplicateFakeSource/preview/1");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.ingredients).to.have.length(1);
|
||||
const [merged] = res.body.ingredients;
|
||||
expect(merged.ingredient).to.deep.include({ id: onion.id, key: "onion" });
|
||||
expect(merged.unit).to.deep.include({ id: gram.id, key: "gram" });
|
||||
expect(merged.quantity).to.equal(150);
|
||||
expect(merged.rawText).to.equal("100g Onion + 50g Onion");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /sources/:sourceKey/import/:externalId", () => {
|
||||
|
|
@ -357,6 +421,23 @@ describe("Sources", () => {
|
|||
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
|
||||
// A source's raw ingredient lines aren't deduplicated by the matcher
|
||||
// (see `ingredient-matcher.ts`) — two different lines (e.g. "Egg
|
||||
// Yolks" and "Eggs") can resolve to the same catalog ingredient, same
|
||||
// as `recipe.test.ts`'s equivalent for a manual creation, just
|
||||
// reached here through the review screen's pre-filled payload
|
||||
// instead.
|
||||
const { agent } = await enableFakeSource();
|
||||
const payload = await buildImportPayload();
|
||||
payload.ingredients.push({ ...payload.ingredients[0] });
|
||||
|
||||
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
|
||||
// Registers/syncs the adapter once — enableFakeSource() itself does
|
||||
// this too, and registerRecipeSource() throws on a duplicate key, so
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { StepTechStepView } from "@batch-cooking/shared";
|
||||
import { splitDescriptionByTechSteps } from "../../src/features/recipes/highlight-tech-steps";
|
||||
import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/highlight-tech-steps";
|
||||
|
||||
// Pure logic, no DOM/mount needed — reuses the component-test runner
|
||||
// (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ Feature: Recipe form — associating ingredients
|
|||
When I visit the new recipe form without a secure random UUID
|
||||
And I fill in the "recipe-name" field with "Recette hors contexte sécurisé"
|
||||
And I select the ingredient "Tomate" from the picker
|
||||
And I select the ingredient "Œuf" from the picker
|
||||
And I select the ingredient "Oeuf" from the picker
|
||||
Then there should be 2 ingredient rows
|
||||
And the recipe should include the ingredient "Tomate"
|
||||
And the recipe should include the ingredient "Œuf"
|
||||
And the recipe should include the ingredient "Oeuf"
|
||||
When I add a step
|
||||
And I add a step
|
||||
Then there should be 2 step editor items
|
||||
|
|
@ -57,7 +57,7 @@ Feature: Recipe form — associating ingredients
|
|||
Given recipe 7 exists with an egg omelette
|
||||
And updating recipe 7 will succeed
|
||||
When I visit "/recettes/7/modifier"
|
||||
Then the recipe should include the ingredient "Œuf"
|
||||
Then the recipe should include the ingredient "Oeuf"
|
||||
And the ingredient's quantity should be "3"
|
||||
When I select the ingredient "Tomate" from the picker
|
||||
Then there should be 2 ingredient rows
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const authenticatedProfile = {
|
|||
};
|
||||
|
||||
const vegetarien = { id: 1, key: "vegetarian" };
|
||||
const gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
|
||||
const _gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
|
||||
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
|
||||
|
||||
const ratatouille = {
|
||||
|
|
|
|||
|
|
@ -2,22 +2,22 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
||||
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { ImportRecipePage } from "./pages/ImportRecipePage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { PlanningPage } from "./pages/PlanningPage";
|
||||
import { RecipeFormPage } from "./pages/RecipeFormPage";
|
||||
import { RecipesPage } from "./pages/RecipesPage";
|
||||
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||
import { SignupPage } from "./pages/SignupPage";
|
||||
import { LoginPage } from "./pages/auth/LoginPage";
|
||||
import { SignupPage } from "./pages/auth/SignupPage";
|
||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||
import { OnboardingSourcesPage } from "./pages/onboarding/OnboardingSourcesPage";
|
||||
import { PlanningPage } from "./pages/planning/PlanningPage";
|
||||
import { ImportRecipePage } from "./pages/recipes/ImportRecipePage";
|
||||
import { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
|
||||
import { RecipesPage } from "./pages/recipes/RecipesPage";
|
||||
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
||||
import { CreditsPage } from "./pages/settings/CreditsPage";
|
||||
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
||||
import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
||||
import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage";
|
||||
import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||
|
||||
/**
|
||||
* Top-level route table. Every authenticated section is nested under one
|
||||
|
|
|
|||
|
|
@ -71,58 +71,67 @@ export class ApiClient {
|
|||
*
|
||||
* @throws {ApiError} if the response status is not in the 2xx range.
|
||||
*/
|
||||
private async request<TResponseBody>(
|
||||
private async _request<TResponseBody>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<TResponseBody> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
// Required for the httpOnly session cookie to be sent/received — the
|
||||
// API and the web app run on different origins.
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
// Required for the httpOnly session cookie to be sent/received — the
|
||||
// API and the web app run on different origins.
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||||
// Fallback for a response that couldn't even be parsed as JSON — no
|
||||
// hardcoded string, always the real enum member.
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||||
// Fallback for a response that couldn't even be parsed as JSON — no
|
||||
// hardcoded string, always the real enum member.
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
||||
);
|
||||
}
|
||||
|
||||
// 204 No Content (e.g. logout) has no body to parse.
|
||||
if (response.status === 204) {
|
||||
return undefined as TResponseBody;
|
||||
// 204 No Content (e.g. logout) has no body to parse.
|
||||
if (response.status === 204) {
|
||||
return undefined as TResponseBody;
|
||||
}
|
||||
return (await response.json()) as TResponseBody;
|
||||
} catch (err) {
|
||||
// Rethrown as-is — every caller already handles/surfaces API failures
|
||||
// its own way (an `ApiError` catch, a `.catch()` chain — see
|
||||
// `error-message.service.ts`), this is just the one place the
|
||||
// fetch/`await` itself has to sit inside a try/catch per the repo's
|
||||
// convention.
|
||||
throw err;
|
||||
}
|
||||
return response.json() as Promise<TResponseBody>;
|
||||
}
|
||||
|
||||
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
|
||||
public signup(input: SignupInput): Promise<SafeUserProfile> {
|
||||
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
||||
return this._request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Verifies credentials and starts a session. */
|
||||
public login(input: LoginInput): Promise<SafeUserProfile> {
|
||||
return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||
return this._request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Ends the current session. */
|
||||
public logout(): Promise<void> {
|
||||
return this.request("/auth/logout", { method: "POST" });
|
||||
return this._request("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
||||
public me(): Promise<SafeUserProfile> {
|
||||
return this.request("/auth/me");
|
||||
return this._request("/auth/me");
|
||||
}
|
||||
|
||||
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */
|
||||
public deleteAccount(password: string): Promise<void> {
|
||||
return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
|
||||
return this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -131,7 +140,7 @@ export class ApiClient {
|
|||
* there isn't one for that week yet.
|
||||
*/
|
||||
public getPlanningForWeek(date: string): Promise<PlanningView | null> {
|
||||
return this.request(`/planning?date=${date}`);
|
||||
return this._request(`/planning?date=${date}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -142,37 +151,37 @@ export class ApiClient {
|
|||
* caller).
|
||||
*/
|
||||
public addPlanningItem(input: AddPlanningItemInput): Promise<PlanningItemView> {
|
||||
return this.request("/planning/items", { method: "POST", body: JSON.stringify(input) });
|
||||
return this._request("/planning/items", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */
|
||||
public removePlanningItem(id: number): Promise<void> {
|
||||
return this.request(`/planning/items/${id}`, { method: "DELETE" });
|
||||
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||
public getDiets(): Promise<DietView[]> {
|
||||
return this.request("/reference/diets");
|
||||
return this._request("/reference/diets");
|
||||
}
|
||||
|
||||
/** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
|
||||
public getAllergies(): Promise<AllergyView[]> {
|
||||
return this.request("/reference/allergies");
|
||||
return this._request("/reference/allergies");
|
||||
}
|
||||
|
||||
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
|
||||
public getIngredients(): Promise<IngredientView[]> {
|
||||
return this.request("/reference/ingredients");
|
||||
return this._request("/reference/ingredients");
|
||||
}
|
||||
|
||||
/** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */
|
||||
public getUnits(): Promise<UnitView[]> {
|
||||
return this.request("/reference/units");
|
||||
return this._request("/reference/units");
|
||||
}
|
||||
|
||||
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
|
||||
public getSources(): Promise<SourceView[]> {
|
||||
return this.request("/reference/sources");
|
||||
return this._request("/reference/sources");
|
||||
}
|
||||
|
||||
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
|
||||
|
|
@ -184,12 +193,12 @@ export class ApiClient {
|
|||
if (params.query) search.set("query", params.query);
|
||||
if (params.cursor) search.set("cursor", params.cursor);
|
||||
const queryString = search.toString();
|
||||
return this.request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
|
||||
return this._request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
|
||||
}
|
||||
|
||||
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
|
||||
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
|
||||
return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
|
||||
return this._request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
|
||||
}
|
||||
|
||||
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
|
||||
|
|
@ -198,7 +207,7 @@ export class ApiClient {
|
|||
externalId: string,
|
||||
input: CreateRecipeInput,
|
||||
): Promise<RecipeView> {
|
||||
return this.request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
|
||||
return this._request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
|
@ -225,82 +234,82 @@ export class ApiClient {
|
|||
if (filters.suitableForHousehold) params.set("suitableForHousehold", "true");
|
||||
for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id));
|
||||
for (const id of filters.dietIds ?? []) params.append("dietIds", String(id));
|
||||
return this.request(`/recipes?${params.toString()}`);
|
||||
return this._request(`/recipes?${params.toString()}`);
|
||||
}
|
||||
|
||||
/** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */
|
||||
public getRecipe(id: number): Promise<RecipeView> {
|
||||
return this.request(`/recipes/${id}`);
|
||||
return this._request(`/recipes/${id}`);
|
||||
}
|
||||
|
||||
/** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */
|
||||
public createRecipe(input: CreateRecipeInput): Promise<RecipeView> {
|
||||
return this.request("/recipes", { method: "POST", body: JSON.stringify(input) });
|
||||
return this._request("/recipes", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */
|
||||
public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> {
|
||||
return this.request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
|
||||
return this._request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */
|
||||
public deleteRecipe(id: number): Promise<void> {
|
||||
return this.request(`/recipes/${id}`, { method: "DELETE" });
|
||||
return this._request(`/recipes/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Favorites a recipe for the current user — idempotent. */
|
||||
public addFavoriteRecipe(id: number): Promise<void> {
|
||||
return this.request(`/recipes/${id}/favorite`, { method: "POST" });
|
||||
return this._request(`/recipes/${id}/favorite`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** Unfavorites a recipe for the current user — idempotent. */
|
||||
public removeFavoriteRecipe(id: number): Promise<void> {
|
||||
return this.request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
||||
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
||||
public getCurrentHouse(): Promise<HouseView | null> {
|
||||
return this.request("/house/current");
|
||||
return this._request("/house/current");
|
||||
}
|
||||
|
||||
/** Renames the current user's household. */
|
||||
public renameHouse(name: string): Promise<HouseView> {
|
||||
return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
|
||||
return this._request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
/** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */
|
||||
public createHouse(name: string): Promise<HouseView> {
|
||||
return this.request("/house", { method: "POST", body: JSON.stringify({ name }) });
|
||||
return this._request("/house", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
/** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */
|
||||
public joinHouse(inviteCode: string): Promise<HouseView> {
|
||||
return this.request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) });
|
||||
return this._request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) });
|
||||
}
|
||||
|
||||
/** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */
|
||||
public leaveHouse(): Promise<void> {
|
||||
return this.request("/house/leave", { method: "POST" });
|
||||
return this._request("/house/leave", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Deletes the current user's household outright — every member loses it. Admin-only. */
|
||||
public deleteHouse(): Promise<void> {
|
||||
return this.request("/house/current", { method: "DELETE" });
|
||||
return this._request("/house/current", { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Removes one specific member from the current user's household. Admin-only. */
|
||||
public removeHouseMember(memberId: number): Promise<HouseView> {
|
||||
return this.request(`/house/members/${memberId}`, { method: "DELETE" });
|
||||
return this._request(`/house/members/${memberId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */
|
||||
public getHouseSourceIds(): Promise<number[]> {
|
||||
return this.request("/house/current/sources");
|
||||
return this._request("/house/current/sources");
|
||||
}
|
||||
|
||||
/** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */
|
||||
public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> {
|
||||
return this.request("/house/current/sources", {
|
||||
return this._request("/house/current/sources", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ sourceIds }),
|
||||
});
|
||||
|
|
@ -308,17 +317,17 @@ export class ApiClient {
|
|||
|
||||
/** Sets (or clears, with `null`) the current user's dietary regime. */
|
||||
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
||||
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
||||
return this._request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
||||
}
|
||||
|
||||
/** Fetches the current user's selected allergen ids. */
|
||||
public getAllergyIds(): Promise<number[]> {
|
||||
return this.request("/profile/allergies");
|
||||
return this._request("/profile/allergies");
|
||||
}
|
||||
|
||||
/** Replaces the current user's full allergen selection (not a merge — send the complete list). */
|
||||
public updateAllergyIds(allergyIds: number[]): Promise<number[]> {
|
||||
return this.request("/profile/allergies", {
|
||||
return this._request("/profile/allergies", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ allergyIds }),
|
||||
});
|
||||
|
|
@ -326,12 +335,12 @@ export class ApiClient {
|
|||
|
||||
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
|
||||
public getDislikedIngredientIds(): Promise<number[]> {
|
||||
return this.request("/profile/disliked-ingredients");
|
||||
return this._request("/profile/disliked-ingredients");
|
||||
}
|
||||
|
||||
/** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */
|
||||
public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> {
|
||||
return this.request("/profile/disliked-ingredients", {
|
||||
return this._request("/profile/disliked-ingredients", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ dislikedIngredientIds }),
|
||||
});
|
||||
|
|
@ -339,12 +348,12 @@ export class ApiClient {
|
|||
|
||||
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
|
||||
public getPreferences(): Promise<PreferencesView> {
|
||||
return this.request("/preferences");
|
||||
return this._request("/preferences");
|
||||
}
|
||||
|
||||
/** Sets the current user's theme preference. */
|
||||
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
|
||||
return this.request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
|
||||
return this._request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
26
apps/web/src/components/ui/ComingSoonPage.tsx
Normal file
26
apps/web/src/components/ui/ComingSoonPage.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import "./ComingSoonPage.scss";
|
||||
|
||||
interface ComingSoonPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder rendered by a section that has a route/sidebar entry but no
|
||||
* real feature behind it yet — today only `pages/shopping-list/ShoppingListPage.tsx`
|
||||
* (`Recettes`/`Foyer & profil` both grew real backends since this was
|
||||
* written, see `pages/recipes/`/`pages/settings/`). Kept as a shared,
|
||||
* reusable component (`components/ui/`, not itself a routed page) rather
|
||||
* than inlined into that one page, so a future stub section doesn't need to
|
||||
* hand-roll the same markup — the page that needs it still gets its own
|
||||
* file (and its own copy, via i18n), just wrapping this instead of
|
||||
* rewriting it.
|
||||
*/
|
||||
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
|
||||
return (
|
||||
<div className="coming-soon-page">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { type ReactElement, cloneElement, useId } from "react";
|
||||
import { cloneElement, type ReactElement, useId } from "react";
|
||||
import "./tooltip.scss";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
||||
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { apiClient } from "../../api/client";
|
||||
|
||||
/** Shape of the auth state/actions exposed via {@link useAuth}. */
|
||||
|
|
@ -49,25 +49,49 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
}, []);
|
||||
|
||||
const signup = useCallback(async (input: SignupInput) => {
|
||||
setUser(await apiClient.signup(input));
|
||||
try {
|
||||
setUser(await apiClient.signup(input));
|
||||
} catch (err) {
|
||||
// Rethrown as-is — the caller (`SignupPage`'s `handleSubmit`) is what
|
||||
// actually catches and displays this (see this context value's doc
|
||||
// comment, "Throws ApiError on failure"); this callback just isn't
|
||||
// allowed a bare `await` per the repo's async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (input: LoginInput) => {
|
||||
setUser(await apiClient.login(input));
|
||||
try {
|
||||
setUser(await apiClient.login(input));
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await apiClient.logout();
|
||||
setUser(null);
|
||||
try {
|
||||
await apiClient.logout();
|
||||
setUser(null);
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteAccount = useCallback(async (password: string) => {
|
||||
await apiClient.deleteAccount(password);
|
||||
setUser(null);
|
||||
try {
|
||||
await apiClient.deleteAccount(password);
|
||||
setUser(null);
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
setUser(await apiClient.me());
|
||||
try {
|
||||
setUser(await apiClient.me());
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -15,20 +15,23 @@ import { ApiError, apiClient } from "../../api/client";
|
|||
import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||
import { Dialog } from "../../components/ui/Dialog";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
import { DietTagSelect } from "../recipes/DietTagSelect";
|
||||
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||
import { DietTagSelect } from "../recipes/badges/DietTagSelect";
|
||||
import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
|
||||
import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
|
||||
import { RecipeImportForm } from "../recipes/RecipeImportForm";
|
||||
import { RecipeSourcesPanel, type SourceItemSelection } from "../recipes/RecipeSourcesPanel";
|
||||
import { RecipeTable } from "../recipes/RecipeTable";
|
||||
import {
|
||||
RecipeTabs,
|
||||
type RecipesPageTab,
|
||||
isSourceTab,
|
||||
parseSourceTabValue,
|
||||
type RecipesPageTab,
|
||||
RecipeTabs,
|
||||
} from "../recipes/RecipeTabs";
|
||||
import { tryBuildCompleteImport } from "../recipes/recipe-import-draft";
|
||||
import { useEnabledSources } from "../recipes/useEnabledSources";
|
||||
import { RecipeImportForm } from "../recipes/sources/RecipeImportForm";
|
||||
import {
|
||||
RecipeSourcesPanel,
|
||||
type SourceItemSelection,
|
||||
} from "../recipes/sources/RecipeSourcesPanel";
|
||||
import { tryBuildCompleteImport } from "../recipes/sources/recipe-import-draft";
|
||||
import { useEnabledSources } from "../recipes/sources/useEnabledSources";
|
||||
import "./recipe-picker-dialog.scss";
|
||||
|
||||
/** Debounce for the search field — same value as `RecipesPage`'s. */
|
||||
|
|
@ -313,7 +316,7 @@ export function RecipePickerDialog({
|
|||
// for the identical failure — see this dialog's `onImported` handler
|
||||
// below).
|
||||
setIsConfirmingDraft(false);
|
||||
navigate(`/recettes/${saved.id}`);
|
||||
void navigate(`/recettes/${saved.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,7 +364,7 @@ export function RecipePickerDialog({
|
|||
// to this slot failed. Same fallback as the transparent-
|
||||
// import path above: land on its own page instead of
|
||||
// retrying.
|
||||
navigate(`/recettes/${recipe.id}`);
|
||||
void navigate(`/recettes/${recipe.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { IngredientView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||
import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
|
||||
// Reuses `IngredientPicker` verbatim (built for the recipe form's
|
||||
// ingredient picker, features/recipes/) rather than a second search+browse
|
||||
// field — same reference ingredient list, same category/search browsing,
|
||||
|
|
@ -10,7 +10,7 @@ import { IngredientPicker } from "../recipes/IngredientPicker";
|
|||
// recipes.scss — colocated there since it's the same "reference ingredient
|
||||
// picker" visual family, even though this field lives in the profile
|
||||
// feature).
|
||||
import { IngredientTypeIcon } from "../recipes/ingredient-icons";
|
||||
import { IngredientTypeIcon } from "../recipes/ingredients/ingredient-icons";
|
||||
import "../recipes/recipes.scss";
|
||||
import "./profile-forms.scss";
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { Link } from "react-router-dom";
|
|||
import { ApiError, apiClient } from "../../api/client";
|
||||
import { SourceLinkIcon } from "../../layouts/nav-icons";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { FavoriteStarButton } from "./FavoriteStarButton";
|
||||
import { StepDescription } from "./StepDescription";
|
||||
import { AllergenBadges } from "./badges/AllergenBadges";
|
||||
import { FavoriteStarButton } from "./badges/FavoriteStarButton";
|
||||
import { StepDescription } from "./steps/StepDescription";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
|
|
@ -135,6 +135,7 @@ export function RecipeDetailPanel({
|
|||
<h3>{t("recipes.stepsTitle")}</h3>
|
||||
<ol className="recipe-detail-panel__steps">
|
||||
{draft.steps.map((step, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: DraftRecipeStepView (packages/shared) has no id — it's an unsaved source preview, nothing to key on yet — and this list is a static read-only render, never reordered/edited here.
|
||||
<li key={`${index}-${step.description}`}>
|
||||
{step.picture && <img src={step.picture} alt="" />}
|
||||
<StepDescription description={step.description} techSteps={step.techSteps} />
|
||||
|
|
@ -218,13 +219,7 @@ export function RecipeDetailPanel({
|
|||
}
|
||||
|
||||
/** Delete action with an inline two-step confirmation, same pattern as `HouseholdSettingsPage`'s danger zone. */
|
||||
function DeleteRecipeButton({
|
||||
recipeId,
|
||||
onDeleted,
|
||||
}: {
|
||||
recipeId: number;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
function DeleteRecipeButton({ recipeId, onDeleted }: { recipeId: number; onDeleted: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { RecipeSummaryView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { DietBadges } from "./DietBadges";
|
||||
import { AllergenBadges } from "./badges/AllergenBadges";
|
||||
import { DietBadges } from "./badges/DietBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AllergyView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
import "../recipes.scss";
|
||||
|
||||
/**
|
||||
* A row of allergen pills — used both on {@link RecipeCard} (catalog list)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { DietView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
import "../recipes.scss";
|
||||
|
||||
/**
|
||||
* A row of diet-regime pills — the "associated regime" reminder tagged on a
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { DietView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||
import "./recipes.scss";
|
||||
import { CheckboxOption } from "../../../components/ui/Checkbox";
|
||||
import "../recipes.scss";
|
||||
|
||||
/**
|
||||
* Multi-select (checkbox grid, same pattern as `AllergySelect`) for the
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { FavoriteIcon } from "../../layouts/nav-icons";
|
||||
import "./recipes.scss";
|
||||
import { apiClient } from "../../../api/client";
|
||||
import { FavoriteIcon } from "../../../layouts/nav-icons";
|
||||
import "../recipes.scss";
|
||||
|
||||
/**
|
||||
* Star toggle for a recipe's favorite status — the overlay button on
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
import "../recipes.scss";
|
||||
|
||||
/**
|
||||
* Single "faisable maison" pill for an ingredient flagged
|
||||
|
|
@ -7,13 +7,13 @@ import {
|
|||
} from "@batch-cooking/shared";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||
import { SettingsIcon } from "../../layouts/nav-icons";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { DietBadges } from "./DietBadges";
|
||||
import { ReproducibleBadge } from "./ReproducibleBadge";
|
||||
import { CheckboxOption } from "../../../components/ui/Checkbox";
|
||||
import { SettingsIcon } from "../../../layouts/nav-icons";
|
||||
import { AllergenBadges } from "../badges/AllergenBadges";
|
||||
import { DietBadges } from "../badges/DietBadges";
|
||||
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
||||
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
||||
import "./recipes.scss";
|
||||
import "../recipes.scss";
|
||||
|
||||
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
|
||||
const ALL = "ALL" as const;
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import type { IngredientView, UnitView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { DietBadges } from "./DietBadges";
|
||||
import { ReproducibleBadge } from "./ReproducibleBadge";
|
||||
import { AllergenBadges } from "../badges/AllergenBadges";
|
||||
import { DietBadges } from "../badges/DietBadges";
|
||||
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
||||
import { IngredientTypeIcon } from "./ingredient-icons";
|
||||
import "./recipes.scss";
|
||||
import "../recipes.scss";
|
||||
|
||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
|
||||
export function IngredientRow({
|
||||
|
|
@ -12,6 +12,7 @@ export function IngredientRow({
|
|||
quantity,
|
||||
unitId,
|
||||
unitsCatalog,
|
||||
duplicate = false,
|
||||
onQuantityChange,
|
||||
onUnitChange,
|
||||
onRemove,
|
||||
|
|
@ -20,14 +21,24 @@ export function IngredientRow({
|
|||
quantity: string;
|
||||
unitId: number | null;
|
||||
unitsCatalog: UnitView[];
|
||||
/** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */
|
||||
duplicate?: boolean;
|
||||
onQuantityChange: (quantity: string) => void;
|
||||
onUnitChange: (unitId: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// No unit picked yet — surfaced with a highlighted `<select>` + inline
|
||||
// hint rather than silently keeping the form's submit button disabled
|
||||
// (see issue #53: an import pre-filled from a source can land here with
|
||||
// an ingredient resolved but no unit — e.g. a bare count like "4 Egg
|
||||
// Yolks" — and nothing used to tell the user which line was blocking
|
||||
// them, or why).
|
||||
const unitMissing = unitId === null;
|
||||
const needsAttention = unitMissing || duplicate;
|
||||
|
||||
return (
|
||||
<li className="ingredient-row">
|
||||
<li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}>
|
||||
<span className="ingredient-row__icon" aria-hidden="true">
|
||||
<IngredientTypeIcon icon={ingredient.icon} />
|
||||
</span>
|
||||
|
|
@ -46,6 +57,7 @@ export function IngredientRow({
|
|||
value={unitId ?? ""}
|
||||
onChange={(e) => onUnitChange(Number(e.target.value))}
|
||||
aria-label={t("recipes.form.unitLabel")}
|
||||
aria-invalid={unitMissing}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("recipes.form.unitPlaceholder")}
|
||||
|
|
@ -70,6 +82,14 @@ export function IngredientRow({
|
|||
>
|
||||
✕
|
||||
</button>
|
||||
{unitMissing && (
|
||||
<p className="field-error ingredient-row__error">{t("recipes.form.unitMissingHint")}</p>
|
||||
)}
|
||||
{duplicate && (
|
||||
<p className="field-error ingredient-row__error">
|
||||
{t("recipes.form.duplicateIngredientHint")}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -923,6 +923,20 @@
|
|||
border-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
// A resolved ingredient with no unit picked — the form's submit stays
|
||||
// disabled until this is fixed, so it needs to be obvious *which* row is
|
||||
// the reason why (see issue #53). `__error` takes the row's full width
|
||||
// (it comes after the `flex-wrap`ped controls above, so it naturally
|
||||
// drops to its own line).
|
||||
&--incomplete &__unit {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
&__error {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ingredient picker (recipe form + disliked-ingredients field) ----------
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
type CreateRecipeInput,
|
||||
createRecipeSchema,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type IngredientView,
|
||||
|
|
@ -9,18 +10,17 @@ import {
|
|||
type RecipeVisibility,
|
||||
type UnitView,
|
||||
type WeekDay,
|
||||
createRecipeSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApiError, apiClient } from "../../api/client";
|
||||
import { makeClientKey } from "../../lib/client-key";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
import { DietTagSelect } from "./DietTagSelect";
|
||||
import { IngredientPicker } from "./IngredientPicker";
|
||||
import { IngredientRow } from "./IngredientRow";
|
||||
import { type StepDraft, StepListEditor } from "./StepListEditor";
|
||||
import "./recipes.scss";
|
||||
import { ApiError, apiClient } from "../../../api/client";
|
||||
import { makeClientKey } from "../../../lib/client-key";
|
||||
import { errorMessageService } from "../../../services/error-message.service";
|
||||
import { DietTagSelect } from "../badges/DietTagSelect";
|
||||
import { IngredientPicker } from "../ingredients/IngredientPicker";
|
||||
import { IngredientRow } from "../ingredients/IngredientRow";
|
||||
import { type StepDraft, StepListEditor } from "../steps/StepListEditor";
|
||||
import "../recipes.scss";
|
||||
|
||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||
|
|
@ -212,12 +212,38 @@ export function RecipeImportForm({
|
|||
setResolvingKey((current) => (current === key ? null : current));
|
||||
}
|
||||
|
||||
// Surfaced next to the submit button (see the `unitMissingHint` per-row
|
||||
// hint in `IngredientRow` for the same thing at the line level) — without
|
||||
// this, a pre-filled import whose auto-matched ingredients are otherwise
|
||||
// complete could leave `canSubmit` false with nothing on the page saying
|
||||
// why (see issue #53).
|
||||
const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null);
|
||||
|
||||
// Two raw source lines can independently resolve to the same catalog
|
||||
// ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") —
|
||||
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row
|
||||
// per ingredient (schema.prisma), so submitting both would otherwise fail
|
||||
// (`createRecipeSchema` now rejects it, see its own doc comment). Blocked
|
||||
// here too, with each duplicate row highlighted, rather than letting the
|
||||
// user find out only after clicking "Importer".
|
||||
const duplicateIngredientIds = (() => {
|
||||
const seen = new Set<number>();
|
||||
const duplicates = new Set<number>();
|
||||
for (const line of ingredientLines) {
|
||||
if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id);
|
||||
seen.add(line.ingredient.id);
|
||||
}
|
||||
return duplicates;
|
||||
})();
|
||||
const hasDuplicateIngredient = duplicateIngredientIds.size > 0;
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
Number.isInteger(Number(portions)) &&
|
||||
Number(portions) > 0 &&
|
||||
ingredientLines.length > 0 &&
|
||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
||||
!hasDuplicateIngredient &&
|
||||
unresolvedIngredients.length === 0 &&
|
||||
steps.length > 0 &&
|
||||
steps.every((step) => step.description.trim().length > 0);
|
||||
|
|
@ -368,6 +394,7 @@ export function RecipeImportForm({
|
|||
quantity={line.quantity}
|
||||
unitId={line.unitId}
|
||||
unitsCatalog={unitsCatalog}
|
||||
duplicate={duplicateIngredientIds.has(line.ingredient.id)}
|
||||
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
||||
onRemove={() => removeIngredientLine(line.key)}
|
||||
|
|
@ -424,6 +451,12 @@ export function RecipeImportForm({
|
|||
</section>
|
||||
|
||||
{formError && <p className="form-error">{formError}</p>}
|
||||
{!canSubmit && hasIngredientMissingUnit && (
|
||||
<p className="form-error">{t("recipes.form.incompleteIngredientsHint")}</p>
|
||||
)}
|
||||
{!canSubmit && hasDuplicateIngredient && (
|
||||
<p className="form-error">{t("recipes.form.duplicateIngredientsHint")}</p>
|
||||
)}
|
||||
|
||||
<div className="recipe-form__actions">
|
||||
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel";
|
||||
import { apiClient } from "../../../api/client";
|
||||
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
|
||||
import { SourceItemTable } from "./SourceItemTable";
|
||||
import "./recipes.scss";
|
||||
import "../recipes.scss";
|
||||
|
||||
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
import "../recipes.scss";
|
||||
|
||||
/**
|
||||
* List of one source's browsable items (`RecipeSourcesPanel`) — same
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
type CreateRecipeInput,
|
||||
type RecipeImportDraftView,
|
||||
createRecipeSchema,
|
||||
type RecipeImportDraftView,
|
||||
} from "@batch-cooking/shared";
|
||||
|
||||
/**
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { SourceView } from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { apiClient } from "../../../api/client";
|
||||
|
||||
export type EnabledSourcesState =
|
||||
| { status: "loading" }
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { StepTechStepView } from "@batch-cooking/shared";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "../../components/ui/Tooltip";
|
||||
import { Tooltip } from "../../../components/ui/Tooltip";
|
||||
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
||||
|
||||
/**
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { makeClientKey } from "../../lib/client-key";
|
||||
import "./recipes.scss";
|
||||
import { makeClientKey } from "../../../lib/client-key";
|
||||
import "../recipes.scss";
|
||||
|
||||
/** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */
|
||||
export interface StepDraft {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ThemePreference } from "@batch-cooking/shared";
|
||||
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
|
|
@ -71,9 +71,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
}, [user?.id]);
|
||||
|
||||
const setTheme = useCallback(async (newTheme: ThemePreference) => {
|
||||
await apiClient.updatePreferences(newTheme);
|
||||
setThemeState(newTheme);
|
||||
applyTheme(newTheme);
|
||||
try {
|
||||
await apiClient.updatePreferences(newTheme);
|
||||
setThemeState(newTheme);
|
||||
applyTheme(newTheme);
|
||||
} catch (err) {
|
||||
// Rethrown as-is — the caller is what actually handles this (see this
|
||||
// context value's doc comment, "Throws ApiError on failure"); this
|
||||
// callback just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -264,58 +264,70 @@
|
|||
// A single class toggle on the root element — every nested rule below
|
||||
// just hides labels/chevrons and re-centers icons via CSS, no child
|
||||
// component needs to know the sidebar is collapsed.
|
||||
&.collapsed {
|
||||
width: 4.25rem;
|
||||
padding-left: var(--space-sm);
|
||||
padding-right: var(--space-sm);
|
||||
//
|
||||
// Scoped to `min-width: 641px` (the exact complement of the mobile
|
||||
// `@media (max-width: 640px)` block below): `isCollapsed` persists in
|
||||
// `localStorage` (`batchcooking:sidebarCollapsed`) independently of
|
||||
// viewport width, so a user who collapsed the desktop rail and then
|
||||
// shrinks the window (or opens the same session on a phone) still carries
|
||||
// that flag. Without this guard, `.app-sidebar.collapsed`'s `width:
|
||||
// 4.25rem`/`flex-direction: column` (specificity 0,2,0) beat the mobile
|
||||
// block's `.app-sidebar` rules (specificity 0,1,0) and crushed the
|
||||
// full-width horizontal bar into a tiny broken rail — see issue #27.
|
||||
@media (min-width: 641px) {
|
||||
&.collapsed {
|
||||
width: 4.25rem;
|
||||
padding-left: var(--space-sm);
|
||||
padding-right: var(--space-sm);
|
||||
|
||||
.app-sidebar__top {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.app-sidebar__top {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.app-sidebar__brand-full {
|
||||
display: none;
|
||||
}
|
||||
.app-sidebar__brand-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-sidebar__brand-mark {
|
||||
display: block;
|
||||
}
|
||||
.app-sidebar__brand-mark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-sidebar__collapse-toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.app-sidebar__collapse-toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.app-sidebar__nav a,
|
||||
.app-sidebar__settings-toggle,
|
||||
.app-sidebar__account-toggle {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.app-sidebar__nav a,
|
||||
.app-sidebar__settings-toggle,
|
||||
.app-sidebar__account-toggle {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.app-sidebar__settings-toggle-left {
|
||||
gap: 0;
|
||||
}
|
||||
.app-sidebar__settings-toggle-left {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.label,
|
||||
.chevron {
|
||||
display: none;
|
||||
}
|
||||
.label,
|
||||
.chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-sidebar__version {
|
||||
display: none;
|
||||
}
|
||||
.app-sidebar__version {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// The popover would otherwise shrink to the icon rail's own width,
|
||||
// squashing "Mon compte"/"Se déconnecter" — give it a normal,
|
||||
// comfortable width instead, still anchored to the rail's left edge.
|
||||
.app-sidebar__account-menu {
|
||||
left: 0;
|
||||
right: auto;
|
||||
width: 12rem;
|
||||
// The popover would otherwise shrink to the icon rail's own width,
|
||||
// squashing "Mon compte"/"Se déconnecter" — give it a normal,
|
||||
// comfortable width instead, still anchored to the rail's left edge.
|
||||
.app-sidebar__account-menu {
|
||||
left: 0;
|
||||
right: auto;
|
||||
width: 12rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,8 +194,15 @@ function AccountMenu() {
|
|||
/** Ends the session and returns to the login page. */
|
||||
async function handleLogout() {
|
||||
setIsOpen(false);
|
||||
await logout();
|
||||
navigate("/login");
|
||||
try {
|
||||
await logout();
|
||||
void navigate("/login");
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `AuthContext`'s `logout` already documents itself
|
||||
// as throwing `ApiError` on failure; this handler just isn't allowed
|
||||
// a bare `await` per the repo's async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
|
||||
|
|
|
|||
|
|
@ -13,18 +13,18 @@
|
|||
// every consumer passes `aria-hidden="true"` itself (unlike the old custom
|
||||
// `Icon` wrapper, lucide doesn't set this by default).
|
||||
export {
|
||||
Calendar as PlanningIcon,
|
||||
BookOpen as RecipesIcon,
|
||||
ShoppingCart as ShoppingListIcon,
|
||||
Settings as SettingsIcon,
|
||||
User as AccountIcon,
|
||||
Leaf as DietPreferencesIcon,
|
||||
Home as HouseholdIcon,
|
||||
Palette as UserPreferencesIcon,
|
||||
Info as CreditsIcon,
|
||||
Calendar as PlanningIcon,
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
Star as FavoriteIcon,
|
||||
Globe as PublicIcon,
|
||||
Rss as SourcesIcon,
|
||||
ExternalLink as SourceLinkIcon,
|
||||
Globe as PublicIcon,
|
||||
Home as HouseholdIcon,
|
||||
Info as CreditsIcon,
|
||||
Leaf as DietPreferencesIcon,
|
||||
Palette as UserPreferencesIcon,
|
||||
Rss as SourcesIcon,
|
||||
Settings as SettingsIcon,
|
||||
ShoppingCart as ShoppingListIcon,
|
||||
Star as FavoriteIcon,
|
||||
User as AccountIcon,
|
||||
} from "lucide-react";
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@
|
|||
"breads": "Pains",
|
||||
"rawDough": "Pâtes à cuire",
|
||||
"dairy": "Produits laitiers",
|
||||
"eggs": "Œufs",
|
||||
"eggs": "Oeufs",
|
||||
"plantBasedAlternatives": "Alternatives végétales",
|
||||
"spices": "Épices",
|
||||
"sauces": "Sauces",
|
||||
|
|
@ -260,6 +260,10 @@
|
|||
"quantityLabel": "Quantité",
|
||||
"unitLabel": "Unité",
|
||||
"unitPlaceholder": "Choisir une unité",
|
||||
"unitMissingHint": "Choisissez une unité pour cet ingrédient.",
|
||||
"duplicateIngredientHint": "Cet ingrédient apparaît plusieurs fois — retirez les doublons.",
|
||||
"incompleteIngredientsHint": "Complétez la quantité et l'unité des ingrédients surlignés ci-dessus avant de continuer.",
|
||||
"duplicateIngredientsHint": "Retirez les doublons parmi les ingrédients surlignés ci-dessus avant de continuer.",
|
||||
"removeIngredient": "Retirer cet ingrédient",
|
||||
"stepDescriptionPlaceholder": "Décrivez cette étape…",
|
||||
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",
|
||||
|
|
@ -414,7 +418,7 @@
|
|||
"allergens": {
|
||||
"gluten": "Gluten",
|
||||
"crustaceans": "Crustacés",
|
||||
"eggs": "Œufs",
|
||||
"eggs": "Oeufs",
|
||||
"fish": "Poissons",
|
||||
"peanuts": "Arachides",
|
||||
"soy": "Soja",
|
||||
|
|
@ -547,6 +551,9 @@
|
|||
"vealCutlet": "Escalope de veau",
|
||||
"porkTenderloin": "Filet mignon de porc",
|
||||
"porkChop": "Côte de porc",
|
||||
"groundVeal": "Veau haché",
|
||||
"groundPork": "Porc haché",
|
||||
"groundLamb": "Agneau haché",
|
||||
"lamb": "Agneau",
|
||||
"legOfLamb": "Gigot d'agneau",
|
||||
"baconLardons": "Lardons",
|
||||
|
|
@ -585,7 +592,9 @@
|
|||
"beefMuzzle": "Museau de bœuf",
|
||||
"grisonsDriedBeef": "Viande des Grisons",
|
||||
"chicken": "Poulet",
|
||||
"groundChicken": "Poulet haché",
|
||||
"turkey": "Dinde",
|
||||
"groundTurkey": "Dinde hachée",
|
||||
"duck": "Canard",
|
||||
"duckBreast": "Magret de canard",
|
||||
"quail": "Caille",
|
||||
|
|
@ -792,7 +801,9 @@
|
|||
"montDor": "Mont d'or",
|
||||
"kefir": "Kéfir",
|
||||
"greekYogurt": "Yaourt à la grecque",
|
||||
"egg": "Œuf",
|
||||
"egg": "Oeuf",
|
||||
"eggYolk": "Jaune d'oeuf",
|
||||
"eggWhite": "Blanc d'oeuf",
|
||||
"coconutMilk": "Lait de coco",
|
||||
"coconutCream": "Crème de coco",
|
||||
"almondMilk": "Lait d'amande",
|
||||
|
|
@ -834,6 +845,7 @@
|
|||
"fiveSpice": "Cinq épices",
|
||||
"garamMasala": "Garam masala",
|
||||
"corianderSeeds": "Graines de coriandre",
|
||||
"groundCoriander": "Coriandre en poudre",
|
||||
"cardamom": "Cardamome",
|
||||
"fenugreek": "Fenugrec",
|
||||
"jalapeno": "Piment jalapeño",
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import "./ComingSoonPage.scss";
|
||||
|
||||
interface ComingSoonPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder rendered by every section that has a route/sidebar entry but
|
||||
* no real feature behind it yet (Recettes, Liste de courses, Foyer &
|
||||
* profil — see `RecipesPage.tsx` etc.). One shared component instead of
|
||||
* three near-identical markup blocks; each page still gets its own file
|
||||
* (and its own copy, via i18n) so building out a real feature later means
|
||||
* rewriting one dedicated file, not splitting a generic route.
|
||||
*/
|
||||
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
|
||||
return (
|
||||
<div className="coming-soon-page">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,13 +2,13 @@ import { ErrorCode, loginSchema } from "@batch-cooking/shared";
|
|||
import { type FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useAuth } from "../features/auth/AuthContext";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { useAuth } from "../../features/auth/AuthContext";
|
||||
// Shared with SignupPage — see the file for why it's colocated in
|
||||
// features/auth/ rather than duplicated per page.
|
||||
import "../features/auth/auth-form.scss";
|
||||
import { fieldErrorsFrom } from "../lib/zod-errors";
|
||||
import { errorMessageService } from "../services/error-message.service";
|
||||
import "../../features/auth/auth-form.scss";
|
||||
import { fieldErrorsFrom } from "../../lib/zod-errors";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
|
||||
/**
|
||||
* Login form. Validates client-side first (via the shared `loginSchema`,
|
||||
|
|
@ -50,7 +50,7 @@ export function LoginPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await login(result.data);
|
||||
navigate("/");
|
||||
void navigate("/");
|
||||
} catch (err) {
|
||||
// ApiError.code is looked up through ErrorMessageService so the
|
||||
// label is centralized and localized — never display err.message
|
||||
|
|
@ -2,13 +2,13 @@ import { ErrorCode, signupSchema } from "@batch-cooking/shared";
|
|||
import { type FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useAuth } from "../features/auth/AuthContext";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { useAuth } from "../../features/auth/AuthContext";
|
||||
// Shared with LoginPage — see the file for why it's colocated in
|
||||
// features/auth/ rather than duplicated per page.
|
||||
import "../features/auth/auth-form.scss";
|
||||
import { fieldErrorsFrom } from "../lib/zod-errors";
|
||||
import { errorMessageService } from "../services/error-message.service";
|
||||
import "../../features/auth/auth-form.scss";
|
||||
import { fieldErrorsFrom } from "../../lib/zod-errors";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
|
||||
/**
|
||||
* Signup form (profile creation). Validates client-side first (via the
|
||||
|
|
@ -52,7 +52,7 @@ export function SignupPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await signup(result.data);
|
||||
navigate("/onboarding/regime");
|
||||
void navigate("/onboarding/regime");
|
||||
} catch (err) {
|
||||
// ApiError.code is looked up through ErrorMessageService so the
|
||||
// label is centralized and localized — never display err.message
|
||||
|
|
@ -42,6 +42,14 @@ export function OnboardingAllergensPage() {
|
|||
setAllergies(allergiesResult);
|
||||
setTotalSteps(house !== null ? 4 : 3);
|
||||
})
|
||||
.catch(() => {
|
||||
// Neither piece is essential for this last step to render — the
|
||||
// allergy list just stays empty (nothing to pick from) and the
|
||||
// step count keeps its 3-of-3 default, same "don't strand the
|
||||
// visitor over a transient failure" reasoning as
|
||||
// OnboardingSourcesPage's own catch, just with nothing to
|
||||
// navigate away to since this already is the last step.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
|
@ -57,7 +65,7 @@ export function OnboardingAllergensPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await apiClient.updateAllergyIds(allergyIds);
|
||||
navigate("/");
|
||||
void navigate("/");
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ export function OnboardingDietPage() {
|
|||
.then((result) => {
|
||||
if (!cancelled) setDiets(result);
|
||||
})
|
||||
// Not essential for this step to render — the diet list just stays
|
||||
// empty (nothing to pick, "Aucun régime particulier" still works)
|
||||
// rather than stranding the visitor over a transient failure, same
|
||||
// reasoning as OnboardingSourcesPage's own catch.
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
|
@ -48,7 +53,7 @@ export function OnboardingDietPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await apiClient.updateDiet(dietId);
|
||||
navigate("/onboarding/foyer");
|
||||
void navigate("/onboarding/foyer");
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ export function OnboardingHouseholdPage() {
|
|||
.then((result) => {
|
||||
if (!cancelled) setHouse(result);
|
||||
})
|
||||
// Not essential for this step to render — `house` just stays `null`
|
||||
// (same as a genuinely household-less profile) rather than
|
||||
// stranding the visitor over a transient failure, same reasoning as
|
||||
// OnboardingSourcesPage's own catch.
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
|
@ -51,7 +56,7 @@ export function OnboardingHouseholdPage() {
|
|||
* configure sources for without a household).
|
||||
*/
|
||||
function goToNextStep(hasHousehold: boolean) {
|
||||
navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
|
||||
void navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function OnboardingSourcesPage() {
|
|||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.length === 0) {
|
||||
navigate("/onboarding/allergenes", { replace: true });
|
||||
void navigate("/onboarding/allergenes", { replace: true });
|
||||
return;
|
||||
}
|
||||
setSources(result);
|
||||
|
|
@ -50,7 +50,7 @@ export function OnboardingSourcesPage() {
|
|||
// Nothing to configure sources for if we can't even list them — the
|
||||
// wizard shouldn't strand the visitor here over a transient failure
|
||||
// fetching an optional step's own data.
|
||||
if (!cancelled) navigate("/onboarding/allergenes", { replace: true });
|
||||
if (!cancelled) void navigate("/onboarding/allergenes", { replace: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -63,7 +63,7 @@ export function OnboardingSourcesPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await apiClient.updateHouseSourceIds(sourceIds);
|
||||
navigate("/onboarding/allergenes");
|
||||
void navigate("/onboarding/allergenes");
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
DateTime,
|
||||
addWeeks,
|
||||
buildCalendarMonth,
|
||||
DateTime,
|
||||
formatDateOnly,
|
||||
getWeekStart,
|
||||
toDateOnly,
|
||||
|
|
@ -15,8 +15,8 @@ import {
|
|||
} from "@batch-cooking/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiClient } from "../api/client";
|
||||
import { type PlanningSlot, RecipePickerDialog } from "../features/planning/RecipePickerDialog";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
||||
import "./planning-page.scss";
|
||||
|
||||
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { MEALS, type Meal, WEEK_DAYS, type WeekDay } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { RecipeImportForm } from "../features/recipes/RecipeImportForm";
|
||||
import "../features/recipes/recipes.scss";
|
||||
import { RecipeImportForm } from "../../features/recipes/sources/RecipeImportForm";
|
||||
import "../../features/recipes/recipes.scss";
|
||||
|
||||
/**
|
||||
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
|
||||
|
|
@ -72,7 +72,7 @@ export function ImportRecipePage() {
|
|||
externalId={externalId}
|
||||
planningSlot={planningSlot ?? undefined}
|
||||
onImported={({ recipe, planningItem }) => {
|
||||
navigate(planningItem ? "/" : `/recettes/${recipe.id}`);
|
||||
void navigate(planningItem ? "/" : `/recettes/${recipe.id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
import {
|
||||
type CreateRecipeInput,
|
||||
createRecipeSchema,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type IngredientView,
|
||||
type RecipeVisibility,
|
||||
type UnitView,
|
||||
createRecipeSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ApiError, apiClient } from "../api/client";
|
||||
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
||||
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
||||
import { IngredientRow } from "../features/recipes/IngredientRow";
|
||||
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
||||
import "../features/recipes/recipes.scss";
|
||||
import { makeClientKey } from "../lib/client-key";
|
||||
import { errorMessageService } from "../services/error-message.service";
|
||||
import { ApiError, apiClient } from "../../api/client";
|
||||
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
|
||||
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
|
||||
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
|
||||
import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor";
|
||||
import "../../features/recipes/recipes.scss";
|
||||
import { makeClientKey } from "../../lib/client-key";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
|
||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||
|
|
@ -138,6 +138,13 @@ export function RecipeFormPage() {
|
|||
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
|
||||
}
|
||||
|
||||
// Surfaced next to the submit button when it's the reason `canSubmit` is
|
||||
// false — same "don't leave the button silently disabled" reasoning as
|
||||
// `RecipeImportForm` (issue #53), just less likely to bite here since a
|
||||
// manually-added line starts with no unit by design, right where the
|
||||
// person is already looking.
|
||||
const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null);
|
||||
|
||||
// Gates the submit button — the schema (checked again on submit, see
|
||||
// `handleSubmit`) is the source of truth, this is just instant feedback
|
||||
// that doesn't need a round trip through zod on every keystroke.
|
||||
|
|
@ -189,7 +196,7 @@ export function RecipeFormPage() {
|
|||
recipeId !== null
|
||||
? await apiClient.updateRecipe(recipeId, result.data)
|
||||
: await apiClient.createRecipe(result.data);
|
||||
navigate(`/recettes/${saved.id}`);
|
||||
void navigate(`/recettes/${saved.id}`);
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
|
|
@ -294,6 +301,9 @@ export function RecipeFormPage() {
|
|||
</section>
|
||||
|
||||
{formError && <p className="form-error">{formError}</p>}
|
||||
{!canSubmit && hasIngredientMissingUnit && (
|
||||
<p className="form-error">{t("recipes.form.incompleteIngredientsHint")}</p>
|
||||
)}
|
||||
|
||||
<div className="recipe-form__actions">
|
||||
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
||||
|
|
@ -2,22 +2,25 @@ import { ErrorCode, type RecipeSummaryView } from "@batch-cooking/shared";
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { ApiError, apiClient } from "../api/client";
|
||||
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
||||
import { ApiError, apiClient } from "../../api/client";
|
||||
import {
|
||||
RecipeDetailPanel,
|
||||
type RecipeDetailState,
|
||||
} from "../../features/recipes/RecipeDetailPanel";
|
||||
import { RecipeTable } from "../../features/recipes/RecipeTable";
|
||||
import {
|
||||
isSourceTab,
|
||||
parseSourceTabValue,
|
||||
type RecipesPageTab,
|
||||
RecipeTabs,
|
||||
sourceTabValue,
|
||||
} from "../../features/recipes/RecipeTabs";
|
||||
import {
|
||||
RecipeSourcesPanel,
|
||||
type SourceItemSelection,
|
||||
} from "../features/recipes/RecipeSourcesPanel";
|
||||
import { RecipeTable } from "../features/recipes/RecipeTable";
|
||||
import {
|
||||
RecipeTabs,
|
||||
type RecipesPageTab,
|
||||
isSourceTab,
|
||||
parseSourceTabValue,
|
||||
sourceTabValue,
|
||||
} from "../features/recipes/RecipeTabs";
|
||||
import { useEnabledSources } from "../features/recipes/useEnabledSources";
|
||||
import "../features/recipes/recipes.scss";
|
||||
} from "../../features/recipes/sources/RecipeSourcesPanel";
|
||||
import { useEnabledSources } from "../../features/recipes/sources/useEnabledSources";
|
||||
import "../../features/recipes/recipes.scss";
|
||||
|
||||
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
|
@ -164,7 +167,7 @@ export function RecipesPage() {
|
|||
|
||||
/** After a delete, the removed recipe can no longer be selected, and the table must drop it too. */
|
||||
function handleDeleted(recipeId: number) {
|
||||
navigate("/recettes");
|
||||
void navigate("/recettes");
|
||||
setListState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? { status: "loaded", recipes: prev.recipes.filter((r) => r.id !== recipeId) }
|
||||
|
|
@ -219,7 +222,7 @@ export function RecipesPage() {
|
|||
}
|
||||
onSelectImportedRecipe={(recipe) => {
|
||||
setActiveTab("favoris");
|
||||
navigate(`/recettes/${recipe.id}`);
|
||||
void navigate(`/recettes/${recipe.id}`);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -30,7 +30,7 @@ export function AccountSettingsPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await deleteAccount(password);
|
||||
navigate("/login");
|
||||
void navigate("/login");
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setError(errorMessageService.getLabel(code));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import "./settings-pages.scss";
|
|||
/**
|
||||
* Third-party attribution — routed at `/parametres/credits`. Exists solely
|
||||
* to satisfy the CC BY 4.0 requirement on the ingredient icon set (see
|
||||
* `features/recipes/ingredient-icons.tsx`'s `FilledIcon`-based components):
|
||||
* `features/recipes/ingredients/ingredient-icons.tsx`'s `FilledIcon`-based components):
|
||||
* most of it comes from foodiconpack.com's free "Common ingredient icons"
|
||||
* and "Common Utensils" collections, which require crediting the source,
|
||||
* linking the license, and noting that the icons were adapted (recolored
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import {
|
||||
ErrorCode,
|
||||
type HouseView,
|
||||
type SourceView,
|
||||
renameHouseSchema,
|
||||
type SourceView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApiError, apiClient } from "../../api/client";
|
||||
import { useAuth } from "../../features/auth/AuthContext";
|
||||
|
|
@ -48,11 +48,15 @@ export function HouseholdSettingsPage() {
|
|||
const [loadError, setLoadError] = useState(false);
|
||||
const [house, setHouse] = useState<HouseView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadHouse();
|
||||
}, []);
|
||||
|
||||
function loadHouse() {
|
||||
// Stable across renders (empty deps — only closes over setters, which
|
||||
// React itself guarantees are stable) so the effect below only re-runs on
|
||||
// mount, not on every render. It used to be a plain function declaration,
|
||||
// a fresh reference every render — its `useEffect` dependency on it then
|
||||
// re-triggered the effect (and thus the fetch, and thus a state update,
|
||||
// and thus another render...) forever. `loadHouse` is also handed to
|
||||
// `NoHousehold`/`HasHousehold` below as their `onChanged` callback, so it
|
||||
// has to stay this stable regardless of the effect.
|
||||
const loadHouse = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setLoadError(false);
|
||||
return apiClient
|
||||
|
|
@ -60,7 +64,11 @@ export function HouseholdSettingsPage() {
|
|||
.then((result) => setHouse(result))
|
||||
.catch(() => setLoadError(true))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHouse();
|
||||
}, [loadHouse]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -300,6 +308,11 @@ function SourcesSection() {
|
|||
setSources(sourcesResult);
|
||||
setSourceIds(sourceIdsResult);
|
||||
})
|
||||
// Not essential for the rest of the page to render — the sources
|
||||
// list just stays empty (nothing to toggle) rather than stranding
|
||||
// the visitor over a transient failure, same reasoning as
|
||||
// OnboardingSourcesPage's own catch.
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
|
@ -352,9 +365,17 @@ function InviteCode({ code }: { code: string }) {
|
|||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Rethrown as-is — nothing meaningful to show inline for a clipboard
|
||||
// failure here (no error state on this small button); this handler
|
||||
// just isn't allowed a bare `await` per the repo's async/try-catch
|
||||
// convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -368,13 +389,7 @@ function InviteCode({ code }: { code: string }) {
|
|||
}
|
||||
|
||||
/** Admin-only button removing one specific member from the household. */
|
||||
function RemoveMemberButton({
|
||||
memberId,
|
||||
onChanged,
|
||||
}: {
|
||||
memberId: number;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
function RemoveMemberButton({ memberId, onChanged }: { memberId: number; onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
|
|
@ -383,6 +398,8 @@ function RemoveMemberButton({
|
|||
try {
|
||||
await apiClient.removeHouseMember(memberId);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
throw err; // see InviteCode's handleCopy() catch comment above
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
|
|
@ -406,6 +423,8 @@ function DeleteHouseholdSection({ onChanged }: { onChanged: () => void }) {
|
|||
try {
|
||||
await apiClient.deleteHouse();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
throw err; // see InviteCode's handleCopy() catch comment above
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
@ -454,6 +473,8 @@ function LeaveHouseholdSection({ onChanged }: { onChanged: () => void }) {
|
|||
try {
|
||||
await apiClient.leaveHouse();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
throw err; // see InviteCode's handleCopy() catch comment above
|
||||
} finally {
|
||||
setIsLeaving(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { ComingSoonPage } from "./ComingSoonPage";
|
||||
import { ComingSoonPage } from "../../components/ui/ComingSoonPage";
|
||||
|
||||
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */
|
||||
export function ShoppingListPage() {
|
||||
36
biome.json
36
biome.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.9/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
|
@ -7,13 +7,14 @@
|
|||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [
|
||||
"dist",
|
||||
"coverage",
|
||||
"**/node_modules",
|
||||
".pnpm-store",
|
||||
"cypress/videos",
|
||||
"cypress/screenshots"
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/coverage",
|
||||
"!**/node_modules",
|
||||
"!**/.pnpm-store",
|
||||
"!**/cypress/videos",
|
||||
"!**/cypress/screenshots"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
|
@ -22,13 +23,24 @@
|
|||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"preset": "recommended",
|
||||
"complexity": {
|
||||
"noUselessCatch": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConsole": {
|
||||
"level": "error",
|
||||
"options": { "allow": ["error", "warn", "info", "debug", "table", "assert"] }
|
||||
},
|
||||
"noExplicitAny": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"dev:web": "pnpm --filter web dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@biomejs/biome": "^2.5.9",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"pnpm": {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
// this package rather than hand-rolled `Date` arithmetic or a second,
|
||||
// differently-behaved date library creeping into one side only.
|
||||
|
||||
export * from "./date-only.js";
|
||||
export * from "./week.js";
|
||||
|
||||
// Re-exported so a consumer never needs its own direct `luxon` dependency
|
||||
// just to type a `DateTime` value passed to/from this package's functions.
|
||||
export { DateTime } from "luxon";
|
||||
export * from "./date-only.js";
|
||||
export * from "./week.js";
|
||||
|
|
|
|||
|
|
@ -31,16 +31,16 @@ export class ErrorHandlerService {
|
|||
*/
|
||||
public handle(error: unknown): ErrorHandlingResult {
|
||||
if (error instanceof ZodError) {
|
||||
return this.fromZodError(error);
|
||||
return this._fromZodError(error);
|
||||
}
|
||||
if (error instanceof HttpError) {
|
||||
return this.fromHttpError(error);
|
||||
return this._fromHttpError(error);
|
||||
}
|
||||
return this.fromUnknownError(error);
|
||||
return this._fromUnknownError(error);
|
||||
}
|
||||
|
||||
/** Request body/query failed schema validation — always a 400. */
|
||||
private fromZodError(error: ZodError): ErrorHandlingResult {
|
||||
private _fromZodError(error: ZodError): ErrorHandlingResult {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
|
|
@ -52,7 +52,7 @@ export class ErrorHandlerService {
|
|||
}
|
||||
|
||||
/** Our own typed error — status/code were decided by whoever threw it. */
|
||||
private fromHttpError(error: HttpError): ErrorHandlingResult {
|
||||
private _fromHttpError(error: HttpError): ErrorHandlingResult {
|
||||
return {
|
||||
status: error.status,
|
||||
body: { code: error.code, message: error.message },
|
||||
|
|
@ -60,11 +60,15 @@ export class ErrorHandlerService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Anything unrecognized: logged server-side (so it's still diagnosable)
|
||||
* but never leaks internal details to the client — always a generic 500.
|
||||
* Anything unrecognized: never leaks internal details to the client —
|
||||
* always a generic 500. Doesn't log the error itself — this class only
|
||||
* maps an error to `{ status, body }` (see the class doc comment); a
|
||||
* caller wanting this logged server-side does so on its own before/
|
||||
* around calling `handle()` (see `apps/api`'s `errorLogger` middleware,
|
||||
* which sees every error — including this exact "unrecognized" case —
|
||||
* before it ever reaches here).
|
||||
*/
|
||||
private fromUnknownError(error: unknown): ErrorHandlingResult {
|
||||
console.error(error);
|
||||
private _fromUnknownError(_error: unknown): ErrorHandlingResult {
|
||||
return {
|
||||
status: 500,
|
||||
body: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" },
|
||||
|
|
|
|||
|
|
@ -31,17 +31,17 @@ export interface ExpressServerCoreOptions {
|
|||
*/
|
||||
export class ExpressServer {
|
||||
/** The underlying Express application. */
|
||||
private readonly app: Express;
|
||||
private readonly _app: Express;
|
||||
/** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
|
||||
private readonly registeredRoutes = new Set<string>();
|
||||
private readonly _registeredRoutes = new Set<string>();
|
||||
|
||||
public constructor() {
|
||||
this.app = express();
|
||||
this._app = express();
|
||||
}
|
||||
|
||||
/** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */
|
||||
public get instance(): Express {
|
||||
return this.app;
|
||||
return this._app;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -51,14 +51,14 @@ export class ExpressServer {
|
|||
* any route.
|
||||
*/
|
||||
public setupCore(options: ExpressServerCoreOptions): void {
|
||||
this.app.use(cors({ origin: options.corsOrigin, credentials: true }));
|
||||
this.app.use(express.json());
|
||||
this.app.use(cookieParser());
|
||||
this._app.use(cors({ origin: options.corsOrigin, credentials: true }));
|
||||
this._app.use(express.json());
|
||||
this._app.use(cookieParser());
|
||||
}
|
||||
|
||||
/** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */
|
||||
public addMiddleware(middleware: RequestHandler): void {
|
||||
this.app.use(middleware);
|
||||
this._app.use(middleware);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -67,12 +67,12 @@ export class ExpressServer {
|
|||
* error handler by its arity, and only the last matching one runs.
|
||||
*/
|
||||
public setErrorHandler(middleware: ErrorRequestHandler): void {
|
||||
this.app.use(middleware);
|
||||
this._app.use(middleware);
|
||||
}
|
||||
|
||||
/** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */
|
||||
public mountRouter(basePath: string, router: Router): void {
|
||||
this.app.use(basePath, router);
|
||||
this._app.use(basePath, router);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,8 +88,8 @@ export class ExpressServer {
|
|||
* this, so `pnpm dev:web`'s own Vite dev server is unaffected.
|
||||
*/
|
||||
public serveStaticFrontend(distDir: string): void {
|
||||
this.app.use(express.static(distDir));
|
||||
this.app.get("*", (_req, res) => {
|
||||
this._app.use(express.static(distDir));
|
||||
this._app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(distDir, "index.html"));
|
||||
});
|
||||
}
|
||||
|
|
@ -101,16 +101,16 @@ export class ExpressServer {
|
|||
*/
|
||||
public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void {
|
||||
const key = `${method.toUpperCase()} ${path}`;
|
||||
if (this.registeredRoutes.has(key)) {
|
||||
if (this._registeredRoutes.has(key)) {
|
||||
console.warn(`[ExpressServer] Route already registered, skipping: ${key}`);
|
||||
return;
|
||||
}
|
||||
this.registeredRoutes.add(key);
|
||||
this.app[method](path, ...handlers);
|
||||
this._registeredRoutes.add(key);
|
||||
this._app[method](path, ...handlers);
|
||||
}
|
||||
|
||||
/** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */
|
||||
public listen(port: number, onListening?: () => void): void {
|
||||
this.app.listen(port, onListening);
|
||||
this._app.listen(port, onListening);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,9 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
vealCutlet: "Veal cutlet",
|
||||
porkTenderloin: "Pork tenderloin",
|
||||
porkChop: "Pork chop",
|
||||
groundVeal: "Ground veal",
|
||||
groundPork: "Ground pork",
|
||||
groundLamb: "Ground lamb",
|
||||
lamb: "Lamb",
|
||||
legOfLamb: "Leg of lamb",
|
||||
baconLardons: "Bacon lardons",
|
||||
|
|
@ -177,7 +180,9 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
|
||||
// Poultry
|
||||
chicken: "Chicken",
|
||||
groundChicken: "Ground chicken",
|
||||
turkey: "Turkey",
|
||||
groundTurkey: "Ground turkey",
|
||||
duck: "Duck",
|
||||
duckBreast: "Duck breast",
|
||||
quail: "Quail",
|
||||
|
|
@ -403,6 +408,8 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
|
||||
// Eggs
|
||||
egg: "Egg",
|
||||
eggYolk: "Egg yolk",
|
||||
eggWhite: "Egg white",
|
||||
|
||||
// Plant-based alternatives
|
||||
coconutMilk: "Coconut milk",
|
||||
|
|
@ -448,6 +455,7 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
fiveSpice: "Five-spice powder",
|
||||
garamMasala: "Garam masala",
|
||||
corianderSeeds: "Coriander seeds",
|
||||
groundCoriander: "Ground coriander",
|
||||
cardamom: "Cardamom",
|
||||
fenugreek: "Fenugreek",
|
||||
jalapeno: "Jalapeño",
|
||||
|
|
@ -600,6 +608,24 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
caneSyrup: "Cane syrup",
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra English matching phrases for a handful of {@link INGREDIENT_LABELS_EN}
|
||||
* entries whose primary label doesn't cover a common alternate wording used
|
||||
* by real recipe sources (e.g. TheMealDB's `"Vanilla Pod"` vs. this
|
||||
* catalog's own `"Vanilla bean"`) — see issue #54. Deliberately a *second*,
|
||||
* mostly-empty map rather than widening `INGREDIENT_LABELS_EN` itself to
|
||||
* `string | string[]` for every one of its ~550 entries: only entries that
|
||||
* actually need an alternate wording are listed here, so this stays a
|
||||
* short, easy-to-scan list of exceptions instead of turning every other
|
||||
* entry into a single-item array for no reason. `apps/api`'s
|
||||
* `ingredient-matcher.ts` (`loadIngredientCatalog`) adds one extra catalog
|
||||
* entry per synonym, alongside the primary label — matched exactly the same
|
||||
* way, `matchIngredientName` itself doesn't need to know synonyms exist.
|
||||
*/
|
||||
export const INGREDIENT_LABEL_SYNONYMS_EN: Record<string, string[]> = {
|
||||
vanillaBean: ["Vanilla pod"],
|
||||
};
|
||||
|
||||
/**
|
||||
* English matching synonyms for the `Unit` reference catalog
|
||||
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue