feat: admin Ingredients CRUD UI; fix "page not found" after deleting shopping list/recipe/collection (v0.85.0)

Admin > Ingredients lets admins add/edit/delete canonical ingredients and their aliases (e.g. "sel"/"sel fin"/"table salt") without touching code — previously only settable by hand in packages/db/src/seed.ts. Deleting just unlinks referencing pantry items/recipe ingredients (onDelete: set null), nothing else changes.

Fixed: deleting a shopping list, recipe, or collection from its own detail page used router.push to navigate away, leaving the deleted resource's URL in browser history — one back-button press landed on a real "Page not found" screen. All three now use router.replace so the dead URL never sits in history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 18:42:42 +02:00
parent ebe3216c04
commit 4ae0bd580e
14 changed files with 389 additions and 8 deletions
+31
View File
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Ingredients</h1>
<p className="text-muted-foreground text-sm mt-1">
Canonical ingredients and their name variants (e.g. &quot;sel&quot;, &quot;sel fin&quot;, &quot;table salt&quot;) 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.
</p>
</div>
<IngredientsManager
initialIngredients={rows.map((r) => ({
id: r.id,
name: r.name,
aliases: r.aliases,
category: r.category,
}))}
/>
</div>
);
}
+2 -1
View File
@@ -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 },
@@ -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 });
}
@@ -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 });
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit ingredient" : "New ingredient"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="ingredient-name">Canonical name</Label>
<Input id="ingredient-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. salt" />
</div>
<div className="space-y-1.5">
<Label htmlFor="ingredient-aliases">Aliases (comma-separated)</Label>
<Input
id="ingredient-aliases"
value={aliasesText}
onChange={(e) => setAliasesText(e.target.value)}
placeholder="e.g. sel, sel fin, sel de table, table salt"
/>
<p className="text-xs text-muted-foreground">
Matched case-insensitively against pantry item and recipe ingredient names.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ingredient-category">Category (optional)</Label>
<Input id="ingredient-category" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="e.g. spicesCondiments" />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button type="button" onClick={() => { void handleSave(); }} disabled={saving || !name.trim()}>
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function IngredientsManager({ initialIngredients }: { initialIngredients: Ingredient[] }) {
const [ingredients, setIngredients] = useState<Ingredient[]>(initialIngredients);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingIngredient, setEditingIngredient] = useState<Ingredient | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(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 (
<div className="space-y-4 max-w-3xl">
<div className="flex justify-end">
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" /> New ingredient
</Button>
</div>
{ingredients.length === 0 ? (
<EmptyState icon={Tag} title="No ingredients yet" description="Add a canonical ingredient and its common name variants." compact />
) : (
<div className="rounded-xl border divide-y">
{ingredients.map((ingredient) => (
<div key={ingredient.id} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{ingredient.name}</span>
{ingredient.category && (
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground bg-muted rounded px-1.5 py-0.5">
{ingredient.category}
</span>
)}
</div>
{ingredient.aliases.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{ingredient.aliases.join(", ")}</p>
)}
</div>
<button onClick={() => openEdit(ingredient)} aria-label="Edit" className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => setConfirmId(ingredient.id)} aria-label="Delete" className="text-muted-foreground hover:text-destructive transition-colors shrink-0">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
<IngredientDialog
ingredient={editingIngredient}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSaved={(saved) => {
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));
});
}}
/>
<AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this ingredient?</AlertDialogTitle>
<AlertDialogDescription>
{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.` : ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (confirmId) void handleDelete(confirmId);
setConfirmId(null);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -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"));
@@ -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);
@@ -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"));
+11 -1
View File
@@ -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",
+10
View File
@@ -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 } } } } });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.84.0",
"version": "0.85.0",
"private": true,
"scripts": {
"dev": "next dev",