diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6e5ab..f560b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.85.0 — 2026-07-24 21:00 + +### Added +- Admin → Ingredients: manage canonical ingredients and their name variants (e.g. "sel"/"sel fin"/"table salt") without a code change — previously only editable by hand in the seed file. + +### Fixed +- Deleting a shopping list, recipe, or collection from its own page could leave you one back-button press away from a "Page not found" screen — the deleted URL stayed in browser history. Now replaces the history entry instead of pushing a new one. + ## 0.84.0 — 2026-07-24 20:00 ### Added diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index e1bb53b..49b375f 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -61,6 +61,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Weekly nutrition rollup | Exists | | `apps/web/app/api/v1/meal-plans/[weekStart]/nutrition` | | Meal plan print + Markdown export | Exists | Undocumented until this pass despite section 1 mentioning print/markdown generally | `/print/meal-plan`, `apps/web/lib/markdown/meal-plan.ts` | | Multiple shopping lists | Exists | | `apps/web/app/api/v1/shopping-lists/**` | +| "Page not found" after deleting (closed 2026-07-24) | Fixed | Deleting a shopping list, recipe, or collection from its own detail page navigated away with `router.push`, leaving the just-deleted URL in browser history — hitting the back button landed straight on a real 404 (`(app)/not-found.tsx`, literally "Page not found"). Switched all three to `router.replace` so the dead URL is never in history to go back to. | `apps/web/components/shopping-lists/shopping-list-actions-menu.tsx`, `apps/web/components/recipe/delete-recipe-button.tsx`, `apps/web/components/collections/delete-collection-dialog.tsx` | | Generate list from meal-plan week | Exists | Merges duplicate ingredients, flags items already covered by pantry (never silently drops) | `apps/web/lib/pantry-shopping-match.ts` | | Add to list from a single recipe | Exists | Separate path from meal-plan-based generation; also reachable via the cooking-chat AI tool ("add to shopping list") | `apps/web/components/recipe/add-to-shopping-list-button.tsx`, `apps/web/lib/ai/tools/add-to-shopping-list-tool.ts` | | Shared/collaborative lists | Exists | Member roles **and** an anonymous public link that can grant full anonymous edit rights (Google-Docs-style), not just read — `isPublic` + `publicEditable`; disabling `isPublic` force-revokes `publicEditable`. Also has its own print view with a QR code, and a public page distinct from the meal-plan one. | `apps/web/lib/shopping-list-access.ts`, `apps/web/app/s/[id]/page.tsx`, `/print/shopping-list/[id]` | @@ -70,7 +71,8 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` | | Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — | | Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` | -| Ingredient alias matching (new 2026-07-24) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. **No UI to add/edit aliases** — the seed list in `packages/db/src/seed.ts` is the only place today; growing it means editing that file and re-running `pnpm db:seed` (idempotent, `onConflictDoNothing` on name). | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | +| Ingredient alias matching (new 2026-07-24) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | +| Admin: Ingredients CRUD (new 2026-07-24) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` | | Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` | | Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | | Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` | diff --git a/apps/web/app/admin/ingredients/page.tsx b/apps/web/app/admin/ingredients/page.tsx new file mode 100644 index 0000000..6ee5a99 --- /dev/null +++ b/apps/web/app/admin/ingredients/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { db, ingredients, asc } from "@epicure/db"; +import { requireFullAdminPage } from "@/lib/require-admin-page"; +import { IngredientsManager } from "@/components/admin/ingredients-manager"; + +export const metadata: Metadata = {}; + +export default async function AdminIngredientsPage() { + await requireFullAdminPage(); + + const rows = await db.select().from(ingredients).orderBy(asc(ingredients.name)); + + return ( +
+
+

Ingredients

+

+ Canonical ingredients and their name variants (e.g. "sel", "sel fin", "table salt") — lets pantry items and recipe ingredients written differently still be recognized as the same thing. Used by pantry add/edit, can-cook scoring, auto-deduct on cook, and shopping-list pantry-awareness. +

