batchCooking/apps/api/src/modules/reference/reference.service.ts
Nicolas 766d48eaa5 feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle
Répond à deux besoins : permettre à chaque foyer de choisir quelles
sources apparaissent dans ses onglets de recettes, et distinguer les
sources à API officielle des sources scrapées.

- RecipeSourceAdapter.official (booléen, sans défaut — chaque
  adaptateur doit le déclarer explicitement) synchronisé sur
  Source.official par syncRecipeSources.
- HouseSource : table de jointure opt-in (House <-> Source) — aucune
  ligne = source masquée. Un foyer nouvellement créé ne voit aucune
  source tant qu'il ne les active pas explicitement.
- GET /reference/sources (catalogue des sources implémentées, avec le
  flag officiel).
- GET/PATCH /house/current/sources (lecture/remplacement complet des
  sources activées par le foyer courant).
- recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS
  les onglets (perso/foyer/publique/favoris) — une recette sans
  source reste toujours visible ; une recette importée ne l'est que
  si sa source est activée pour le foyer du viewer. Un viewer sans
  foyer ne voit aucune recette sourcée.

Côté web :
- Nouvelle étape /onboarding/sources dans le wizard d'inscription,
  atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on
  saute direct aux allergènes) ; s'auto-saute aussi si aucune source
  n'est encore implémentée (catalogue vide aujourd'hui).
- Nouvelle section « Sources de recettes » dans /parametres/foyer
  (masquée dans les mêmes conditions), avec sauvegarde à la volée
  (même pattern que les autres préférences hot-saved).
- SourceSelect (features/house/), grille de cases à cocher avec badge
  officiel/non-officielle, sur le même principe qu'AllergySelect.

172 tests backend passent (dont 25 nouveaux). Build et lint propres.
Vérifié manuellement en navigateur : le parcours d'onboarding saute
bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un
foyer a été créé ; la section paramètres reste invisible tant
qu'aucune source n'existe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:22:54 +02:00

115 lines
4.2 KiB
TypeScript

import type {
AllergyView,
DietView,
IngredientView,
SourceView,
TechStepView,
UnitView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* All reference dietary regimes, ordered by `key` — small, static list (see
* prisma/seed.ts). `key` is a stable slug, not the display label (see
* {@link DietView}), so this is an alphabetical-by-slug order rather than a
* true French alphabetical one — close enough for a 5-item list, and the
* server has no other order to offer now that the label itself only exists
* client-side (`apps/web`'s `locales/fr/translation.json`).
*/
export async function getDiets(): Promise<DietView[]> {
return prisma.diet.findMany({ orderBy: { key: "asc" } });
}
/**
* All reference allergens, ordered by key (see {@link getDiets} for why key,
* not label). `Allergy` carries no `key` of its own — it's the selectable
* instance of a keyed `Category` (see schema.prisma) — so this resolves
* each allergen's key from its category and flattens the split away for
* 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,
}));
}
/**
* All reference recipe-ingredient units, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s `UNITS`).
* `toBaseFactor` comes back as a Prisma `Decimal`, converted to a plain
* `number` here the same way `recipe.service.ts` does for
* `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),
}));
}
/**
* All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
*/
export async function getTechSteps(): Promise<TechStepView[]> {
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
}
/**
* Every implemented recipe source, ordered by name (not `key` — unlike
* every other reference catalog, `name` here *is* the display string a
* household picks from, see {@link SourceView}, so alphabetical-by-name is
* what a real picker should show). Empty until a concrete adapter is
* registered (see `recipe-source-registry.ts`) and synced (see
* `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 },
orderBy: { name: "asc" },
});
}
/**
* All reference ingredients, ordered by key (see {@link getDiets} for why),
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
* and compatible diet regimes (see `IngredientDiet`) — same flattening
* approach as {@link getAllergies}. Ingredients with no linked
* 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 })),
}));
}