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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user