feat(recipes): permet d'ajouter des ingredients hors-catalogue
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait etre bloque (creation manuelle) ou perdre silencieusement la ligne (import). Une ligne de recette accepte desormais `placeholderName` (texte libre) au lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder` (cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont exclues de `GET /reference/ingredients` et de `ingredient-matcher`. Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker` (formulaire + import), badge "a completer" sur la ligne, helper `ingredientLabel` applique partout ou un libelle d'ingredient est rendu. Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des orphelins. La promotion en vraie entree catalogue reste manuelle. Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible). Suites Mocha DB-backed ecrites, non executees en session ; test pur `normalizePlaceholderName` + Cypress admin-web/web verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
15dad91a43
commit
5cc70d4244
43 changed files with 1815 additions and 116 deletions
|
|
@ -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}/`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
96
apps/admin-web/cypress/e2e/catalog.cy.ts
Normal file
96
apps/admin-web/cypress/e2e/catalog.cy.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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`. */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
169
apps/admin-web/src/pages/catalog/CatalogPage.tsx
Normal file
169
apps/admin-web/src/pages/catalog/CatalogPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/admin-web/src/pages/catalog/catalog-page.scss
Normal file
144
apps/admin-web/src/pages/catalog/catalog-page.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/admin-web/src/pages/catalog/catalog.ts
Normal file
34
apps/admin-web/src/pages/catalog/catalog.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
44
apps/api/src/modules/admin/admin-catalog.routes.ts
Normal file
44
apps/api/src/modules/admin/admin-catalog.routes.ts
Normal 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());
|
||||||
|
}),
|
||||||
|
);
|
||||||
156
apps/api/src/modules/admin/admin-catalog.service.ts
Normal file
156
apps/api/src/modules/admin/admin-catalog.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
27
apps/api/src/scripts/prune-orphan-placeholders.ts
Normal file
27
apps/api/src/scripts/prune-orphan-placeholders.ts
Normal 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);
|
||||||
|
});
|
||||||
32
apps/api/test/admin-catalog-normalize.test.ts
Normal file
32
apps/api/test/admin-catalog-normalize.test.ts
Normal 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 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
141
apps/api/test/admin-catalog.test.ts
Normal file
141
apps/api/test/admin-catalog.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
196
apps/api/test/recipe-placeholder.test.ts
Normal file
196
apps/api/test/recipe-placeholder.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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={() =>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -216,8 +216,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…",
|
||||||
|
|
@ -254,6 +255,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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`)}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,34 @@ 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"],
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One preparation step accepted by `POST /recipes`/`PATCH /recipes/:id`.
|
* 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
|
// 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;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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** —
|
**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)
|
||||||
|
|
@ -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
|
(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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue