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:
@@ -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. "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.
|
||||
</p>
|
||||
</div>
|
||||
<IngredientsManager
|
||||
initialIngredients={rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
aliases: r.aliases,
|
||||
category: r.category,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user