diff --git a/apps/admin-web/cypress/e2e/admin-layout.cy.ts b/apps/admin-web/cypress/e2e/admin-layout.cy.ts index 33cfff2..68d936b 100644 --- a/apps/admin-web/cypress/e2e/admin-layout.cy.ts +++ b/apps/admin-web/cypress/e2e/admin-layout.cy.ts @@ -20,7 +20,7 @@ describe("Admin layout", () => { 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.visit("/"); @@ -36,6 +36,11 @@ describe("Admin layout", () => { cy.url().should("include", "/corrections"); 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.url().should("eq", `${Cypress.config().baseUrl}/`); }); diff --git a/apps/admin-web/cypress/e2e/catalog.cy.ts b/apps/admin-web/cypress/e2e/catalog.cy.ts new file mode 100644 index 0000000..7bf00ed --- /dev/null +++ b/apps/admin-web/cypress/e2e/catalog.cy.ts @@ -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"); + }); +}); diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index ad9ba3d..e37c1fa 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { RequireAdmin } from "./features/auth/RequireAdmin"; import { AdminLayout } from "./layouts/AdminLayout"; +import { CatalogPage } from "./pages/catalog/CatalogPage"; import { CorrectionsPage } from "./pages/corrections/CorrectionsPage"; import { DashboardPage } from "./pages/dashboard/DashboardPage"; import { LoginPage } from "./pages/login/LoginPage"; @@ -27,6 +28,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts index b81b7a2..f96d980 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/admin-web/src/api/client.ts @@ -2,10 +2,13 @@ import { type AdminLoginInput, type AdminUserView, type ApiErrorResponse, + type CatalogPlaceholderGroupView, type CorrectionAdminView, ErrorCode, + type MarkPlaceholdersReviewedInput, type MetricsView, type MonitoringView, + type PruneOrphansResultView, type RetrainRequestInput, type RetrainResultView, type TrainingDataSnippetView, @@ -163,6 +166,26 @@ export class AdminApiClient { 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 { + 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 { + return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" }); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 341fce7..1a0eb27 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -1,4 +1,4 @@ -import { Activity, LayoutDashboard, ListChecks } from "lucide-react"; +import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; @@ -13,6 +13,7 @@ const NAV_ITEMS = [ { to: "/", key: "dashboard", Icon: LayoutDashboard, end: true }, { to: "/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/corrections", key: "corrections", Icon: ListChecks, end: false }, + { to: "/catalogue", key: "catalog", Icon: PackageSearch, end: false }, ] as const; /** diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json index bcf8f94..3b6487f 100644 --- a/apps/admin-web/src/locales/fr/translation.json +++ b/apps/admin-web/src/locales/fr/translation.json @@ -24,7 +24,8 @@ "nav": { "dashboard": "Tableau de bord", "monitoring": "Monitoring", - "corrections": "Corrections" + "corrections": "Corrections", + "catalog": "Catalogue" }, "layout": { "logout": "Se déconnecter" @@ -123,6 +124,25 @@ "created": "Créé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)." } } } diff --git a/apps/admin-web/src/pages/catalog/CatalogPage.tsx b/apps/admin-web/src/pages/catalog/CatalogPage.tsx new file mode 100644 index 0000000..9aaa028 --- /dev/null +++ b/apps/admin-web/src/pages/catalog/CatalogPage.tsx @@ -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("pending"); + const [state, setState] = useState({ status: "loading" }); + const [pendingIds, setPendingIds] = useState(null); + const [pruneMessage, setPruneMessage] = useState(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 ( +
+

{t("admin.catalog.title")}

+

{t("admin.catalog.lead")}

+ +
+
+ {(["pending", "reviewed"] as const).map((value) => ( + + ))} +
+ +
+ {pruneMessage &&

{pruneMessage}

} + + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && + (state.groups.length === 0 ? ( +

{t("admin.catalog.empty")}

+ ) : ( +
    + {state.groups.map((group) => ( + markReviewed(group)} + /> + ))} +
+ ))} +
+ ); +} + +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 ( +
  • +
    +

    {headline}

    + + {t("admin.catalog.recipeCount", { count: group.recipeCount })} + +
    + + {variants.length > 0 && ( +

    + {t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })} +

    + )} + + {group.sampleRecipes.length > 0 && ( +

    + {t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")} +

    + )} + +
    + {firstSeen && ( + + {t("admin.catalog.firstSeen", { date: firstSeen })} + + )} + {!group.allReviewed && ( + + )} +
    +
  • + ); +} diff --git a/apps/admin-web/src/pages/catalog/catalog-page.scss b/apps/admin-web/src/pages/catalog/catalog-page.scss new file mode 100644 index 0000000..43f6d94 --- /dev/null +++ b/apps/admin-web/src/pages/catalog/catalog-page.scss @@ -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; + } + } +} diff --git a/apps/admin-web/src/pages/catalog/catalog.ts b/apps/admin-web/src/pages/catalog/catalog.ts new file mode 100644 index 0000000..8e5eec6 --- /dev/null +++ b/apps/admin-web/src/pages/catalog/catalog.ts @@ -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"); +} diff --git a/apps/api/prisma/migrations/20260828140000_ingredient_placeholder/migration.sql b/apps/api/prisma/migrations/20260828140000_ingredient_placeholder/migration.sql new file mode 100644 index 0000000..55c07fa --- /dev/null +++ b/apps/api/prisma/migrations/20260828140000_ingredient_placeholder/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d9f373c..f90060e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -129,6 +129,9 @@ model UserProfile { /// view a recipe may correct its tech-step matches, not just its author — /// see `StepTechStepCorrection.correctorId`). techStepCorrections StepTechStepCorrection[] + /// Placeholder `Ingredient` rows this profile created by typing a free-text + /// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`. + createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator") @@map("user_profiles") } @@ -530,6 +533,38 @@ model Ingredient { /// ingredient↔recipe linking in the database, the UI only pre-fills the /// catalog's own search with this ingredient's name). reproducible Boolean @default(false) + /// `true` = a "placeholder" row: a free-text ingredient a user typed on a + /// recipe line because the seeded catalog had nothing matching (see + /// specs/batch-cooking-modele.md's "ingrédients hors-catalogue"). Such a + /// row has `displayName` non-null, a generated `key` (`placeholder:`, + /// 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.`). Invariant "non-null iff `isPlaceholder`" + /// is enforced service-side, not by the schema (same posture as other + /// cross-field invariants here). + displayName String? @map("display_name") + /// Profile that first created this placeholder — context for the admin + /// catalog-gap review. `onDelete: SetNull` so deleting an account never + /// blocks on, or cascades into, the recipes that still use its placeholder. + /// `null` for a real catalog row. + createdById Int? @map("created_by_id") + /// When this placeholder was created. `null` for a real catalog row (the + /// seed carries no timestamp). + createdAt DateTime? @map("created_at") + /// Stamped when an admin has triaged this catalog gap + /// (`PATCH /admin/catalog/placeholders/mark-reviewed`) — the group then + /// drops out of the default "à traiter" list. `null` while pending / for a + /// real catalog row. + reviewedAt DateTime? @map("reviewed_at") recipes RecipeIngredient[] allergies IngredientAllergy[] @@ -540,7 +575,10 @@ model Ingredient { /// Mentions of this ingredient detected in a step's free text alongside a /// technique — see `StepTechStepIngredient`. stepTechSteps StepTechStepIngredient[] + /// The profile that created this row when it is a placeholder — see `createdById`. + createdBy UserProfile? @relation("PlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull) + @@index([isPlaceholder]) @@map("ingredients") } diff --git a/apps/api/src/lib/recipe-matching/ingredient-matcher.ts b/apps/api/src/lib/recipe-matching/ingredient-matcher.ts index 63081fe..13b6b25 100644 --- a/apps/api/src/lib/recipe-matching/ingredient-matcher.ts +++ b/apps/api/src/lib/recipe-matching/ingredient-matcher.ts @@ -427,6 +427,11 @@ export async function loadIngredientCatalog(locale = "en"): Promise` key) have no + // authored label so they'd be skipped by the `label === undefined` + // check below anyway — filtered here too so an import never even + // considers resolving one raw line to another line's placeholder. + where: { isPlaceholder: false }, select: { id: true, key: true }, }); const catalog: IngredientMatchEntry[] = []; diff --git a/apps/api/src/modules/admin/admin-catalog.routes.ts b/apps/api/src/modules/admin/admin-catalog.routes.ts new file mode 100644 index 0000000..33b6f72 --- /dev/null +++ b/apps/api/src/modules/admin/admin-catalog.routes.ts @@ -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()); + }), +); diff --git a/apps/api/src/modules/admin/admin-catalog.service.ts b/apps/api/src/modules/admin/admin-catalog.service.ts new file mode 100644 index 0000000..48570a9 --- /dev/null +++ b/apps/api/src/modules/admin/admin-catalog.service.ts @@ -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 { + 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; + ingredientIds: number[]; + recipeIds: Set; + sampleRecipes: Map; + firstSeenAt: Date | null; + allReviewed: boolean; + } + const groups = new Map(); + + 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 { + 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 + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index cadeaf1..75eeb8e 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { adminAuthRouter } from "./admin-auth.routes.js"; +import { adminCatalogRouter } from "./admin-catalog.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminMonitoringRouter } from "./admin-monitoring.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` * in `app.ts`. Every sub-router here is for `apps/admin-web` only — * `/admin/auth` is public (login), everything added later - * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits - * behind `requireAdmin` (`middlewares/require-admin.ts`). + * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`, + * `/admin/catalog/*`) sits behind `requireAdmin` + * (`middlewares/require-admin.ts`). */ export const adminRouter = Router(); @@ -17,3 +19,4 @@ adminRouter.use("/auth", adminAuthRouter); adminRouter.use("/metrics", adminMetricsRouter); adminRouter.use("/monitoring", adminMonitoringRouter); adminRouter.use("/tech-steps", adminTechStepsRouter); +adminRouter.use("/catalog", adminCatalogRouter); diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 42f02ad..b63101c 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { HttpError } from "@batch-cooking/error-tools"; import { type AllergyView, @@ -109,6 +110,12 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV kind: allergy.category.kind, })), diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), + // A placeholder line round-trips as a normal `Ingredient` with a real + // id — the frontend tells it apart by this flag (badge, no + // allergen/diet info) and shows `displayName` verbatim instead of + // looking up an i18n label that doesn't exist for a `placeholder:` key. + isPlaceholder: ingredient.isPlaceholder, + displayName: ingredient.displayName, }; } @@ -542,6 +549,69 @@ async function matchStepsTechSteps( ); } +/** 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:` 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( input: CreateRecipeInput, authorId: number, @@ -549,7 +619,9 @@ async function createRecipeInternal( source: { sourceId: number; externalId: string; locale: string } | null, ): Promise { 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 assertDietsExist(input.dietIds); // Matched up front (one call per step, in parallel) rather than inline @@ -562,66 +634,83 @@ async function createRecipeInternal( source?.locale ?? DEFAULT_TECH_STEP_LOCALE, ); - const created = await prisma.recipe.create({ - data: { - name: input.name, - description: input.description ?? null, - picture: input.picture ?? null, - portions: input.portions, + // One transaction so the free-text placeholder `Ingredient` rows and the + // recipe that references them commit together — a failed recipe create + // must never leave orphan placeholders behind. + const { created, createdPlaceholders } = await prisma.$transaction(async (tx) => { + const { resolved, createdPlaceholders } = await resolveIngredientLines( + input.ingredients, authorId, - authorHouseId, - visibility: input.visibility, - sourceId: source?.sourceId ?? null, - externalId: source?.externalId ?? null, - ingredients: { - create: input.ingredients.map((ingredient) => ({ - ingredientId: ingredient.ingredientId, - quantity: ingredient.quantity, - unitId: ingredient.unitId, - })), + tx, + ); + const created = await tx.recipe.create({ + data: { + name: input.name, + description: input.description ?? null, + picture: input.picture ?? null, + portions: input.portions, + authorId, + authorHouseId, + visibility: input.visibility, + sourceId: source?.sourceId ?? null, + externalId: source?.externalId ?? null, + ingredients: { + create: resolved.map((line) => ({ + ingredientId: line.ingredientId, + quantity: line.quantity, + unitId: line.unitId, + })), + }, + steps: { + create: stepsWithTechSteps.map(({ step, matches }, index) => ({ + description: step.description, + picture: step.picture ?? null, + order: index, + techSteps: { + create: matches.map((match, order) => ({ + techStepId: match.techStepId, + start: match.start, + end: match.end, + contextStart: match.contextStart, + contextEnd: match.contextEnd, + order, + ingredients: { + create: match.ingredients.map((ingredient) => ({ + ingredientId: ingredient.ingredientId, + quantity: ingredient.quantity, + unitId: ingredient.unitId, + start: ingredient.start, + end: ingredient.end, + })), + }, + utensils: { + create: match.utensils.map((utensil) => ({ + utensilId: utensil.utensilId, + start: utensil.start, + end: utensil.end, + })), + }, + })), + }, + })), + }, + diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, }, - steps: { - create: stepsWithTechSteps.map(({ step, matches }, index) => ({ - description: step.description, - picture: step.picture ?? null, - order: index, - techSteps: { - create: matches.map((match, order) => ({ - techStepId: match.techStepId, - start: match.start, - end: match.end, - contextStart: match.contextStart, - contextEnd: match.contextEnd, - order, - ingredients: { - create: match.ingredients.map((ingredient) => ({ - ingredientId: ingredient.ingredientId, - quantity: ingredient.quantity, - unitId: ingredient.unitId, - start: ingredient.start, - end: ingredient.end, - })), - }, - utensils: { - create: match.utensils.map((utensil) => ({ - utensilId: utensil.utensilId, - start: utensil.start, - end: utensil.end, - })), - }, - })), - }, - })), - }, - diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, - }, - include: recipeInclude(authorId), + include: recipeInclude(authorId), + }); + return { created, createdPlaceholders }; }); analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", { actorId: authorId, context: { recipeId: created.id, sourceId: source?.sourceId ?? null }, }); + for (const placeholder of createdPlaceholders) { + analytics.recordEvent("ingredient.placeholder_created", { + actorId: authorId, + context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: created.id }, + }); + } return toRecipeView(created); } catch (err) { @@ -650,16 +739,30 @@ export async function updateRecipe( ): Promise { try { 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 assertDietsExist(input.dietIds); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); - await prisma.$transaction([ - prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }), - prisma.step.deleteMany({ where: { recipeId: id } }), - prisma.recipeDiet.deleteMany({ where: { recipeId: id } }), - prisma.recipe.update({ + // Interactive transaction (not the array form) so any new free-text + // placeholder rows are created in the same atomic unit as the + // delete+recreate of the recipe's content. An *existing* placeholder + // line round-trips by its real `ingredientId` and is left untouched; + // only a brand-new `placeholderName` line creates a row here. + const createdPlaceholders = await prisma.$transaction(async (tx) => { + await tx.recipeIngredient.deleteMany({ where: { recipeId: id } }); + await tx.step.deleteMany({ where: { recipeId: id } }); + await tx.recipeDiet.deleteMany({ where: { recipeId: id } }); + const { resolved, createdPlaceholders } = await resolveIngredientLines( + input.ingredients, + viewerId, + tx, + ); + await tx.recipe.update({ where: { id }, data: { name: input.name, @@ -668,10 +771,10 @@ export async function updateRecipe( portions: input.portions, visibility: input.visibility, ingredients: { - create: input.ingredients.map((ingredient) => ({ - ingredientId: ingredient.ingredientId, - quantity: ingredient.quantity, - unitId: ingredient.unitId, + create: resolved.map((line) => ({ + ingredientId: line.ingredientId, + quantity: line.quantity, + unitId: line.unitId, })), }, steps: { @@ -693,8 +796,16 @@ export async function updateRecipe( }, 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)); } catch (err) { diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 51c76ee..b00e7f3 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -133,10 +133,16 @@ export async function getSources(): Promise { * and compatible diet regimes (see `IngredientDiet`) — same flattening * approach as {@link getAllergies}. Ingredients with no linked * allergen/diet come back with `allergens: []`/`diets: []`. + * + * Excludes placeholder rows (`Ingredient.isPlaceholder` — the free-text + * ingredients users type when the catalog falls short): this is the + * *browsable* catalog, and a placeholder is a per-recipe-line stand-in, not + * a real entry anyone should be able to pick again. */ export async function getIngredients(): Promise { try { const ingredients = await prisma.ingredient.findMany({ + where: { isPlaceholder: false }, include: { allergies: { include: { allergy: { include: { category: true } } } }, diets: { include: { diet: true } }, @@ -159,6 +165,9 @@ export async function getIngredients(): Promise { id: diet.id, key: diet.key, })), + // Always a real catalog row here (placeholders are filtered out above). + isPlaceholder: false, + displayName: null, })); } catch (err) { throw err; // see getDiets()'s catch comment above diff --git a/apps/api/src/scripts/prune-orphan-placeholders.ts b/apps/api/src/scripts/prune-orphan-placeholders.ts new file mode 100644 index 0000000..61f9641 --- /dev/null +++ b/apps/api/src/scripts/prune-orphan-placeholders.ts @@ -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 { + 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); + }); diff --git a/apps/api/test/admin-catalog-normalize.test.ts b/apps/api/test/admin-catalog-normalize.test.ts new file mode 100644 index 0000000..44fdfc0 --- /dev/null +++ b/apps/api/test/admin-catalog-normalize.test.ts @@ -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 D’ESPELETTE")).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(""); + }); +}); diff --git a/apps/api/test/admin-catalog.test.ts b/apps/api/test/admin-catalog.test.ts new file mode 100644 index 0000000..dffe82a --- /dev/null +++ b/apps/api/test/admin-catalog.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/test/recipe-placeholder.test.ts b/apps/api/test/recipe-placeholder.test.ts new file mode 100644 index 0000000..5c2059c --- /dev/null +++ b/apps/api/test/recipe-placeholder.test.ts @@ -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 { + return (await prisma.unit.findFirstOrThrow({ where: { key } })).id; +} + +/** Resolves a reference ingredient's id by its seed uid. */ +async function ingredientId(key: string): Promise { + 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; 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); + }); +}); diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature index 89cc60d..aba3240 100644 --- a/apps/web/cypress/e2e/recipe-form.feature +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -45,6 +45,21 @@ Feature: Recipe form — associating ingredients And I add a step 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 When I visit "/recettes/nouvelle" And I select the ingredient "Carotte" from the picker diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts index 03cb085..d655f04 100644 --- a/apps/web/cypress/e2e/recipe-form.ts +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -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", () => { const existingRecipe = { id: 7, diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index c59d15a..50ff366 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -17,6 +17,7 @@ import { Dialog } from "../../components/ui/Dialog"; import { errorMessageService } from "../../services/error-message.service"; import { DietTagSelect } from "../recipes/badges/DietTagSelect"; import { IngredientPicker } from "../recipes/ingredients/IngredientPicker"; +import { ingredientLabel } from "../recipes/ingredients/ingredient-label"; import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel"; import { RecipeTable } from "../recipes/RecipeTable"; import { @@ -447,7 +448,7 @@ export function RecipePickerDialog({
    {selectedIngredients.map((ingredient) => ( - {t(`catalog.ingredients.${ingredient.key}`)} + {ingredientLabel(ingredient, t)} + )} +
    ) : (
    {visible.map((ingredient) => ( @@ -184,9 +215,7 @@ export function IngredientPicker({ - - {t(`catalog.ingredients.${ingredient.key}`)} - + {ingredientLabel(ingredient, t)} {showAllergens && } {showDiets && } {showReproducible && } diff --git a/apps/web/src/features/recipes/ingredients/IngredientRow.tsx b/apps/web/src/features/recipes/ingredients/IngredientRow.tsx index 82c1c7f..a88e6b8 100644 --- a/apps/web/src/features/recipes/ingredients/IngredientRow.tsx +++ b/apps/web/src/features/recipes/ingredients/IngredientRow.tsx @@ -4,6 +4,7 @@ import { AllergenBadges } from "../badges/AllergenBadges"; import { DietBadges } from "../badges/DietBadges"; import { ReproducibleBadge } from "../badges/ReproducibleBadge"; import { IngredientTypeIcon } from "./ingredient-icons"; +import { ingredientLabel } from "./ingredient-label"; 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. */ @@ -36,13 +37,25 @@ export function IngredientRow({ // them, or why). const unitMissing = unitId === null; 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 (
  • - {t(`catalog.ingredients.${ingredient.key}`)} + + {ingredientLabel(ingredient, t)} + {isPlaceholder && ( + + {t("recipes.form.placeholderBadge")} + + )} + ))} - - - + {!isPlaceholder && ( + <> + + + + + )} + diff --git a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx index c20e3b8..3870f3e 100644 --- a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx +++ b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx @@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, apiClient } from "../../../api/client"; import { errorMessageService } from "../../../services/error-message.service"; +import { ingredientLabel } from "../ingredients/ingredient-label"; import { CatalogSearchPicker } from "./CatalogSearchPicker"; import type { TextSelectionRange } from "./use-text-selection"; @@ -349,7 +350,7 @@ export function TechStepCorrectionPopover({ ({ id: ingredient.id, - label: t(`catalog.ingredients.${ingredient.key}`), + label: ingredientLabel(ingredient, t), }))} onSelect={setPickedIngredientId} placeholder={t("recipes.form.searchIngredientPlaceholder")} @@ -447,7 +448,7 @@ export function TechStepCorrectionPopover({ const view = ingredientById.get(ingredient.ingredientId); const unit = ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined; - const label = view ? t(`catalog.ingredients.${view.key}`) : "…"; + const label = view ? ingredientLabel(view, t) : "…"; 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.
  • diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 6737ff1..a400d8d 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -216,8 +216,9 @@ "planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.", "loadError": "Impossible de charger cette recette pour le moment.", "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", + "keepAsPlaceholderButton": "Garder tel quel", "discardButton": "Retirer cette ligne", "submit": "Importer", "submitting": "Import en cours…", @@ -254,6 +255,8 @@ "allCategories": "Tout", "allSubcategories": "Tout", "noIngredientFound": "Aucun ingrédient trouvé.", + "addPlaceholderButton": "Ajouter « {{name}} » comme ingrédient à compléter", + "placeholderBadge": "à compléter", "category": { "freshProduce": "Produits frais", "meatAndSeafood": "Boucherie & poissonnerie", diff --git a/apps/web/src/pages/recipes/RecipeFormPage.tsx b/apps/web/src/pages/recipes/RecipeFormPage.tsx index b26a1c5..c7d7aac 100644 --- a/apps/web/src/pages/recipes/RecipeFormPage.tsx +++ b/apps/web/src/pages/recipes/RecipeFormPage.tsx @@ -14,6 +14,10 @@ import { ApiError, apiClient } from "../../api/client"; import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect"; import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker"; import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow"; +import { + isUnsavedPlaceholder, + makePlaceholderIngredientView, +} from "../../features/recipes/ingredients/placeholder-ingredient"; import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor"; import "../../features/recipes/recipes.scss"; 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). */ 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 { key: string; 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( key: string, patch: Partial>, @@ -169,7 +198,13 @@ export function RecipeFormPage() { visibility, dietIds, 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), // `canSubmit` already requires every line to have a unit picked // 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 ( @@ -292,6 +332,7 @@ export function RecipeFormPage() { ingredients={ingredientsCatalog} excludeIds={selectedIds} onSelect={addIngredient} + onAddPlaceholder={addPlaceholderIngredient} /> diff --git a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx index e307a11..a50eddf 100644 --- a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx +++ b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx @@ -8,6 +8,7 @@ import { CategoryIcon, IngredientTypeIcon, } from "../../features/recipes/ingredients/ingredient-icons"; +import { ingredientLabel } from "../../features/recipes/ingredients/ingredient-label"; import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list"; import "./shopping-list-page.scss"; @@ -80,9 +81,7 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) { return

    {t("shoppingList.empty")}

    ; } - const groups = groupShoppingListItems(items, (item) => - t(`catalog.ingredients.${item.ingredient.key}`), - ); + const groups = groupShoppingListItems(items, (item) => ingredientLabel(item.ingredient, t)); return (
    @@ -99,7 +98,7 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) { - {t(`catalog.ingredients.${item.ingredient.key}`)} + {ingredientLabel(item.ingredient, t)} {formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)} diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index ca60747..2a12be0 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -104,3 +104,27 @@ export const trainingDataSnippetQuerySchema = z.object({ }); /** Inferred TS type for {@link trainingDataSnippetQuerySchema}. */ export type TrainingDataSnippetQuery = z.infer; + +/** + * 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; + +/** + * 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; diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index 715ac27..c162264 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -4,18 +4,34 @@ import { z } from "zod"; /** * 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, - * ingredients are static reference data. An unknown id is rejected - * service-side with `INGREDIENT_NOT_FOUND`, not here — this schema only - * checks shape. + * + * A line carries **exactly one** of: + * - `ingredientId` — references an existing reference `Ingredient` (see + * `GET /reference/ingredients`). An unknown id is rejected service-side + * 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({ - ingredientId: z.number().int().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`. */ - unitId: z.number().int().positive(), -}); +const recipeIngredientInputSchema = z + .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"), + /** 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(), + }) + .refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), { + message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre", + path: ["ingredientId"], + }); /** * One preparation step accepted by `POST /recipes`/`PATCH /recipes/:id`. @@ -63,9 +79,16 @@ export const createRecipeSchema = z // silently summed, since two lines resolving to the same ingredient // aren't necessarily interchangeable quantities (different units, // 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( (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; }, { diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts index c6fede7..c6ddbac 100644 --- a/packages/shared/src/types/admin.ts +++ b/packages/shared/src/types/admin.ts @@ -196,3 +196,35 @@ export interface RetrainResultView { backfilled: { total: number; changed: number } | null; 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; +} diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index 9a43c1f..26f0d9a 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -275,7 +275,10 @@ export interface SourceView { * * `key` is a stable English camelCase uid (e.g. `"tomato"`), not a display * 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 { id: number; @@ -287,4 +290,15 @@ export interface IngredientView { reproducible: boolean; allergens: AllergyView[]; 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; } diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 3382e96..f58debc 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -376,6 +376,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** — 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) @@ -542,6 +562,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 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` Historiquement une table `TechStepMapping` de regex par technique/locale diff --git a/specs/batch-cooking-modele.md b/specs/batch-cooking-modele.md index 532979a..072ca4c 100644 --- a/specs/batch-cooking-modele.md +++ b/specs/batch-cooking-modele.md @@ -313,15 +313,17 @@ calculés depuis les ingrédients). ### `ingredients` (`Ingredient`) et catalogue associé 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 | |---|---| | `id` | Identifiant | -| `key` | Slug unique — libellé dans `catalog.ingredients.` (locale) | +| `key` | Slug unique — libellé dans `catalog.ingredients.` (locale) ; pour un placeholder, `placeholder:` (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` | | `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é) | +| `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` et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index f764620..6a9ede1 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -626,10 +626,25 @@ Clic sur une ligne : pas). Un menu "options d'affichage" bascule les badges allergène/régime/reproductible par carte (préférence UI locale, pas 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 (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:`, 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 "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).