+
+ ({ + id: r.id, + name: r.name, + aliases: r.aliases, + category: r.category, + }))} + /> +
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 2818d68..4e0824f 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook, CreditCard } from "lucide-react"; +import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook, CreditCard, Tag } from "lucide-react"; import { cn } from "@/lib/utils"; import { getStaffRole } from "@/lib/require-admin-page"; import { redirect } from "next/navigation"; @@ -16,6 +16,7 @@ const adminNav = [ { href: "/admin/reports", label: "Reports", icon: Flag, adminOnly: false }, { href: "/admin/support", label: "Support", icon: LifeBuoy, adminOnly: true }, { href: "/admin/tiers", label: "Tier Limits", icon: Gauge, adminOnly: true }, + { href: "/admin/ingredients", label: "Ingredients", icon: Tag, adminOnly: true }, { href: "/admin/billing", label: "Billing", icon: CreditCard, adminOnly: true }, { href: "/admin/webhooks", label: "Webhooks", icon: Webhook, adminOnly: true }, { href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList, adminOnly: true }, diff --git a/apps/web/app/api/v1/admin/ingredients/[id]/route.ts b/apps/web/app/api/v1/admin/ingredients/[id]/route.ts new file mode 100644 index 0000000..519819c --- /dev/null +++ b/apps/web/app/api/v1/admin/ingredients/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, ingredients, eq } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +const Schema = z.object({ + name: z.string().trim().min(1).max(100).optional(), + aliases: z.array(z.string().trim().min(1).max(100)).max(50).optional(), + category: z.string().max(50).nullable().optional(), +}); + +export async function PUT(req: NextRequest, { params }: Params) { + const { response } = await requireAdmin(); + if (response) return response; + const { id } = await params; + + const parsed = Schema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const data = parsed.data; + try { + await db.update(ingredients).set({ + ...(data.name && { name: data.name }), + ...(data.aliases && { aliases: data.aliases }), + ...(data.category !== undefined && { category: data.category ?? undefined }), + }).where(eq(ingredients.id, id)); + } catch { + return NextResponse.json({ error: "An ingredient with this name already exists" }, { status: 409 }); + } + + return NextResponse.json({ updated: true }); +} + +// Unlinks (doesn't cascade-delete) any pantry item / recipe ingredient that +// pointed at this canonical entry — both onDelete: "set null". +export async function DELETE(req: NextRequest, { params }: Params) { + const { response } = await requireAdmin(); + if (response) return response; + const { id } = await params; + + await db.delete(ingredients).where(eq(ingredients.id, id)); + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/admin/ingredients/route.ts b/apps/web/app/api/v1/admin/ingredients/route.ts new file mode 100644 index 0000000..d45bcb0 --- /dev/null +++ b/apps/web/app/api/v1/admin/ingredients/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, ingredients, asc } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; + +const Schema = z.object({ + name: z.string().trim().min(1).max(100), + aliases: z.array(z.string().trim().min(1).max(100)).max(50).default([]), + category: z.string().max(50).optional(), +}); + +export async function GET() { + const { response } = await requireAdmin(); + if (response) return response; + + const rows = await db.select().from(ingredients).orderBy(asc(ingredients.name)); + return NextResponse.json({ data: rows }); +} + +export async function POST(req: NextRequest) { + const { response } = await requireAdmin(); + if (response) return response; + + const parsed = Schema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const id = crypto.randomUUID(); + try { + await db.insert(ingredients).values({ + id, + name: parsed.data.name, + aliases: parsed.data.aliases, + category: parsed.data.category, + }); + } catch { + return NextResponse.json({ error: "An ingredient with this name already exists" }, { status: 409 }); + } + + return NextResponse.json({ id }, { status: 201 }); +} diff --git a/apps/web/components/admin/ingredients-manager.tsx b/apps/web/components/admin/ingredients-manager.tsx new file mode 100644 index 0000000..181cd7e --- /dev/null +++ b/apps/web/components/admin/ingredients-manager.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Plus, Pencil, Trash2, Tag } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { EmptyState } from "@/components/shared/empty-state"; + +type Ingredient = { + id: string; + name: string; + aliases: string[]; + category: string | null; +}; + +function parseAliases(input: string): string[] { + return [...new Set(input.split(",").map((a) => a.trim()).filter(Boolean))]; +} + +function IngredientDialog({ + ingredient, + open, + onOpenChange, + onSaved, +}: { + ingredient: Ingredient | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: (ingredient: Ingredient) => void; +}) { + const [name, setName] = useState(ingredient?.name ?? ""); + const [aliasesText, setAliasesText] = useState(ingredient?.aliases.join(", ") ?? ""); + const [category, setCategory] = useState(ingredient?.category ?? ""); + const [saving, setSaving] = useState(false); + const isEdit = !!ingredient; + + async function handleSave() { + const trimmedName = name.trim(); + if (!trimmedName) return; + const aliases = parseAliases(aliasesText); + setSaving(true); + try { + const res = await fetch( + isEdit ? `/api/v1/admin/ingredients/${ingredient.id}` : "/api/v1/admin/ingredients", + { + method: isEdit ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: trimmedName, aliases, category: category.trim() || null }), + } + ); + if (!res.ok) { + const data = await res.json().catch(() => null) as { error?: string } | null; + toast.error(data?.error ?? "Failed to save"); + return; + } + toast.success(isEdit ? "Ingredient updated" : "Ingredient created"); + const id = isEdit ? ingredient.id : ((await res.json()) as { id: string }).id; + onSaved({ id, name: trimmedName, aliases, category: category.trim() || null }); + onOpenChange(false); + } finally { + setSaving(false); + } + } + + return ( + + + + {isEdit ? "Edit ingredient" : "New ingredient"} + +
+
+ + setName(e.target.value)} placeholder="e.g. salt" /> +
+
+ + setAliasesText(e.target.value)} + placeholder="e.g. sel, sel fin, sel de table, table salt" + /> +

