feat(recipes): ingrédients hors-catalogue (placeholders) + revue admin #16

Merged
kyuno merged 1 commit from feat/off-catalog-ingredients into feat/admin-tech-steps 2026-08-29 09:42:58 +02:00
43 changed files with 1815 additions and 116 deletions

View file

@ -20,7 +20,7 @@ describe("Admin layout", () => {
cy.contains("h1", "Administration").should("be.visible"); cy.contains("h1", "Administration").should("be.visible");
}); });
it("shows the sidebar and navigates between the three sections", () => { it("shows the sidebar and navigates between the sections", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
cy.visit("/"); cy.visit("/");
@ -36,6 +36,11 @@ describe("Admin layout", () => {
cy.url().should("include", "/corrections"); cy.url().should("include", "/corrections");
cy.contains("h1", "Corrections").should("be.visible"); cy.contains("h1", "Corrections").should("be.visible");
cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] });
cy.contains("nav a", "Catalogue").click();
cy.url().should("include", "/catalogue");
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
cy.contains("nav a", "Tableau de bord").click(); cy.contains("nav a", "Tableau de bord").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/`); cy.url().should("eq", `${Cypress.config().baseUrl}/`);
}); });

View file

@ -0,0 +1,96 @@
// Mocks the admin API via cy.intercept — no live backend.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
function pendingGroups() {
return [
{
normalizedName: "piment d espelette",
displayNames: ["Piment d'Espelette", "piment d espelette"],
ingredientIds: [11, 12],
recipeCount: 2,
sampleRecipes: [
{ id: 1, name: "Poulet basquaise" },
{ id: 2, name: "Piperade" },
],
firstSeenAt: "2026-08-20T10:00:00.000Z",
allReviewed: false,
},
{
normalizedName: "sumac",
displayNames: ["Sumac"],
ingredientIds: [13],
recipeCount: 1,
sampleRecipes: [{ id: 3, name: "Fattoush" }],
firstSeenAt: "2026-08-22T10:00:00.000Z",
allReviewed: false,
},
];
}
describe("Admin catalog — off-catalog ingredients", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
});
it("lists placeholder groups newest-impact first with their recipe count and spelling variants", () => {
cy.intercept("GET", "**/admin/catalog/placeholders*", {
statusCode: 200,
body: pendingGroups(),
}).as("getPlaceholders");
cy.visit("/catalogue");
cy.wait("@getPlaceholders");
cy.get(".catalog-card").should("have.length", 2);
cy.get(".catalog-card").first().should("contain.text", "Piment d'Espelette");
cy.contains(".catalog-card", "Piment d'Espelette")
.should("contain.text", "2 recette")
.and("contain.text", "piment d espelette")
.and("contain.text", "Poulet basquaise");
});
it("marks a group reviewed and reloads the list", () => {
cy.intercept("GET", "**/admin/catalog/placeholders*", {
statusCode: 200,
body: pendingGroups(),
}).as("getPlaceholders");
cy.intercept("PATCH", "**/admin/catalog/placeholders/mark-reviewed", {
statusCode: 200,
body: { reviewed: 1 },
}).as("markReviewed");
cy.visit("/catalogue");
cy.wait("@getPlaceholders");
cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click();
cy.wait("@markReviewed")
.its("request.body")
.should("deep.equal", { ingredientIds: [13] });
// The page re-fetches the list after the PATCH.
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
});
it("switches to the reviewed archive tab", () => {
cy.intercept("GET", "**/admin/catalog/placeholders", {
statusCode: 200,
body: pendingGroups(),
});
cy.intercept("GET", "**/admin/catalog/placeholders?reviewed=true", {
statusCode: 200,
body: [],
}).as("getReviewed");
cy.visit("/catalogue");
cy.contains(".catalog-tabs button", "Traités").click();
cy.wait("@getReviewed");
cy.contains("Aucun ingrédient hors-catalogue").should("be.visible");
});
});

View file

@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { RequireAdmin } from "./features/auth/RequireAdmin"; import { RequireAdmin } from "./features/auth/RequireAdmin";
import { AdminLayout } from "./layouts/AdminLayout"; import { AdminLayout } from "./layouts/AdminLayout";
import { CatalogPage } from "./pages/catalog/CatalogPage";
import { CorrectionsPage } from "./pages/corrections/CorrectionsPage"; import { CorrectionsPage } from "./pages/corrections/CorrectionsPage";
import { DashboardPage } from "./pages/dashboard/DashboardPage"; import { DashboardPage } from "./pages/dashboard/DashboardPage";
import { LoginPage } from "./pages/login/LoginPage"; import { LoginPage } from "./pages/login/LoginPage";
@ -27,6 +28,7 @@ export function App() {
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/monitoring" element={<MonitoringPage />} /> <Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/corrections" element={<CorrectionsPage />} /> <Route path="/corrections" element={<CorrectionsPage />} />
<Route path="/catalogue" element={<CatalogPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View file

@ -2,10 +2,13 @@ import {
type AdminLoginInput, type AdminLoginInput,
type AdminUserView, type AdminUserView,
type ApiErrorResponse, type ApiErrorResponse,
type CatalogPlaceholderGroupView,
type CorrectionAdminView, type CorrectionAdminView,
ErrorCode, ErrorCode,
type MarkPlaceholdersReviewedInput,
type MetricsView, type MetricsView,
type MonitoringView, type MonitoringView,
type PruneOrphansResultView,
type RetrainRequestInput, type RetrainRequestInput,
type RetrainResultView, type RetrainResultView,
type TrainingDataSnippetView, type TrainingDataSnippetView,
@ -163,6 +166,26 @@ export class AdminApiClient {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} }
/** Off-catalog ingredient "placeholders" users typed, grouped by normalized name. `reviewed` omitted/`"false"` = the still-to-triage list, `"true"` = the archive. */
public getPlaceholders(reviewed?: "true" | "false"): Promise<CatalogPlaceholderGroupView[]> {
return this._request(`/admin/catalog/placeholders${query({ reviewed })}`);
}
/** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */
public markPlaceholdersReviewed(
body: MarkPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
return this._request("/admin/catalog/placeholders/mark-reviewed", {
method: "PATCH",
body: JSON.stringify(body),
});
}
/** Deletes placeholder rows no recipe references any more. */
public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" });
}
} }
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */

View file

@ -1,4 +1,4 @@
import { Activity, LayoutDashboard, ListChecks } from "lucide-react"; import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
@ -13,6 +13,7 @@ const NAV_ITEMS = [
{ to: "/", key: "dashboard", Icon: LayoutDashboard, end: true }, { to: "/", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/catalogue", key: "catalog", Icon: PackageSearch, end: false },
] as const; ] as const;
/** /**

View file

@ -24,7 +24,8 @@
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"monitoring": "Monitoring", "monitoring": "Monitoring",
"corrections": "Corrections" "corrections": "Corrections",
"catalog": "Catalogue"
}, },
"layout": { "layout": {
"logout": "Se déconnecter" "logout": "Se déconnecter"
@ -123,6 +124,25 @@
"created": "Créée", "created": "Créée",
"consumed": "Consommée" "consumed": "Consommée"
} }
},
"catalog": {
"title": "Ingrédients hors-catalogue",
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
"empty": "Aucun ingrédient hors-catalogue.",
"tab": {
"pending": "À traiter",
"reviewed": "Traités"
},
"recipeCount": "{{count}} recette(s)",
"alsoWritten": "Aussi écrit : {{variants}}",
"seenIn": "Vu dans :",
"firstSeen": "Première fois le {{date}}",
"markReviewed": "Marquer comme traité",
"marking": "…",
"pruneOrphans": "Purger les orphelins",
"pruning": "Purge…",
"prunedNone": "Aucun placeholder orphelin à purger.",
"pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)."
} }
} }
} }

View file

@ -0,0 +1,169 @@
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../api/client";
import "../admin-page.scss";
import "./catalog-page.scss";
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
type CatalogState =
| { status: "loading" }
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
| { status: "error" };
/**
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the
* free text users typed when the seeded catalog fell short see
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
* name, so a maintainer sees what the catalog is missing and how many
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
* as handled, or purge rows no recipe references any more. Actually adding
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
* locale files.
*/
export function CatalogPage() {
const { t } = useTranslation();
const [tab, setTab] = useState<CatalogTab>("pending");
const [state, setState] = useState<CatalogState>({ status: "loading" });
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
const [isPruning, setIsPruning] = useState(false);
const load = useCallback((forTab: CatalogTab) => {
setState({ status: "loading" });
adminApiClient
.getPlaceholders(reviewedParam(forTab))
.then((groups) => setState({ status: "loaded", groups }))
.catch(() => setState({ status: "error" }));
}, []);
useEffect(() => {
load(tab);
}, [tab, load]);
function markReviewed(group: CatalogPlaceholderGroupView) {
setPendingIds(group.ingredientIds);
adminApiClient
.markPlaceholdersReviewed({ ingredientIds: group.ingredientIds })
.then(() => load(tab))
.catch(() => load(tab))
.finally(() => setPendingIds(null));
}
function pruneOrphans() {
setIsPruning(true);
setPruneMessage(null);
adminApiClient
.pruneOrphanPlaceholders()
.then(({ deleted }) => {
setPruneMessage(
deleted === 0
? t("admin.catalog.prunedNone")
: t("admin.catalog.pruned", { count: deleted }),
);
load(tab);
})
.catch(() => setPruneMessage(t("admin.common.loadError")))
.finally(() => setIsPruning(false));
}
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
<div className="catalog-toolbar">
<div className="catalog-tabs" role="tablist">
{(["pending", "reviewed"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
className={tab === value ? "active" : undefined}
onClick={() => setTab(value)}
>
{t(`admin.catalog.tab.${value}`)}
</button>
))}
</div>
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
</button>
</div>
{pruneMessage && <p className="catalog-prune-message">{pruneMessage}</p>}
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" &&
(state.groups.length === 0 ? (
<p className="admin-page__placeholder">{t("admin.catalog.empty")}</p>
) : (
<ul className="catalog-list">
{state.groups.map((group) => (
<PlaceholderGroupCard
key={group.normalizedName}
group={group}
busy={pendingIds === group.ingredientIds}
onMarkReviewed={() => markReviewed(group)}
/>
))}
</ul>
))}
</div>
);
}
function PlaceholderGroupCard({
group,
busy,
onMarkReviewed,
}: {
group: CatalogPlaceholderGroupView;
busy: boolean;
onMarkReviewed: () => void;
}) {
const { t } = useTranslation();
const { headline, variants } = splitSpellings(group);
const firstSeen = formatDate(group.firstSeenAt);
return (
<li className="catalog-card">
<div className="catalog-card__head">
<h2 className="catalog-card__name">{headline}</h2>
<span className="catalog-card__count">
{t("admin.catalog.recipeCount", { count: group.recipeCount })}
</span>
</div>
{variants.length > 0 && (
<p className="catalog-card__variants">
{t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })}
</p>
)}
{group.sampleRecipes.length > 0 && (
<p className="catalog-card__recipes">
{t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")}
</p>
)}
<div className="catalog-card__foot">
{firstSeen && (
<span className="catalog-card__seen">
{t("admin.catalog.firstSeen", { date: firstSeen })}
</span>
)}
{!group.allReviewed && (
<button type="button" onClick={onMarkReviewed} disabled={busy}>
{busy ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
</button>
)}
</div>
</li>
);
}

View file

@ -0,0 +1,144 @@
// =============================================================================
// CatalogPage the off-catalog ingredient review: a pending/reviewed tab
// switch + "purge orphans" action, then one card per grouped placeholder.
// Mirrors CorrectionsPage's tab/card vocabulary so the admin app stays
// visually consistent.
// =============================================================================
.catalog-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-md);
// Right-hand "purge orphans" button a secondary/destructive action, so
// outlined rather than filled like the primary actions elsewhere.
> button {
padding: var(--space-xs) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
border-color: var(--color-error);
color: var(--color-error);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.catalog-tabs {
display: flex;
gap: var(--space-xs);
button {
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&.active {
color: var(--color-primary);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}
}
}
.catalog-prune-message {
margin: 0 0 var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.catalog-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.catalog-card {
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent);
border-radius: var(--radius-md);
&__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
}
&__name {
font-size: var(--font-size-md);
margin: 0;
}
&__count {
flex-shrink: 0;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
}
&__variants,
&__recipes {
margin: var(--space-xs) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
&__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
&__seen {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
&__foot button {
padding: var(--space-xs) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}

View file

@ -0,0 +1,34 @@
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
/**
* Pure helpers for `CatalogPage` kept out of the `.tsx` per repo
* convention, unit-tested on their own.
*/
/** Which server-side list a UI tab maps to — `pending` sends no `reviewed` param (the working list), `reviewed` sends `reviewed=true` (the archive). */
export type CatalogTab = "pending" | "reviewed";
/** `CatalogTab` → the `reviewed` query value `adminApiClient.getPlaceholders` expects. */
export function reviewedParam(tab: CatalogTab): "true" | undefined {
return tab === "reviewed" ? "true" : undefined;
}
/**
* Splits a group's spellings into the one to show as the card title and the
* rest to list as "aussi écrit : …". The API already sorts `displayNames`
* alphabetically and none is more canonical than another, so the first is
* as good a headline as any the point of the group is that they're the
* same missing ingredient.
*/
export function splitSpellings(group: CatalogPlaceholderGroupView): {
headline: string;
variants: string[];
} {
const [headline = group.normalizedName, ...variants] = group.displayNames;
return { headline, variants };
}
/** `"2026-08-28T09:00:00.000Z"` → `"28/08/2026"` for the "première fois le …" line. `null` → `null`. */
export function formatDate(iso: string | null): string | null {
return iso === null ? null : new Date(iso).toLocaleDateString("fr-FR");
}

View file

@ -0,0 +1,14 @@
-- AlterTable: off-catalog ingredient "placeholder" rows. Every existing row
-- is a real seeded catalog entry, so the flag defaults to false and the four
-- new nullable columns stay NULL for them — no backfill needed.
ALTER TABLE "ingredients" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ingredients" ADD COLUMN "display_name" TEXT;
ALTER TABLE "ingredients" ADD COLUMN "created_by_id" INTEGER;
ALTER TABLE "ingredients" ADD COLUMN "created_at" TIMESTAMP(3);
ALTER TABLE "ingredients" ADD COLUMN "reviewed_at" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "ingredients_is_placeholder_idx" ON "ingredients"("is_placeholder");
-- AddForeignKey
ALTER TABLE "ingredients" ADD CONSTRAINT "ingredients_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -129,6 +129,9 @@ model UserProfile {
/// view a recipe may correct its tech-step matches, not just its author — /// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`). /// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[] techStepCorrections StepTechStepCorrection[]
/// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
@@map("user_profiles") @@map("user_profiles")
} }
@ -530,6 +533,38 @@ model Ingredient {
/// ingredient↔recipe linking in the database, the UI only pre-fills the /// ingredient↔recipe linking in the database, the UI only pre-fills the
/// catalog's own search with this ingredient's name). /// catalog's own search with this ingredient's name).
reproducible Boolean @default(false) reproducible Boolean @default(false)
/// `true` = a "placeholder" row: a free-text ingredient a user typed on a
/// recipe line because the seeded catalog had nothing matching (see
/// specs/batch-cooking-modele.md's "ingrédients hors-catalogue"). Such a
/// row has `displayName` non-null, a generated `key` (`placeholder:<uuid>`,
/// never an i18n label), and the default metadata (`JAR`/`dryGoods`/`other`,
/// no allergen/diet links). It is referenced by `RecipeIngredient` like any
/// other `Ingredient`, but `GET /reference/ingredients` and
/// `ingredient-matcher.ts`'s `loadIngredientCatalog` both exclude it — it is
/// never a browsable/matchable target, only a per-line stand-in a
/// maintainer later promotes into a real catalog entry by hand. The
/// `/admin/catalog/placeholders` view groups these by normalized name so
/// the maintainer sees which ingredients the catalog is missing.
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// Display name of a placeholder ingredient — the exact text the user
/// typed. `null` for a real catalog row (whose label lives in i18n under
/// `catalog.ingredients.<key>`). Invariant "non-null iff `isPlaceholder`"
/// is enforced service-side, not by the schema (same posture as other
/// cross-field invariants here).
displayName String? @map("display_name")
/// Profile that first created this placeholder — context for the admin
/// catalog-gap review. `onDelete: SetNull` so deleting an account never
/// blocks on, or cascades into, the recipes that still use its placeholder.
/// `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was created. `null` for a real catalog row (the
/// seed carries no timestamp).
createdAt DateTime? @map("created_at")
/// Stamped when an admin has triaged this catalog gap
/// (`PATCH /admin/catalog/placeholders/mark-reviewed`) — the group then
/// drops out of the default "à traiter" list. `null` while pending / for a
/// real catalog row.
reviewedAt DateTime? @map("reviewed_at")
recipes RecipeIngredient[] recipes RecipeIngredient[]
allergies IngredientAllergy[] allergies IngredientAllergy[]
@ -540,7 +575,10 @@ model Ingredient {
/// Mentions of this ingredient detected in a step's free text alongside a /// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`. /// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[] stepTechSteps StepTechStepIngredient[]
/// The profile that created this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("PlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("ingredients") @@map("ingredients")
} }

View file

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

View file

@ -0,0 +1,44 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
listPlaceholderGroups,
markPlaceholdersReviewed,
pruneOrphanPlaceholders,
} from "./admin-catalog.service.js";
/**
* Router mounted at `/admin/catalog` (via `admin.routes.ts`) every route
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
* "placeholders" users typed when the seeded catalog fell short, so a
* maintainer can see what's missing and mark gaps as handled.
*/
export const adminCatalogRouter = Router();
adminCatalogRouter.get(
"/placeholders",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listPlaceholderGroups(listPlaceholdersQuerySchema.parse(req.query)));
}),
);
adminCatalogRouter.patch(
"/placeholders/mark-reviewed",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await markPlaceholdersReviewed(markPlaceholdersReviewedSchema.parse(req.body)));
}),
);
/** Deletes placeholder rows no recipe references any more — see {@link pruneOrphanPlaceholders}. */
adminCatalogRouter.post(
"/placeholders/prune-orphans",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await pruneOrphanPlaceholders());
}),
);

View file

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

View file

@ -1,5 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js"; import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminMonitoringRouter } from "./admin-monitoring.routes.js"; import { adminMonitoringRouter } from "./admin-monitoring.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
@ -8,8 +9,9 @@ import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
* Aggregator for the admin application's API surface, mounted at `/admin` * Aggregator for the admin application's API surface, mounted at `/admin`
* in `app.ts`. Every sub-router here is for `apps/admin-web` only * in `app.ts`. Every sub-router here is for `apps/admin-web` only
* `/admin/auth` is public (login), everything added later * `/admin/auth` is public (login), everything added later
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`,
* behind `requireAdmin` (`middlewares/require-admin.ts`). * `/admin/catalog/*`) sits behind `requireAdmin`
* (`middlewares/require-admin.ts`).
*/ */
export const adminRouter = Router(); export const adminRouter = Router();
@ -17,3 +19,4 @@ adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter); adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/monitoring", adminMonitoringRouter); adminRouter.use("/monitoring", adminMonitoringRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter);

View file

@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
type AllergyView, type AllergyView,
@ -109,6 +110,12 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
kind: allergy.category.kind, kind: allergy.category.kind,
})), })),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
// A placeholder line round-trips as a normal `Ingredient` with a real
// id — the frontend tells it apart by this flag (badge, no
// allergen/diet info) and shows `displayName` verbatim instead of
// looking up an i18n label that doesn't exist for a `placeholder:` key.
isPlaceholder: ingredient.isPlaceholder,
displayName: ingredient.displayName,
}; };
} }
@ -542,6 +549,69 @@ async function matchStepsTechSteps<T extends { description: string }>(
); );
} }
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */
interface ResolvedIngredientLine {
ingredientId: number;
quantity: number;
unitId: number;
}
/**
* Turns each `input.ingredients` line into a {@link ResolvedIngredientLine}:
* a line that already carries an `ingredientId` (a catalog pick, or an
* existing placeholder round-tripping through an edit) passes through
* unchanged; a `placeholderName` line gets a fresh placeholder `Ingredient`
* row (`isPlaceholder: true`, a generated `placeholder:<uuid>` key, the
* typed text as `displayName`, stamped with `authorId`/now) created via
* `tx` so it rolls back together with the recipe if anything later in the
* same transaction fails, never leaving an orphan behind.
*
* Returns the created placeholders separately so the caller can emit one
* `ingredient.placeholder_created` analytics event per row *after* the
* transaction commits (the recipe id it wants in the event context doesn't
* exist yet in here).
*/
async function resolveIngredientLines(
lines: CreateRecipeInput["ingredients"],
authorId: number,
tx: Prisma.TransactionClient,
): Promise<{
resolved: ResolvedIngredientLine[];
createdPlaceholders: { id: number; name: string }[];
}> {
try {
const resolved: ResolvedIngredientLine[] = [];
const createdPlaceholders: { id: number; name: string }[] = [];
for (const line of lines) {
if (line.ingredientId !== undefined) {
resolved.push({
ingredientId: line.ingredientId,
quantity: line.quantity,
unitId: line.unitId,
});
continue;
}
// `recipeIngredientInputSchema`'s refine guarantees the other branch.
const name = (line.placeholderName ?? "").trim();
const placeholder = await tx.ingredient.create({
data: {
key: `placeholder:${randomUUID()}`,
isPlaceholder: true,
displayName: name,
createdById: authorId,
createdAt: new Date(),
},
select: { id: true },
});
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
createdPlaceholders.push({ id: placeholder.id, name });
}
return { resolved, createdPlaceholders };
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
async function createRecipeInternal( async function createRecipeInternal(
input: CreateRecipeInput, input: CreateRecipeInput,
authorId: number, authorId: number,
@ -549,7 +619,9 @@ async function createRecipeInternal(
source: { sourceId: number; externalId: string; locale: string } | null, source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> { ): Promise<RecipeView> {
try { try {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertIngredientsExist(
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
);
await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds); await assertDietsExist(input.dietIds);
// Matched up front (one call per step, in parallel) rather than inline // Matched up front (one call per step, in parallel) rather than inline
@ -562,7 +634,16 @@ async function createRecipeInternal(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE, source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
); );
const created = await prisma.recipe.create({ // One transaction so the free-text placeholder `Ingredient` rows and the
// recipe that references them commit together — a failed recipe create
// must never leave orphan placeholders behind.
const { created, createdPlaceholders } = await prisma.$transaction(async (tx) => {
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
authorId,
tx,
);
const created = await tx.recipe.create({
data: { data: {
name: input.name, name: input.name,
description: input.description ?? null, description: input.description ?? null,
@ -574,10 +655,10 @@ async function createRecipeInternal(
sourceId: source?.sourceId ?? null, sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null, externalId: source?.externalId ?? null,
ingredients: { ingredients: {
create: input.ingredients.map((ingredient) => ({ create: resolved.map((line) => ({
ingredientId: ingredient.ingredientId, ingredientId: line.ingredientId,
quantity: ingredient.quantity, quantity: line.quantity,
unitId: ingredient.unitId, unitId: line.unitId,
})), })),
}, },
steps: { steps: {
@ -617,11 +698,19 @@ async function createRecipeInternal(
}, },
include: recipeInclude(authorId), include: recipeInclude(authorId),
}); });
return { created, createdPlaceholders };
});
analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", { analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", {
actorId: authorId, actorId: authorId,
context: { recipeId: created.id, sourceId: source?.sourceId ?? null }, context: { recipeId: created.id, sourceId: source?.sourceId ?? null },
}); });
for (const placeholder of createdPlaceholders) {
analytics.recordEvent("ingredient.placeholder_created", {
actorId: authorId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: created.id },
});
}
return toRecipeView(created); return toRecipeView(created);
} catch (err) { } catch (err) {
@ -650,16 +739,30 @@ export async function updateRecipe(
): Promise<RecipeView> { ): Promise<RecipeView> {
try { try {
await assertIsAuthor(id, viewerId, viewerHouseId); await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertIngredientsExist(
input.ingredients
.map((i) => i.ingredientId)
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
);
await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds); await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
await prisma.$transaction([ // Interactive transaction (not the array form) so any new free-text
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }), // placeholder rows are created in the same atomic unit as the
prisma.step.deleteMany({ where: { recipeId: id } }), // delete+recreate of the recipe's content. An *existing* placeholder
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }), // line round-trips by its real `ingredientId` and is left untouched;
prisma.recipe.update({ // only a brand-new `placeholderName` line creates a row here.
const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } });
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
viewerId,
tx,
);
await tx.recipe.update({
where: { id }, where: { id },
data: { data: {
name: input.name, name: input.name,
@ -668,10 +771,10 @@ export async function updateRecipe(
portions: input.portions, portions: input.portions,
visibility: input.visibility, visibility: input.visibility,
ingredients: { ingredients: {
create: input.ingredients.map((ingredient) => ({ create: resolved.map((line) => ({
ingredientId: ingredient.ingredientId, ingredientId: line.ingredientId,
quantity: ingredient.quantity, quantity: line.quantity,
unitId: ingredient.unitId, unitId: line.unitId,
})), })),
}, },
steps: { steps: {
@ -693,8 +796,16 @@ export async function updateRecipe(
}, },
diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
}), });
]); return createdPlaceholders;
});
for (const placeholder of createdPlaceholders) {
analytics.recordEvent("ingredient.placeholder_created", {
actorId: viewerId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: id },
});
}
return toRecipeView(await findRecipeOrThrow(id, viewerId)); return toRecipeView(await findRecipeOrThrow(id, viewerId));
} catch (err) { } catch (err) {

View file

@ -133,10 +133,16 @@ export async function getSources(): Promise<SourceView[]> {
* and compatible diet regimes (see `IngredientDiet`) same flattening * and compatible diet regimes (see `IngredientDiet`) same flattening
* approach as {@link getAllergies}. Ingredients with no linked * approach as {@link getAllergies}. Ingredients with no linked
* allergen/diet come back with `allergens: []`/`diets: []`. * 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[]> { export async function getIngredients(): Promise<IngredientView[]> {
try { try {
const ingredients = await prisma.ingredient.findMany({ const ingredients = await prisma.ingredient.findMany({
where: { isPlaceholder: false },
include: { include: {
allergies: { include: { allergy: { include: { category: true } } } }, allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } }, diets: { include: { diet: true } },
@ -159,6 +165,9 @@ export async function getIngredients(): Promise<IngredientView[]> {
id: diet.id, id: diet.id,
key: diet.key, key: diet.key,
})), })),
// Always a real catalog row here (placeholders are filtered out above).
isPlaceholder: false,
displayName: null,
})); }));
} catch (err) { } catch (err) {
throw err; // see getDiets()'s catch comment above throw err; // see getDiets()'s catch comment above

View file

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

View file

@ -0,0 +1,32 @@
import { expect } from "chai";
import { normalizePlaceholderName } from "../src/modules/admin/admin-catalog.service.js";
/**
* Pure unit tests for {@link normalizePlaceholderName} the grouping key
* that collapses near-duplicate placeholder spellings into one catalog gap.
* No database, so this file can run standalone (`mocha --no-config`) as
* well as inside the full suite.
*/
describe("normalizePlaceholderName", () => {
it("lower-cases, strips accents and collapses whitespace", () => {
expect(normalizePlaceholderName(" Piment d'Espelette ")).to.equal("piment d espelette");
expect(normalizePlaceholderName("PIMENT DESPELETTE")).to.equal("piment d espelette");
expect(normalizePlaceholderName("piment d espelette")).to.equal("piment d espelette");
});
it("neutralises punctuation to a single space", () => {
expect(normalizePlaceholderName("sel & poivre")).to.equal("sel poivre");
expect(normalizePlaceholderName("sel, poivre")).to.equal("sel poivre");
expect(normalizePlaceholderName("fleur-de-sel")).to.equal("fleur de sel");
});
it("keeps digits (a quantity baked into the name still distinguishes it)", () => {
expect(normalizePlaceholderName("Chocolat 70%")).to.equal("chocolat 70");
});
it("maps an all-punctuation / empty string to an empty key", () => {
expect(normalizePlaceholderName("")).to.equal("");
expect(normalizePlaceholderName(" ")).to.equal("");
expect(normalizePlaceholderName("--- ///")).to.equal("");
});
});

View file

@ -0,0 +1,141 @@
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** A recipe with one placeholder ingredient line whose `displayName` is `name`. Returns the placeholder `Ingredient` id. */
async function seedPlaceholderRecipe(name: string): Promise<number> {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const unit = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
const placeholder = await prisma.ingredient.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: name,
createdById: author.id,
createdAt: new Date(),
},
});
await prisma.recipe.create({
data: {
name: faker.lorem.words(3),
authorId: author.id,
portions: 2,
ingredients: { create: [{ ingredientId: placeholder.id, quantity: 1, unitId: unit.id }] },
},
});
return placeholder.id;
}
/**
* `/admin/catalog/*` the off-catalog ingredient review. Every route is
* behind `requireAdmin`; the list groups placeholder rows by normalized
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
* no recipe references any more.
*/
describe("Admin catalog — off-catalog ingredients", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
const get = await request(app).get("/admin/catalog/placeholders");
expect(get.status).to.equal(401);
const patch = await request(app)
.patch("/admin/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [1] });
expect(patch.status).to.equal(401);
const post = await request(app).post("/admin/catalog/placeholders/prune-orphans");
expect(post.status).to.equal(401);
});
it("groups two spellings of the same missing ingredient into one row", async () => {
await seedPlaceholderRecipe("Piment d'Espelette");
await seedPlaceholderRecipe("piment d espelette");
await seedPlaceholderRecipe("Sumac");
const agent = await adminAgent();
const res = await agent.get("/admin/catalog/placeholders");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
const espelette = res.body.find(
(g: { normalizedName: string }) => g.normalizedName === "piment d espelette",
);
expect(espelette.recipeCount).to.equal(2);
expect(espelette.ingredientIds).to.have.length(2);
expect(espelette.displayNames).to.have.members(["Piment d'Espelette", "piment d espelette"]);
// Impact-ordered: the 2-recipe gap before the 1-recipe one.
expect(res.body[0].normalizedName).to.equal("piment d espelette");
});
it("mark-reviewed stamps reviewedAt and moves the group out of the default list", async () => {
const id = await seedPlaceholderRecipe("Galanga");
const agent = await adminAgent();
const patched = await agent
.patch("/admin/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [id] });
expect(patched.status).to.equal(200);
expect(patched.body.reviewed).to.equal(1);
expect(
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
).to.be.an.instanceOf(Date);
const pending = await agent.get("/admin/catalog/placeholders");
expect(pending.body).to.have.length(0);
const reviewed = await agent.get("/admin/catalog/placeholders").query({ reviewed: "true" });
expect(reviewed.body).to.have.length(1);
expect(reviewed.body[0].allReviewed).to.equal(true);
});
it("prune-orphans deletes only placeholder rows with no recipe left", async () => {
await seedPlaceholderRecipe("Encore utilisé");
await prisma.ingredient.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: "Orphelin",
createdAt: new Date(),
},
});
const agent = await adminAgent();
const res = await agent.post("/admin/catalog/placeholders/prune-orphans");
expect(res.status).to.equal(200);
expect(res.body.deleted).to.equal(1);
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
});
});

View file

@ -0,0 +1,196 @@
import type { SignupInput } from "@batch-cooking/shared";
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 { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — generated, never a real-looking person. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Resolves a reference unit's id by its seed uid (also its DB `key`). */
async function unitId(key: string): Promise<number> {
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
}
/** Resolves a reference ingredient's id by its seed uid. */
async function ingredientId(key: string): Promise<number> {
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
}
/**
* The off-catalog ("placeholder") ingredient escape hatch on `POST /recipes`
* / `PATCH /recipes/:id` a line with `placeholderName` instead of
* `ingredientId` creates a dedicated `Ingredient` row
* (`isPlaceholder: true`) so the recipe still saves, and that row is
* surfaced (with its `displayName`) inside the recipe but never in the
* browsable catalog.
*/
describe("Recipes — off-catalog placeholder ingredients", () => {
const app = createApp();
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profileId: res.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("creates a placeholder Ingredient row for a `placeholderName` line and returns it inside the recipe", async () => {
const { agent, profileId } = await signup();
const piece = await unitId("piece");
const tomato = await ingredientId("tomato");
const res = await agent.post("/recipes").send({
name: "Poulet basquaise",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomato, quantity: 3, unitId: piece },
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
],
steps: [{ description: "Tout mélanger" }],
});
expect(res.status).to.equal(201);
expect(res.body.ingredients).to.have.length(2);
const placeholderLine = res.body.ingredients.find(
(line: { ingredient: { isPlaceholder: boolean } }) => line.ingredient.isPlaceholder,
);
expect(placeholderLine, "a placeholder line is present").to.not.equal(undefined);
expect(placeholderLine.ingredient.displayName).to.equal("Piment d'Espelette");
expect(placeholderLine.ingredient.key).to.match(/^placeholder:/);
expect(placeholderLine.ingredient.allergens).to.deep.equal([]);
expect(placeholderLine.quantity).to.equal(1);
const row = await prisma.ingredient.findUniqueOrThrow({
where: { id: placeholderLine.ingredient.id },
});
expect(row.isPlaceholder).to.equal(true);
expect(row.displayName).to.equal("Piment d'Espelette");
expect(row.createdById).to.equal(profileId);
expect(row.createdAt).to.be.an.instanceOf(Date);
expect(row.reviewedAt).to.equal(null);
});
it("never lists placeholder rows in GET /reference/ingredients", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
await agent.post("/recipes").send({
name: "Test",
portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
steps: [{ description: "x" }],
});
const reference = await agent.get("/reference/ingredients");
expect(reference.status).to.equal(200);
expect(
reference.body.some(
(i: { isPlaceholder?: boolean; displayName?: string }) =>
i.isPlaceholder === true || i.displayName === "Feuille de combava",
),
).to.equal(false);
});
it("reuses the existing placeholder row on edit (no duplicate) and drops it when the line is removed", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
const tomato = await ingredientId("tomato");
const created = await agent.post("/recipes").send({
name: "Édition",
portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
steps: [{ description: "x" }],
});
const placeholderId = created.body.ingredients[0].ingredient.id;
// Re-submit the same recipe, keeping the placeholder line by its real id.
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition",
portions: 2,
dietIds: [],
ingredients: [
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
{ ingredientId: tomato, quantity: 1, unitId: piece },
],
steps: [{ description: "x" }],
});
expect(edited.status).to.equal(200);
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
// Now edit again, dropping the placeholder line entirely.
await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition",
portions: 2,
dietIds: [],
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
steps: [{ description: "x" }],
});
// The row is now an orphan (kept on purpose — the admin catalog view
// prunes it), but no *new* placeholder was created.
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
});
it("rejects a line carrying both ingredientId and placeholderName with 400", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
const tomato = await ingredientId("tomato");
const res = await agent.post("/recipes").send({
name: "Invalide",
portions: 2,
dietIds: [],
ingredients: [
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
],
steps: [{ description: "x" }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("allows two placeholder lines with the same text (each becomes its own row)", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Doublons libres",
portions: 2,
dietIds: [],
ingredients: [
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
],
steps: [{ description: "x" }],
});
expect(res.status).to.equal(201);
expect(res.body.ingredients).to.have.length(2);
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(2);
});
});

View file

@ -45,6 +45,21 @@ Feature: Recipe form — associating ingredients
And I add a step And I add a step
Then there should be 2 step editor items Then there should be 2 step editor items
Scenario: Adds an off-catalog ingredient as a free-text placeholder when nothing in the picker matches
Given creating the recipe will succeed and return id 43
When I visit "/recettes/nouvelle"
And I fill in the "recipe-name" field with "Poulet basquaise"
And I search the ingredient picker for "piment d'espelette"
Then the picker should offer to add "piment d'espelette" as a placeholder
When I add "piment d'espelette" as a placeholder ingredient
Then the recipe should include the placeholder ingredient "piment d'espelette"
When I fill in the ingredient's quantity with "1" and unit "unité"
And I add a step
And I fill in the step description with "Tout mélanger."
And I click the button "Enregistrer"
Then the recipe creation request should have included a placeholder ingredient "piment d'espelette" with quantity 1 and unitId 1
And the URL should include "/recettes/43"
Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back
When I visit "/recettes/nouvelle" When I visit "/recettes/nouvelle"
And I select the ingredient "Carotte" from the picker And I select the ingredient "Carotte" from the picker

View file

@ -70,6 +70,31 @@ Then(
}, },
); );
Then("the picker should offer to add {string} as a placeholder", (name: string) => {
cy.get(".ingredient-picker__add-placeholder").should("contain.text", name);
});
When("I add {string} as a placeholder ingredient", () => {
// The search field already holds the query from the previous step, so the
// "add « … »" button carries the right name.
cy.get(".ingredient-picker__add-placeholder").click();
});
Then("the recipe should include the placeholder ingredient {string}", (name: string) => {
cy.contains(".ingredient-row__name", name)
.find(".ingredient-row__placeholder-badge")
.should("be.visible");
});
Then(
"the recipe creation request should have included a placeholder ingredient {string} with quantity {int} and unitId {int}",
(placeholderName: string, quantity: number, unitId: number) => {
cy.wait("@createRecipe")
.its("request.body.ingredients")
.should("deep.equal", [{ placeholderName, quantity, unitId }]);
},
);
Given("recipe 7 exists with an egg omelette", () => { Given("recipe 7 exists with an egg omelette", () => {
const existingRecipe = { const existingRecipe = {
id: 7, id: 7,

View file

@ -17,6 +17,7 @@ import { Dialog } from "../../components/ui/Dialog";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { DietTagSelect } from "../recipes/badges/DietTagSelect"; import { DietTagSelect } from "../recipes/badges/DietTagSelect";
import { IngredientPicker } from "../recipes/ingredients/IngredientPicker"; import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
import { ingredientLabel } from "../recipes/ingredients/ingredient-label";
import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel"; import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
import { RecipeTable } from "../recipes/RecipeTable"; import { RecipeTable } from "../recipes/RecipeTable";
import { import {
@ -447,7 +448,7 @@ export function RecipePickerDialog({
<div className="recipe-picker__chips"> <div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => ( {selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip"> <span key={ingredient.id} className="filter-chip">
{t(`catalog.ingredients.${ingredient.key}`)} {ingredientLabel(ingredient, t)}
<button <button
type="button" type="button"
onClick={() => onClick={() =>

View file

@ -11,6 +11,7 @@ import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
// picker" visual family, even though this field lives in the profile // picker" visual family, even though this field lives in the profile
// feature). // feature).
import { IngredientTypeIcon } from "../recipes/ingredients/ingredient-icons"; import { IngredientTypeIcon } from "../recipes/ingredients/ingredient-icons";
import { ingredientLabel } from "../recipes/ingredients/ingredient-label";
import "../recipes/recipes.scss"; import "../recipes/recipes.scss";
import "./profile-forms.scss"; import "./profile-forms.scss";
@ -54,7 +55,7 @@ export function DislikedIngredientsField({
<span aria-hidden="true"> <span aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} /> <IngredientTypeIcon icon={ingredient.icon} />
</span> </span>
{t(`catalog.ingredients.${ingredient.key}`)} {ingredientLabel(ingredient, t)}
<button <button
type="button" type="button"
onClick={() => remove(ingredient.id)} onClick={() => remove(ingredient.id)}

View file

@ -7,6 +7,7 @@ import { SourceLinkIcon } from "../../layouts/nav-icons";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { AllergenBadges } from "./badges/AllergenBadges"; import { AllergenBadges } from "./badges/AllergenBadges";
import { FavoriteStarButton } from "./badges/FavoriteStarButton"; import { FavoriteStarButton } from "./badges/FavoriteStarButton";
import { ingredientLabel } from "./ingredients/ingredient-label";
import { StepDescription } from "./steps/StepDescription"; import { StepDescription } from "./steps/StepDescription";
import "./recipes.scss"; import "./recipes.scss";
@ -180,7 +181,7 @@ export function RecipeDetailPanel({
<ul className="disliked-badges"> <ul className="disliked-badges">
{dislikedIngredients.map((ingredient) => ( {dislikedIngredients.map((ingredient) => (
<li key={ingredient.id} className="disliked-badge"> <li key={ingredient.id} className="disliked-badge">
🚫 {t(`catalog.ingredients.${ingredient.key}`)} 🚫 {ingredientLabel(ingredient, t)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -13,6 +13,7 @@ import { AllergenBadges } from "../badges/AllergenBadges";
import { DietBadges } from "../badges/DietBadges"; import { DietBadges } from "../badges/DietBadges";
import { ReproducibleBadge } from "../badges/ReproducibleBadge"; import { ReproducibleBadge } from "../badges/ReproducibleBadge";
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons"; import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
import { ingredientLabel } from "./ingredient-label";
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). */ /** "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). */
@ -40,15 +41,25 @@ const ALL = "ALL" as const;
* just browsing/searching by name. A labeled checkbox menu rather than two * just browsing/searching by name. A labeled checkbox menu rather than two
* bare icon-only toggle buttons those turned out too ambiguous on their * bare icon-only toggle buttons those turned out too ambiguous on their
* own (unclear what each icon meant without a label attached). * own (unclear what each icon meant without a label attached).
*
* `onAddPlaceholder`, when passed, turns the "aucun ingrédient trouvé"
* empty state into an escape hatch: with a non-empty search query, an
* "ajouter « … » comme ingrédient à compléter" button lets the user add a
* free-text line so the catalog gap doesn't block them (see
* `Ingredient.isPlaceholder` in schema.prisma). Omitted on pickers where
* that makes no sense a profile's disliked-ingredients field, a search
* filter.
*/ */
export function IngredientPicker({ export function IngredientPicker({
ingredients, ingredients,
excludeIds, excludeIds,
onSelect, onSelect,
onAddPlaceholder,
}: { }: {
ingredients: IngredientView[]; ingredients: IngredientView[];
excludeIds: number[]; excludeIds: number[];
onSelect: (ingredient: IngredientView) => void; onSelect: (ingredient: IngredientView) => void;
onAddPlaceholder?: (name: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL); const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
@ -80,17 +91,26 @@ export function IngredientPicker({
// key — searching "œuf" should find "Œuf" the way it always has, not // key — searching "œuf" should find "Œuf" the way it always has, not
// require typing its key. // require typing its key.
if (normalizedQuery.length > 0) { if (normalizedQuery.length > 0) {
const label = t(`catalog.ingredients.${ingredient.key}`).toLowerCase(); const label = ingredientLabel(ingredient, t).toLowerCase();
if (!label.includes(normalizedQuery)) return false; if (!label.includes(normalizedQuery)) return false;
} }
return true; return true;
}); });
const trimmedQuery = query.trim();
function handleSelect(ingredient: IngredientView) { function handleSelect(ingredient: IngredientView) {
onSelect(ingredient); onSelect(ingredient);
setQuery(""); setQuery("");
} }
function handleAddPlaceholder() {
if (onAddPlaceholder && trimmedQuery.length > 0) {
onAddPlaceholder(trimmedQuery);
setQuery("");
}
}
return ( return (
<div className="ingredient-picker"> <div className="ingredient-picker">
<div className="ingredient-picker__search"> <div className="ingredient-picker__search">
@ -171,7 +191,18 @@ export function IngredientPicker({
)} )}
{visible.length === 0 ? ( {visible.length === 0 ? (
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p> <div className="ingredient-picker__empty">
<p>{t("recipes.form.noIngredientFound")}</p>
{onAddPlaceholder && trimmedQuery.length > 0 && (
<button
type="button"
className="ingredient-picker__add-placeholder"
onClick={handleAddPlaceholder}
>
{t("recipes.form.addPlaceholderButton", { name: trimmedQuery })}
</button>
)}
</div>
) : ( ) : (
<div className="ingredient-picker__grid"> <div className="ingredient-picker__grid">
{visible.map((ingredient) => ( {visible.map((ingredient) => (
@ -184,9 +215,7 @@ export function IngredientPicker({
<span className="ingredient-picker__card-icon" aria-hidden="true"> <span className="ingredient-picker__card-icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} /> <IngredientTypeIcon icon={ingredient.icon} />
</span> </span>
<span className="ingredient-picker__card-name"> <span className="ingredient-picker__card-name">{ingredientLabel(ingredient, t)}</span>
{t(`catalog.ingredients.${ingredient.key}`)}
</span>
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />} {showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
{showDiets && <DietBadges diets={ingredient.diets} />} {showDiets && <DietBadges diets={ingredient.diets} />}
{showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />} {showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />}

View file

@ -4,6 +4,7 @@ import { AllergenBadges } from "../badges/AllergenBadges";
import { DietBadges } from "../badges/DietBadges"; import { DietBadges } from "../badges/DietBadges";
import { ReproducibleBadge } from "../badges/ReproducibleBadge"; import { ReproducibleBadge } from "../badges/ReproducibleBadge";
import { IngredientTypeIcon } from "./ingredient-icons"; import { IngredientTypeIcon } from "./ingredient-icons";
import { ingredientLabel } from "./ingredient-label";
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. */ /** 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. */
@ -36,13 +37,25 @@ export function IngredientRow({
// them, or why). // them, or why).
const unitMissing = unitId === null; const unitMissing = unitId === null;
const needsAttention = unitMissing || duplicate; const needsAttention = unitMissing || duplicate;
// A free-text placeholder line (the catalog had nothing matching) — shown
// with an "à compléter" badge and none of the allergen/diet/reproducible
// badges, which carry no meaning until a maintainer promotes it to a real
// catalog entry. Its quantity/unit still work exactly like any other line.
const isPlaceholder = ingredient.isPlaceholder;
return ( return (
<li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}> <li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}>
<span className="ingredient-row__icon" aria-hidden="true"> <span className="ingredient-row__icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} /> <IngredientTypeIcon icon={ingredient.icon} />
</span> </span>
<span className="ingredient-row__name">{t(`catalog.ingredients.${ingredient.key}`)}</span> <span className="ingredient-row__name">
{ingredientLabel(ingredient, t)}
{isPlaceholder && (
<span className="ingredient-row__placeholder-badge">
{t("recipes.form.placeholderBadge")}
</span>
)}
</span>
<input <input
type="number" type="number"
min="0" min="0"
@ -68,12 +81,16 @@ export function IngredientRow({
</option> </option>
))} ))}
</select> </select>
{!isPlaceholder && (
<>
<AllergenBadges allergens={ingredient.allergens} /> <AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} /> <DietBadges diets={ingredient.diets} />
<ReproducibleBadge <ReproducibleBadge
reproducible={ingredient.reproducible} reproducible={ingredient.reproducible}
searchLabel={t(`catalog.ingredients.${ingredient.key}`)} searchLabel={ingredientLabel(ingredient, t)}
/> />
</>
)}
<button <button
type="button" type="button"
className="ingredient-row__remove" className="ingredient-row__remove"

View file

@ -0,0 +1,26 @@
import type { IngredientView } from "@batch-cooking/shared";
/**
* The display name for an ingredient line.
*
* A real catalog ingredient has no name in the database its French label
* lives in i18n under `catalog.ingredients.<key>` so it's resolved
* through `t`. A **placeholder** (`IngredientView.isPlaceholder`, the
* free text a user typed when the catalog fell short see
* `Ingredient.isPlaceholder` in schema.prisma) has its name stored on the
* row as `displayName` and no i18n key at all, so it's shown verbatim.
*
* Every place that used to inline `t(\`catalog.ingredients.${ingredient.key}\`)`
* goes through this instead, so a placeholder never renders as the raw,
* missing translation key `catalog.ingredients.placeholder:<uuid>`.
*
* `t` is passed in (rather than calling `useTranslation` here) so this
* stays a plain function usable from non-component code and unit-testable
* without mounting i18next same split as `shopping-list.ts`.
*/
export function ingredientLabel(
ingredient: Pick<IngredientView, "key" | "displayName">,
t: (key: string) => string,
): string {
return ingredient.displayName ?? t(`catalog.ingredients.${ingredient.key}`);
}

View file

@ -0,0 +1,50 @@
import type { IngredientView } from "@batch-cooking/shared";
/**
* Sentinel `id` for a placeholder ingredient line the user just added in
* the recipe form but that has **no database row yet** the API creates
* the real `Ingredient` row (with a real id) when the recipe is saved (see
* `recipe.service.ts`'s `resolveIngredientLines`). Never sent to the API:
* the submit payload uses `placeholderName`, not `ingredientId`, for these
* lines. Kept out of `excludeIds`/duplicate checks by testing
* `isPlaceholder` rather than the id.
*/
export const UNSAVED_PLACEHOLDER_ID = 0;
/**
* Builds the in-memory {@link IngredientView} for a brand-new placeholder
* line (the "add « … » as an ingredient to complete" action in
* `IngredientPicker`). Shaped exactly like what the API returns for a
* placeholder once persisted `isPlaceholder: true`, the typed text as
* `displayName`, a generic `JAR` icon, default aisle, no allergen/diet
* info so `IngredientRow` renders it through the same path as an
* edit-loaded placeholder with no special-casing.
*/
export function makePlaceholderIngredientView(name: string): IngredientView {
return {
id: UNSAVED_PLACEHOLDER_ID,
key: "",
icon: "JAR",
category: "dryGoods",
subcategory: "other",
reproducible: false,
allergens: [],
diets: [],
isPlaceholder: true,
displayName: name,
};
}
/**
* Whether this line is a placeholder the user just added that has **no
* database row yet** the submit payload must describe it with
* `placeholderName` (asking the API to create the row) rather than
* `ingredientId`. An *edit-loaded* placeholder fails this test: it already
* has a real id and round-trips as a normal `{ ingredientId }` line, so
* editing a recipe never spawns a duplicate placeholder row.
*/
export function isUnsavedPlaceholder(
ingredient: Pick<IngredientView, "id" | "isPlaceholder">,
): boolean {
return ingredient.isPlaceholder && ingredient.id === UNSAVED_PLACEHOLDER_ID;
}

View file

@ -1218,6 +1218,19 @@
margin-right: auto; margin-right: auto;
} }
// Marks a free-text placeholder line (nothing in the catalog matched)
// the ingredient still needs a real catalog entry created by a
// maintainer, but the line is usable (quantity/unit) in the meantime.
&__placeholder-badge {
margin-left: var(--space-xs);
padding: 0 var(--space-xs);
font-size: var(--font-size-xs);
font-weight: 500;
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text-muted);
}
&__quantity { &__quantity {
width: 5rem; width: 5rem;
} }
@ -1512,11 +1525,37 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
// Now a container (not a bare `<p>`) the "aucun ingrédient trouvé"
// message plus, when the picker allows it, an "ajouter « … » comme
// ingrédient à compléter" escape-hatch button stacked under it.
&__empty { &__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md); padding: var(--space-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: center; text-align: center;
p {
margin: 0;
}
}
&__add-placeholder {
padding: var(--space-xs) var(--space-md);
font-size: var(--font-size-sm);
cursor: pointer;
border: 1px dashed var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
&:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
} }
} }

View file

@ -19,13 +19,17 @@ import { errorMessageService } from "../../../services/error-message.service";
import { DietTagSelect } from "../badges/DietTagSelect"; import { DietTagSelect } from "../badges/DietTagSelect";
import { IngredientPicker } from "../ingredients/IngredientPicker"; import { IngredientPicker } from "../ingredients/IngredientPicker";
import { IngredientRow } from "../ingredients/IngredientRow"; import { IngredientRow } from "../ingredients/IngredientRow";
import {
isUnsavedPlaceholder,
makePlaceholderIngredientView,
} from "../ingredients/placeholder-ingredient";
import { type StepDraft, StepListEditor } from "../steps/StepListEditor"; import { type StepDraft, StepListEditor } from "../steps/StepListEditor";
import "../recipes.scss"; import "../recipes.scss";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */ /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */ /** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). */
interface IngredientLine { interface IngredientLine {
key: string; key: string;
ingredient: IngredientView; ingredient: IngredientView;
@ -33,7 +37,7 @@ interface IngredientLine {
unitId: number | null; unitId: number | null;
} }
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */ /** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */
interface UnresolvedIngredientLine { interface UnresolvedIngredientLine {
key: string; key: string;
rawText: string; rawText: string;
@ -207,6 +211,26 @@ export function RecipeImportForm({
setResolvingKey(null); setResolvingKey(null);
} }
/** Keeps an unresolved line as a free-text placeholder — the catalog had nothing matching, but the ingredient shouldn't be silently dropped (see `Ingredient.isPlaceholder` in schema.prisma). Its unit still needs picking, same as a resolved line. */
function keepUnresolvedAsPlaceholder(unresolvedKey: string) {
setUnresolvedIngredients((lines) => {
const line = lines.find((l) => l.key === unresolvedKey);
if (line) {
setIngredientLines((resolved) => [
...resolved,
{
key: makeClientKey(),
ingredient: makePlaceholderIngredientView(line.rawText.trim()),
quantity: line.quantity,
unitId: null,
},
]);
}
return lines.filter((l) => l.key !== unresolvedKey);
});
setResolvingKey((current) => (current === unresolvedKey ? null : current));
}
function discardUnresolvedIngredient(key: string) { function discardUnresolvedIngredient(key: string) {
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key)); setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
setResolvingKey((current) => (current === key ? null : current)); setResolvingKey((current) => (current === key ? null : current));
@ -230,6 +254,10 @@ export function RecipeImportForm({
const seen = new Set<number>(); const seen = new Set<number>();
const duplicates = new Set<number>(); const duplicates = new Set<number>();
for (const line of ingredientLines) { for (const line of ingredientLines) {
// Placeholder lines have no catalog id (all share the sentinel 0) and
// each becomes its own fresh row server-side — two of them never
// collide on `RecipeIngredient`'s `(recipeId, ingredientId)` key.
if (line.ingredient.isPlaceholder) continue;
if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id); if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id);
seen.add(line.ingredient.id); seen.add(line.ingredient.id);
} }
@ -260,7 +288,12 @@ export function RecipeImportForm({
visibility, visibility,
dietIds, dietIds,
ingredients: ingredientLines.map((line) => ({ ingredients: ingredientLines.map((line) => ({
ingredientId: line.ingredient.id, // Free-text lines (nothing in the catalog matched) submit as
// `placeholderName` so the API creates the placeholder row — same
// branch as RecipeFormPage's identical submit.
...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }),
quantity: Number(line.quantity), quantity: Number(line.quantity),
// `canSubmit` already requires every line to have a unit picked — // `canSubmit` already requires every line to have a unit picked —
// same "?? 0, the schema rejects it if ever reached" reasoning as // same "?? 0, the schema rejects it if ever reached" reasoning as
@ -331,7 +364,9 @@ export function RecipeImportForm({
); );
} }
const selectedIds = ingredientLines.map((line) => line.ingredient.id); const selectedIds = ingredientLines
.filter((line) => !line.ingredient.isPlaceholder)
.map((line) => line.ingredient.id);
return ( return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate> <form className="recipe-form" onSubmit={handleSubmit} noValidate>
@ -421,6 +456,9 @@ export function RecipeImportForm({
> >
{t("recipes.sources.import.resolveButton")} {t("recipes.sources.import.resolveButton")}
</button> </button>
<button type="button" onClick={() => keepUnresolvedAsPlaceholder(line.key)}>
{t("recipes.sources.import.keepAsPlaceholderButton")}
</button>
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}> <button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
{t("recipes.sources.import.discardButton")} {t("recipes.sources.import.discardButton")}
</button> </button>

View file

@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client"; import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker"; import { CatalogSearchPicker } from "./CatalogSearchPicker";
import type { TextSelectionRange } from "./use-text-selection"; import type { TextSelectionRange } from "./use-text-selection";
@ -349,7 +350,7 @@ export function TechStepCorrectionPopover({
<CatalogSearchPicker <CatalogSearchPicker
items={(catalogs?.ingredients ?? []).map((ingredient) => ({ items={(catalogs?.ingredients ?? []).map((ingredient) => ({
id: ingredient.id, id: ingredient.id,
label: t(`catalog.ingredients.${ingredient.key}`), label: ingredientLabel(ingredient, t),
}))} }))}
onSelect={setPickedIngredientId} onSelect={setPickedIngredientId}
placeholder={t("recipes.form.searchIngredientPlaceholder")} placeholder={t("recipes.form.searchIngredientPlaceholder")}
@ -447,7 +448,7 @@ export function TechStepCorrectionPopover({
const view = ingredientById.get(ingredient.ingredientId); const view = ingredientById.get(ingredient.ingredientId);
const unit = const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined; ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
const label = view ? t(`catalog.ingredients.${view.key}`) : "…"; const label = view ? ingredientLabel(view, t) : "…";
return ( return (
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place. // biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
<li key={index} className="tech-step-correction-popover__chip"> <li key={index} className="tech-step-correction-popover__chip">

View file

@ -217,8 +217,9 @@
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.", "planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
"loadError": "Impossible de charger cette recette pour le moment.", "loadError": "Impossible de charger cette recette pour le moment.",
"unresolvedTitle": "Ingrédients à compléter", "unresolvedTitle": "Ingrédients à compléter",
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.", "unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, gardez le texte tel quel à compléter plus tard, ou retirez-les.",
"resolveButton": "Choisir un ingrédient", "resolveButton": "Choisir un ingrédient",
"keepAsPlaceholderButton": "Garder tel quel",
"discardButton": "Retirer cette ligne", "discardButton": "Retirer cette ligne",
"submit": "Importer", "submit": "Importer",
"submitting": "Import en cours…", "submitting": "Import en cours…",
@ -255,6 +256,8 @@
"allCategories": "Tout", "allCategories": "Tout",
"allSubcategories": "Tout", "allSubcategories": "Tout",
"noIngredientFound": "Aucun ingrédient trouvé.", "noIngredientFound": "Aucun ingrédient trouvé.",
"addPlaceholderButton": "Ajouter « {{name}} » comme ingrédient à compléter",
"placeholderBadge": "à compléter",
"category": { "category": {
"freshProduce": "Produits frais", "freshProduce": "Produits frais",
"meatAndSeafood": "Boucherie & poissonnerie", "meatAndSeafood": "Boucherie & poissonnerie",

View file

@ -14,6 +14,10 @@ import { ApiError, apiClient } from "../../api/client";
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect"; import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker"; import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow"; import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
import {
isUnsavedPlaceholder,
makePlaceholderIngredientView,
} from "../../features/recipes/ingredients/placeholder-ingredient";
import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor"; import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor";
import "../../features/recipes/recipes.scss"; import "../../features/recipes/recipes.scss";
import { makeClientKey } from "../../lib/client-key"; import { makeClientKey } from "../../lib/client-key";
@ -22,7 +26,19 @@ import { errorMessageService } from "../../services/error-message.service";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */ /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. `unitId` is `null` until the user picks one (no default — unlike `portions`, there's no single "usually right" unit across every ingredient); `canSubmit` gates on every line having one set before allowing save. */ /**
* One selected ingredient line `key` is a client-only stable identity,
* same reasoning as `StepDraft`. `unitId` is `null` until the user picks
* one (no default unlike `portions`, there's no single "usually right"
* unit across every ingredient); `canSubmit` gates on every line having one
* set before allowing save.
*
* `ingredient` is a real `IngredientView` for a catalog pick or an
* edit-loaded placeholder, or a synthetic one (see
* `makePlaceholderIngredientView`) for a brand-new free-text placeholder
* the user added because the catalog fell short the latter submits as
* `placeholderName`, not `ingredientId` (see {@link isUnsavedPlaceholder}).
*/
interface IngredientLine { interface IngredientLine {
key: string; key: string;
ingredient: IngredientView; ingredient: IngredientView;
@ -125,6 +141,19 @@ export function RecipeFormPage() {
]); ]);
} }
/** Adds a free-text placeholder line — the escape hatch when nothing in the catalog matches (see `IngredientPicker`'s `onAddPlaceholder`). */
function addPlaceholderIngredient(name: string) {
setIngredientLines((lines) => [
...lines,
{
key: makeClientKey(),
ingredient: makePlaceholderIngredientView(name),
quantity: "",
unitId: null,
},
]);
}
function updateIngredientLine( function updateIngredientLine(
key: string, key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>, patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
@ -169,7 +198,13 @@ export function RecipeFormPage() {
visibility, visibility,
dietIds, dietIds,
ingredients: ingredientLines.map((line) => ({ ingredients: ingredientLines.map((line) => ({
ingredientId: line.ingredient.id, // A just-added free-text line has no catalog id yet — ask the API to
// create the placeholder row via `placeholderName`. An edit-loaded
// placeholder already has a real id and goes through `ingredientId`
// like any other line (so re-saving never duplicates it).
...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }),
quantity: Number(line.quantity), quantity: Number(line.quantity),
// `canSubmit` already requires every line to have a unit picked // `canSubmit` already requires every line to have a unit picked
// before the button is enabled — `?? 0` is just to satisfy the // before the button is enabled — `?? 0` is just to satisfy the
@ -221,7 +256,12 @@ export function RecipeFormPage() {
); );
} }
const selectedIds = ingredientLines.map((line) => line.ingredient.id); // Placeholder lines carry no real catalog id (a brand-new one is id 0, an
// edit-loaded one isn't in the browsable catalog anyway), so they never
// belong in the picker's "already picked, hide it" set.
const selectedIds = ingredientLines
.filter((line) => !line.ingredient.isPlaceholder)
.map((line) => line.ingredient.id);
return ( return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate> <form className="recipe-form" onSubmit={handleSubmit} noValidate>
@ -292,6 +332,7 @@ export function RecipeFormPage() {
ingredients={ingredientsCatalog} ingredients={ingredientsCatalog}
excludeIds={selectedIds} excludeIds={selectedIds}
onSelect={addIngredient} onSelect={addIngredient}
onAddPlaceholder={addPlaceholderIngredient}
/> />
</section> </section>

View file

@ -8,6 +8,7 @@ import {
CategoryIcon, CategoryIcon,
IngredientTypeIcon, IngredientTypeIcon,
} from "../../features/recipes/ingredients/ingredient-icons"; } from "../../features/recipes/ingredients/ingredient-icons";
import { ingredientLabel } from "../../features/recipes/ingredients/ingredient-label";
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list"; import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
import "./shopping-list-page.scss"; import "./shopping-list-page.scss";
@ -80,9 +81,7 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>; return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
} }
const groups = groupShoppingListItems(items, (item) => const groups = groupShoppingListItems(items, (item) => ingredientLabel(item.ingredient, t));
t(`catalog.ingredients.${item.ingredient.key}`),
);
return ( return (
<div className="shopping-list"> <div className="shopping-list">
@ -99,7 +98,7 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
<IngredientTypeIcon icon={item.ingredient.icon} /> <IngredientTypeIcon icon={item.ingredient.icon} />
</span> </span>
<span className="shopping-list__item-name"> <span className="shopping-list__item-name">
{t(`catalog.ingredients.${item.ingredient.key}`)} {ingredientLabel(item.ingredient, t)}
</span> </span>
<span className="shopping-list__item-quantity"> <span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)} {formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}

View file

@ -104,3 +104,27 @@ export const trainingDataSnippetQuerySchema = z.object({
}); });
/** Inferred TS type for {@link trainingDataSnippetQuerySchema}. */ /** Inferred TS type for {@link trainingDataSnippetQuerySchema}. */
export type TrainingDataSnippetQuery = z.infer<typeof trainingDataSnippetQuerySchema>; export type TrainingDataSnippetQuery = z.infer<typeof trainingDataSnippetQuerySchema>;
/**
* Query params for `GET /admin/catalog/placeholders` the off-catalog
* ingredient review. `reviewed` is tri-state: omitted / `"false"` hides
* groups every row of which has already been triaged (the default working
* view), `"true"` shows only fully-triaged groups.
*/
export const listPlaceholdersQuerySchema = z.object({
reviewed: z.enum(["true", "false"]).optional(),
});
/** Inferred TS type for {@link listPlaceholdersQuerySchema}. */
export type ListPlaceholdersQuery = z.infer<typeof listPlaceholdersQuerySchema>;
/**
* Body of `PATCH /admin/catalog/placeholders/mark-reviewed` stamps
* `reviewedAt` on the given placeholder `Ingredient` ids (a maintainer has
* seen this catalog gap and, if warranted, added the real entry by hand).
* Bounded so one call can't sweep an unbounded set.
*/
export const markPlaceholdersReviewedSchema = z.object({
ingredientIds: z.array(z.number().int().positive()).min(1).max(500),
});
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>;

View file

@ -4,17 +4,33 @@ import { z } from "zod";
/** /**
* One ingredient line accepted by `POST /recipes`/`PATCH /recipes/:id`. * One ingredient line accepted by `POST /recipes`/`PATCH /recipes/:id`.
* `ingredientId` must reference an existing reference `Ingredient` (see *
* `GET /reference/ingredients`) there is no way to create one from here, * A line carries **exactly one** of:
* ingredients are static reference data. An unknown id is rejected * - `ingredientId` references an existing reference `Ingredient` (see
* service-side with `INGREDIENT_NOT_FOUND`, not here this schema only * `GET /reference/ingredients`). An unknown id is rejected service-side
* checks shape. * with `INGREDIENT_NOT_FOUND`, not here this schema only checks shape.
* (A placeholder that already exists, e.g. on a recipe being edited,
* round-trips through this branch by its real id.)
* - `placeholderName` free text the user typed because nothing in the
* catalog matched. The API creates a dedicated placeholder `Ingredient`
* row for this line (see `Ingredient.isPlaceholder` in schema.prisma and
* `recipe.service.ts`'s `createRecipeInternal`); it is never browsable.
*
* Requiring exactly one keeps `RecipeIngredient` unchanged (still a real
* `ingredientId` after the service resolves the line).
*/ */
const recipeIngredientInputSchema = z.object({ const recipeIngredientInputSchema = z
ingredientId: z.number().int().positive(), .object({
ingredientId: z.number().int().positive().optional(),
/** Free-text ingredient name for a line the catalog couldn't cover — see this schema's doc comment. Mutually exclusive with `ingredientId`. */
placeholderName: z.string().trim().min(1).max(120).optional(),
quantity: z.number().positive("La quantité doit être positive"), quantity: z.number().positive("La quantité doit être positive"),
/** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */ /** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */
unitId: z.number().int().positive(), unitId: z.number().int().positive(),
})
.refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), {
message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre",
path: ["ingredientId"],
}); });
/** /**
@ -63,9 +79,16 @@ export const createRecipeSchema = z
// silently summed, since two lines resolving to the same ingredient // silently summed, since two lines resolving to the same ingredient
// aren't necessarily interchangeable quantities (different units, // aren't necessarily interchangeable quantities (different units,
// different confidence in the match). // different confidence in the match).
//
// Placeholder lines (`placeholderName`, no `ingredientId` yet) are
// skipped here: each one becomes its own fresh `Ingredient` row
// service-side, so two placeholder lines with the same text never
// collide on the `(recipeId, ingredientId)` key.
.refine( .refine(
(input) => { (input) => {
const ingredientIds = input.ingredients.map((ingredient) => ingredient.ingredientId); const ingredientIds = input.ingredients
.map((ingredient) => ingredient.ingredientId)
.filter((id): id is number => id !== undefined);
return new Set(ingredientIds).size === ingredientIds.length; return new Set(ingredientIds).size === ingredientIds.length;
}, },
{ {

View file

@ -196,3 +196,35 @@ export interface RetrainResultView {
backfilled: { total: number; changed: number } | null; backfilled: { total: number; changed: number } | null;
marked: { applied: number; rejected: number }; marked: { applied: number; rejected: number };
} }
/**
* One row of `GET /admin/catalog/placeholders` every placeholder
* `Ingredient` (see `Ingredient.isPlaceholder` in schema.prisma) sharing
* one normalized name, so a maintainer sees "this ingredient is missing
* from the catalog, and N recipes are waiting on it" rather than a flat
* list of near-duplicate one-off rows.
*
* `normalizedName` is the grouping key (lower-cased, accent-stripped,
* whitespace-collapsed see `apps/api`'s `admin-catalog.service.ts`).
* `displayNames` is every distinct raw spelling that collapsed into it
* ("Piment d'Espelette", "piment d espelette"). `ingredientIds` is every
* placeholder row in the group the payload `mark-reviewed` takes back.
*/
export interface CatalogPlaceholderGroupView {
normalizedName: string;
displayNames: string[];
ingredientIds: number[];
/** Distinct recipes that use at least one placeholder in this group. */
recipeCount: number;
/** Up to 5 of those recipes, for a "seen in…" preview. */
sampleRecipes: { id: number; name: string }[];
/** Earliest `createdAt` across the group's rows (ISO 8601), or `null` if none carry one. */
firstSeenAt: string | null;
/** `true` once every row in the group has been marked reviewed — the group then leaves the default working list. */
allReviewed: boolean;
}
/** Result of `POST /admin/catalog/placeholders/prune-orphans` — how many placeholder rows with zero remaining recipe references were deleted. */
export interface PruneOrphansResultView {
deleted: number;
}

View file

@ -275,7 +275,10 @@ export interface SourceView {
* *
* `key` is a stable English camelCase uid (e.g. `"tomato"`), not a display * `key` is a stable English camelCase uid (e.g. `"tomato"`), not a display
* label like {@link DietView.key}, resolved via * label like {@link DietView.key}, resolved via
* `t(\`catalog.ingredients.${key}\`)`. * `t(\`catalog.ingredients.${key}\`)` **except** for a placeholder (see
* `isPlaceholder`), whose label is `displayName` verbatim. Consumers must
* therefore resolve the label as `displayName ?? t(\`catalog.ingredients.${key}\`)`
* (helper: `apps/web`'s `features/recipes/ingredients/ingredient-label.ts`).
*/ */
export interface IngredientView { export interface IngredientView {
id: number; id: number;
@ -287,4 +290,15 @@ export interface IngredientView {
reproducible: boolean; reproducible: boolean;
allergens: AllergyView[]; allergens: AllergyView[];
diets: DietView[]; diets: DietView[];
/**
* `true` = a free-text ingredient a user typed on a recipe line because
* the catalog had nothing matching see `Ingredient.isPlaceholder` in
* schema.prisma. Never returned by `GET /reference/ingredients` (the
* browsable catalog excludes them); only ever seen inside a recipe's own
* ingredient list. Rendered with an "à compléter" badge and no
* allergen/diet info.
*/
isPlaceholder: boolean;
/** The user-typed name when `isPlaceholder` — `null` for a real catalog ingredient (its label is in i18n). */
displayName: string | null;
} }

View file

@ -427,6 +427,26 @@ heartbeat en échec ne casse jamais un run. Seuils d'âge : > 8 j ⇒ `degraded`
**Ne peut ni éditer `training_data.py` ni redémarrer l'intent-service** **Ne peut ni éditer `training_data.py` ni redémarrer l'intent-service**
ces deux étapes restent manuelles, l'UI l'affiche en bandeau permanent. ces deux étapes restent manuelles, l'UI l'affiche en bandeau permanent.
**Ingrédients hors-catalogue** (`admin-catalog.service.ts`, routes
`/admin/catalog/*`, `requireAdmin`) — revue des lignes `Ingredient`
`isPlaceholder` (voir la section *Recettes* et `batch-cooking-modele.md`) :
- `GET /placeholders?reviewed=` — tous les placeholders, **regroupés par nom
normalisé** (`normalizePlaceholderName` : minuscules + sans accents +
ponctuation → espace + espaces compressés), un groupe = un manque du
catalogue (`recipeCount`, recettes-échantillon, orthographes vues,
`firstSeenAt`). Défaut / `reviewed=false` : les groupes encore à traiter ;
`reviewed=true` : l'archive des groupes entièrement traités.
- `PATCH /placeholders/mark-reviewed` (`{ ingredientIds }`) — pose `reviewedAt`
(le groupe sort de la liste de travail). N'ajoute **rien** au catalogue.
- `POST /placeholders/prune-orphans` — supprime les placeholders qu'aucune
recette ne référence plus (débris d'une édition de recette). Aussi
disponible en script : `scripts/prune-orphan-placeholders.ts`.
La promotion d'un placeholder en vraie entrée catalogue (édition de
`reference-seed-data.ts` + locales) reste **100 % manuelle** — pas de fusion
ni de génération de snippet depuis l'UI.
--- ---
## `reference` — catalogues publics (pas de session requise) ## `reference` — catalogues publics (pas de session requise)
@ -593,6 +613,19 @@ foyer du viewer est masquée. Sans foyer, rien n'est activé par construction
(pas de ligne `HouseSource` à référencer) — toute recette sourcée est (pas de ligne `HouseSource` à référencer) — toute recette sourcée est
invisible tant que le profil n'a pas rejoint/créé de foyer. invisible tant que le profil n'a pas rejoint/créé de foyer.
**Ingrédients hors-catalogue** — chaque ligne d'ingrédient de `POST`/`PATCH
/recipes` porte **soit** `ingredientId` (catalogue, ou un placeholder
existant qui fait l'aller-retour par son id réel), **soit** `placeholderName`
(texte libre). Pour une ligne `placeholderName`, `createRecipeInternal` /
`updateRecipe` créent, dans le `$transaction` de la recette, une ligne
`Ingredient` `isPlaceholder=true` (`resolveIngredientLines`), puis émettent
`analytics.recordEvent("ingredient.placeholder_created")`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `loadIngredientCatalog`
(`ingredient-matcher.ts`). Retirer une ligne placeholder d'une recette
laisse une ligne `Ingredient` orpheline (non-GC — purge manuelle via
`/admin/catalog/placeholders/prune-orphans`). Revue côté admin :
`/admin/catalog/*` (section *admin* ci-dessus).
### Détection des techniques — `tech-step-matcher.ts` ### Détection des techniques — `tech-step-matcher.ts`
Historiquement une table `TechStepMapping` de regex par technique/locale Historiquement une table `TechStepMapping` de regex par technique/locale

View file

@ -313,15 +313,17 @@ calculés depuis les ingrédients).
### `ingredients` (`Ingredient`) et catalogue associé ### `ingredients` (`Ingredient`) et catalogue associé
Table de référence (seedée, jamais créée/éditée/supprimée via l'API), comme Table de référence (seedée, jamais créée/éditée/supprimée via l'API), comme
`Diet`/`Allergy`. `Diet`/`Allergy` — **sauf** les lignes *placeholder* (voir `isPlaceholder`
ci-dessous), seules lignes de cette table jamais issues du seed.
| Champ | Description | | Champ | Description |
|---|---| |---|---|
| `id` | Identifiant | | `id` | Identifiant |
| `key` | Slug unique — libellé dans `catalog.ingredients.<key>` (locale) | | `key` | Slug unique — libellé dans `catalog.ingredients.<key>` (locale) ; pour un placeholder, `placeholder:<uuid>` (jamais de libellé i18n) |
| `icon` | `IngredientIcon` — ~20 pictogrammes génériques par *type* de chose (légume, bouteille d'huile, fromage…), pas un emoji par ingrédient (437 rejetés comme peu pro) — voir `apps/web/src/features/recipes/ingredients/ingredient-icons.tsx` | | `icon` | `IngredientIcon` — ~20 pictogrammes génériques par *type* de chose (légume, bouteille d'huile, fromage…), pas un emoji par ingrédient (437 rejetés comme peu pro) — voir `apps/web/src/features/recipes/ingredients/ingredient-icons.tsx` |
| `category` / `subcategory` | `IngredientCategory` (7 rayons) / `IngredientSubcategory` (racks plus fins) — organisation "rayon de supermarché français" pour permettre le parcours par catégorie dans le picker (400+ ingrédients, la recherche seule ne suffit pas) | | `category` / `subcategory` | `IngredientCategory` (7 rayons) / `IngredientSubcategory` (racks plus fins) — organisation "rayon de supermarché français" pour permettre le parcours par catégorie dans le picker (400+ ingrédients, la recherche seule ne suffit pas) |
| `reproducible` | Vrai si raisonnablement faisable maison (un pain burger, une béchamel) plutôt qu'un achat systématique — juste un flag, pas un lien vers une recette précise (un ancien `alternateRecipeId` jamais câblé a été retiré) | | `reproducible` | Vrai si raisonnablement faisable maison (un pain burger, une béchamel) plutôt qu'un achat systématique — juste un flag, pas un lien vers une recette précise (un ancien `alternateRecipeId` jamais câblé a été retiré) |
| `isPlaceholder` / `displayName` / `createdById` / `createdAt` / `reviewedAt` | **Ingrédient hors-catalogue** : quand le catalogue ne couvre pas un ingrédient, l'utilisateur peut saisir un texte libre sur une ligne de recette (`placeholderName` dans le payload `POST/PATCH /recipes`) — l'API crée alors une ligne `Ingredient` `isPlaceholder=true`, `displayName` = le texte, `createdById`/`createdAt` renseignés. `RecipeIngredient` la référence comme n'importe quel ingrédient, mais `GET /reference/ingredients` et `ingredient-matcher.ts` l'excluent (jamais parcourable/matchable). `reviewedAt` est posé quand un admin a traité le manque via `/admin/catalog/*`. La promotion en vraie entrée catalogue (édition de `reference-seed-data.ts` + locales) reste **manuelle**. |
`IngredientDiet` (m2m, régimes compatibles — omet volontairement `Omnivore` `IngredientDiet` (m2m, régimes compatibles — omet volontairement `Omnivore`
et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et

View file

@ -626,10 +626,25 @@ Clic sur une ligne :
pas). Un menu "options d'affichage" bascule les badges pas). Un menu "options d'affichage" bascule les badges
allergène/régime/reproductible par carte (préférence UI locale, pas allergène/régime/reproductible par carte (préférence UI locale, pas
persistée). Réutilisé par `RecipeFormPage`, `RecipeImportForm`, le filtre persistée). Réutilisé par `RecipeFormPage`, `RecipeImportForm`, le filtre
ingrédients de `RecipePickerDialog`, et `DislikedIngredientsField`. ingrédients de `RecipePickerDialog`, et `DislikedIngredientsField`. Prop
optionnel `onAddPlaceholder` : quand la recherche ne renvoie rien, un
bouton "Ajouter « … » comme ingrédient à compléter" ajoute une ligne
**hors-catalogue** (texte libre, voir `placeholder-ingredient.ts` +
`backend-architecture.md`) plutôt que de bloquer l'utilisateur — présent
seulement pour `RecipeFormPage`/`RecipeImportForm`.
- `IngredientRow.tsx` / `StepListEditor.tsx` — ligne d'ingrédient sélectionnée - `IngredientRow.tsx` / `StepListEditor.tsx` — ligne d'ingrédient sélectionnée
(icône, nom, quantité, unité, badges, retrait) et éditeur d'étapes ordonné (icône, nom, quantité, unité, badges, retrait) et éditeur d'étapes ordonné
(boutons monter/descendre, pas de drag-and-drop) du formulaire recette. (boutons monter/descendre, pas de drag-and-drop) du formulaire recette. Une
ligne placeholder affiche un badge "à compléter" et aucun badge
allergène/régime.
- `ingredient-label.ts``ingredientLabel(ingredient, t)` =
`displayName ?? t(\`catalog.ingredients.${key}\`)`. **Tout** rendu du libellé
d'un ingrédient passe par là (picker, ligne, liste de courses, filtre du
picker de planning, fiche recette, popover de correction) pour qu'un
placeholder (clé `placeholder:<uuid>`, sans libellé i18n) affiche son
`displayName` et pas la clé brute. `placeholder-ingredient.ts` fabrique la
`IngredientView` synthétique d'une ligne placeholder pas encore
enregistrée (`id` 0, soumise en `placeholderName`).
- `SourceItemTable.tsx` — tableau de parcours d'une source (photo+nom, badge - `SourceItemTable.tsx` — tableau de parcours d'une source (photo+nom, badge
"déjà importé" au lieu des colonnes allergènes/régime — un item de source "déjà importé" au lieu des colonnes allergènes/régime — un item de source
n'est résolu contre les catalogues qu'à la prévisualisation). n'est résolu contre les catalogues qu'à la prévisualisation).