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
@@ -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 });
}