+ Matched case-insensitively against pantry item and recipe ingredient names. +

+
+
+ + setCategory(e.target.value)} placeholder="e.g. spicesCondiments" /> +
+
+ + + + +
+
+ ); +} + +export function IngredientsManager({ initialIngredients }: { initialIngredients: Ingredient[] }) { + const [ingredients, setIngredients] = useState(initialIngredients); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingIngredient, setEditingIngredient] = useState(null); + const [confirmId, setConfirmId] = useState(null); + + function openCreate() { + setEditingIngredient(null); + setDialogOpen(true); + } + + function openEdit(ingredient: Ingredient) { + setEditingIngredient(ingredient); + setDialogOpen(true); + } + + async function handleDelete(id: string) { + const res = await fetch(`/api/v1/admin/ingredients/${id}`, { method: "DELETE" }); + if (res.ok) { + setIngredients((prev) => prev.filter((i) => i.id !== id)); + toast.success("Ingredient deleted"); + } else { + toast.error("Failed to delete"); + } + } + + const pendingDelete = ingredients.find((i) => i.id === confirmId) ?? null; + + return ( +
+
+ +
+ + {ingredients.length === 0 ? ( + + ) : ( +
+ {ingredients.map((ingredient) => ( +
+
+
+ {ingredient.name} + {ingredient.category && ( + + {ingredient.category} + + )} +
+ {ingredient.aliases.length > 0 && ( +

{ingredient.aliases.join(", ")}

+ )} +
+ + +
+ ))} +
+ )} + + { + setIngredients((prev) => { + const exists = prev.some((i) => i.id === saved.id); + const next = exists ? prev.map((i) => (i.id === saved.id ? saved : i)) : [...prev, saved]; + return next.sort((a, b) => a.name.localeCompare(b.name)); + }); + }} + /> + + !open && setConfirmId(null)}> + + + Delete this ingredient? + + {pendingDelete ? `"${pendingDelete.name}" and its aliases will no longer be recognized as a match. Pantry items and recipe ingredients already linked to it just lose that link — nothing is deleted.` : ""} + + + + Cancel + { + if (confirmId) void handleDelete(confirmId); + setConfirmId(null); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + +
+ ); +} diff --git a/apps/web/components/collections/delete-collection-dialog.tsx b/apps/web/components/collections/delete-collection-dialog.tsx index e8a2171..116161a 100644 --- a/apps/web/components/collections/delete-collection-dialog.tsx +++ b/apps/web/components/collections/delete-collection-dialog.tsx @@ -32,7 +32,9 @@ export function DeleteCollectionDialog({ collectionId }: { collectionId: string const res = await fetch(`/api/v1/collections/${collectionId}?deleteRecipes=${deleteRecipes}`, { method: "DELETE" }); if (!res.ok) throw new Error(); toast.success(t("deleteSuccess")); - router.push("/collections"); + // replace, not push — the deleted collection's URL should never sit + // in history, or the browser back button lands straight on a 404. + router.replace("/collections"); router.refresh(); } catch { toast.error(t("deleteFailed")); diff --git a/apps/web/components/recipe/delete-recipe-button.tsx b/apps/web/components/recipe/delete-recipe-button.tsx index e42b3e3..051e645f 100644 --- a/apps/web/components/recipe/delete-recipe-button.tsx +++ b/apps/web/components/recipe/delete-recipe-button.tsx @@ -35,7 +35,9 @@ export function DeleteRecipeButton({ recipeId }: { recipeId: string }) { throw new Error(data.error ?? "Delete failed"); } toast.success(t("deleted")); - router.push("/recipes"); + // replace, not push — the deleted recipe's URL should never sit in + // history, or the browser back button lands straight on a 404. + router.replace("/recipes"); } catch (err) { toast.error(err instanceof Error ? err.message : t("deleteFailed")); setDeleting(false); diff --git a/apps/web/components/shopping-lists/shopping-list-actions-menu.tsx b/apps/web/components/shopping-lists/shopping-list-actions-menu.tsx index a2e1ce2..4705e50 100644 --- a/apps/web/components/shopping-lists/shopping-list-actions-menu.tsx +++ b/apps/web/components/shopping-lists/shopping-list-actions-menu.tsx @@ -83,7 +83,9 @@ export function ShoppingListActionsMenu({ listId, name, onRenamed, onDeleted, re toast.success(t("listDeleted")); setDeleteOpen(false); onDeleted?.(); - if (redirectAfterDeleteTo) router.push(redirectAfterDeleteTo); + // replace, not push — the deleted list's URL should never sit in + // history, or the browser back button lands straight on a 404. + if (redirectAfterDeleteTo) router.replace(redirectAfterDeleteTo); else router.refresh(); } catch { toast.error(t("listDeleteFailed")); diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index e97c29f..7076998 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.84.0"; +export const APP_VERSION = "0.85.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,16 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.85.0", + date: "2026-07-24 21:00", + added: [ + "Admin > Ingredients: manage canonical ingredients and their name variants (e.g. \"sel\"/\"sel fin\"/\"table salt\") without a code change — previously only editable by hand in the seed file.", + ], + fixed: [ + "Deleting a shopping list, recipe, or collection from its own page could leave you one back-button press away from a \"Page not found\" screen — the deleted URL stayed in browser history. Now replaces the history entry instead of pushing a new one.", + ], + }, { version: "0.84.0", date: "2026-07-24 20:00", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index ebb7986..c456b8b 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -975,6 +975,16 @@ export function generateOpenApiSpec(): object { enabled: z.boolean(), })); + const IngredientRef = registry.register("Ingredient", z.object({ + id: z.string(), name: z.string(), aliases: z.array(z.string()), category: z.string().nullable(), + })); + const UpsertIngredientRef = registry.register("UpsertIngredient", z.object({ + name: z.string().min(1).max(100), aliases: z.array(z.string().min(1).max(100)).max(50).default([]), category: z.string().max(50).optional(), + })); + registry.registerPath({ method: "get", path: "/api/v1/admin/ingredients", summary: "List canonical ingredients", description: "Admin only. Used for name/alias matching (\"sel\"/\"sel fin\"/\"table salt\" recognized as the same ingredient) across pantry, can-cook scoring, auto-deduct, and shopping-list generation.", security: adminSecurity, responses: { 200: { description: "Ingredients", content: { "application/json": { schema: z.object({ data: z.array(IngredientRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "post", path: "/api/v1/admin/ingredients", summary: "Create a canonical ingredient", description: "Admin only. name must be unique.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpsertIngredientRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Name already exists", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "put", path: "/api/v1/admin/ingredients/{id}", summary: "Update a canonical ingredient", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpsertIngredientRef.partial() } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Name already exists", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "delete", path: "/api/v1/admin/ingredients/{id}", summary: "Delete a canonical ingredient", description: "Admin only. Unlinks (doesn't cascade-delete) any pantry item or recipe ingredient that referenced it.", security: adminSecurity, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/admin/feature-flags", summary: "Get the full feature x tier toggle matrix", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Matrix", content: { "application/json": { schema: FeatureFlagMatrixRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "patch", path: "/api/v1/admin/feature-flags", summary: "Enable or disable a feature for a tier", description: "Admin only. Disabling a feature doesn't hide its button client-side — the corresponding AI route (variations/drinks/pairings) returns 403 with code FEATURE_DISABLED for users on that tier, and the UI shows an upgrade prompt instead.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateFeatureFlagRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } }); diff --git a/apps/web/package.json b/apps/web/package.json index 2f0d10b..3b5c176 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.84.0", + "version": "0.85.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 00a79bd..f04dac9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.84.0", + "version": "0.85.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",