v0.20.0 — Pantry: multi-tag items (e.g. Pre-cooked + Poultry)

Uses existing Label/LabelOnItem tables — no schema change. Tags field
in the add/edit dialog: type + Enter/comma to add, backspace to remove,
autocomplete from previously-used labels. Tags display as chips on each
item row. API: POST/PATCH accept tags[], upsert Label rows, sync
LabelOnItem. Catalog endpoint also returns all label names for
autocomplete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-06-29 18:25:07 -05:00
parent 70df86ba59
commit d3af826e3a
8 changed files with 176 additions and 17 deletions

View File

@@ -4,6 +4,29 @@ import { ItemSource } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
const PANTRY_SELECT = {
id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
labels: { select: { label: { select: { id: true, name: true } } } },
} as const;
async function syncTags(itemId: string, tags: string[]) {
const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
const labels = await Promise.all(
names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
);
await db.labelOnItem.deleteMany({ where: { itemId } });
if (labels.length) {
await db.labelOnItem.createMany({
data: labels.map(l => ({ itemId, labelId: l.id })),
skipDuplicates: true,
});
}
}
const updateSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
@@ -20,6 +43,7 @@ const updateSchema = z.object({
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
tags: z.array(z.string()).optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
@@ -27,7 +51,7 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
const session = await requireAuth();
const data = updateSchema.parse(await req.json());
const item = await db.item.update({
await db.item.update({
where: { id: params.id },
data: {
...(data.name !== undefined && { name: data.name }),
@@ -45,20 +69,22 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }),
...(data.notes !== undefined && { notes: data.notes ?? null }),
},
select: {
id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
},
});
if (data.tags !== undefined) await syncTags(params.id, data.tags);
const item = await db.item.findUniqueOrThrow({ where: { id: params.id }, select: PANTRY_SELECT });
await db.auditEvent.create({
data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
});
return NextResponse.json(item);
return NextResponse.json({
...item,
quantity: Number(item.quantity),
pricePaid: item.pricePaid != null ? Number(item.pricePaid) : null,
tags: item.labels.map(l => l.label.name),
});
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View File

@@ -32,12 +32,15 @@ export async function GET(req: Request) {
if (!seen.has(key)) seen.set(key, item);
}
// Also return distinct categories/subcategories for autocomplete dropdowns.
const allCats = await db.item.findMany({
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
select: { pantryCategory: true, pantrySubcategory: true },
distinct: ["pantryCategory", "pantrySubcategory"],
});
// Distinct categories/subcategories and all label names for autocomplete.
const [allCats, allLabels] = await Promise.all([
db.item.findMany({
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
select: { pantryCategory: true, pantrySubcategory: true },
distinct: ["pantryCategory", "pantrySubcategory"],
}),
db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }),
]);
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
const subcategories = allCats
@@ -60,5 +63,6 @@ export async function GET(req: Request) {
})),
categories,
subcategories,
labels: allLabels.map(l => l.name),
});
}

View File

@@ -10,6 +10,7 @@ const PANTRY_SELECT = {
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
labels: { select: { label: { select: { id: true, name: true } } } },
} as const;
function serialize(i: any) {
@@ -27,9 +28,25 @@ function serialize(i: any) {
locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null,
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
tags: (i.labels ?? []).map((l: any) => l.label.name) as string[],
};
}
async function syncTags(itemId: string, tags: string[]) {
const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
// Upsert each label, then replace all LabelOnItem rows for this item.
const labels = await Promise.all(
names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
);
await db.labelOnItem.deleteMany({ where: { itemId } });
if (labels.length) {
await db.labelOnItem.createMany({
data: labels.map(l => ({ itemId, labelId: l.id })),
skipDuplicates: true,
});
}
}
export async function GET() {
await requireAuth();
const items = await db.item.findMany({
@@ -56,6 +73,7 @@ const createSchema = z.object({
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
tags: z.array(z.string()).optional(),
});
export async function POST(req: Request) {
@@ -84,11 +102,15 @@ export async function POST(req: Request) {
select: PANTRY_SELECT,
});
if (data.tags?.length) await syncTags(item.id, data.tags);
await db.auditEvent.create({
data: { action: "pantry.create", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
});
return NextResponse.json(serialize(item), { status: 201 });
// Re-fetch with labels
const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
return NextResponse.json(serialize(full), { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });