Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait etre bloque (creation manuelle) ou perdre silencieusement la ligne (import). Une ligne de recette accepte desormais `placeholderName` (texte libre) au lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder` (cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont exclues de `GET /reference/ingredients` et de `ingredient-matcher`. Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker` (formulaire + import), badge "a completer" sur la ligne, helper `ingredientLabel` applique partout ou un libelle d'ingredient est rendu. Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des orphelins. La promotion en vraie entree catalogue reste manuelle. Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible). Suites Mocha DB-backed ecrites, non executees en session ; test pur `normalizePlaceholderName` + Cypress admin-web/web verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
175 lines
6 KiB
TypeScript
175 lines
6 KiB
TypeScript
import type {
|
|
AllergyView,
|
|
DietView,
|
|
IngredientView,
|
|
SourceView,
|
|
TechStepView,
|
|
UnitView,
|
|
UtensilView,
|
|
} 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[]> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[]> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[]> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[]> {
|
|
try {
|
|
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
|
} catch (err) {
|
|
throw err; // see getDiets()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* All reference cooking utensils, ordered by key (see {@link getDiets} for
|
|
* why) — small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
|
|
* same bare `id`/`key` shape as {@link getTechSteps}.
|
|
*/
|
|
export async function getUtensils(): Promise<UtensilView[]> {
|
|
try {
|
|
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
|
|
} catch (err) {
|
|
throw err; // see getDiets()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[]> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: []`.
|
|
*
|
|
* Excludes placeholder rows (`Ingredient.isPlaceholder` — the free-text
|
|
* ingredients users type when the catalog falls short): this is the
|
|
* *browsable* catalog, and a placeholder is a per-recipe-line stand-in, not
|
|
* a real entry anyone should be able to pick again.
|
|
*/
|
|
export async function getIngredients(): Promise<IngredientView[]> {
|
|
try {
|
|
const ingredients = await prisma.ingredient.findMany({
|
|
where: { isPlaceholder: false },
|
|
include: {
|
|
allergies: { include: { allergy: { include: { category: true } } } },
|
|
diets: { include: { diet: true } },
|
|
},
|
|
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,
|
|
})),
|
|
// Always a real catalog row here (placeholders are filtered out above).
|
|
isPlaceholder: false,
|
|
displayName: null,
|
|
}));
|
|
} catch (err) {
|
|
throw err; // see getDiets()'s catch comment above
|
|
}
|
|
}
|