diff --git a/CLAUDE.md b/CLAUDE.md
index f44040f..a912d1c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,8 +16,13 @@ user-facing overview.
`serial`/`modelNumber`/`brand`, a **sold** lifecycle, and freeform `customFields`
(JSON, e.g. VIN). Maintenance = a `Task` linked via `Task.itemId`. **HomeBox CSV
import** at `/api/v1/items/import` (pure parser `src/lib/inventory/homebox-import.ts`).
- `kind` is DURABLE|CONSUMABLE — consumable/Pantry is unbuilt (fields present so it
- needs no migration). Shares the Location spine.
+ `kind` is DURABLE|CONSUMABLE. Shares the Location spine.
+- **Pantry** (Kitchen → Pantry & Freezer) — CONSUMABLE Items with categories,
+ price paid, tags (shared `Label` table), and expiry. Pulls go through
+ `/api/v1/pantry/[id]/use` or `/bulk-use` and write `ItemLog` type `USED` with a
+ structured `amount` (consumption is queryable — don't regress to NOTE strings).
+ Service `src/lib/services/pantry.ts`; pure logic + tests in `src/lib/pantry/core.ts`.
+ Rides the `items:*` scopes (pantry items ARE Items).
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
tree, and `Task`s can link to an animal (`Task.animalId`).
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
@@ -100,6 +105,9 @@ reloads. **Never rsync over the appdata dir** — it fights the checkout.
- **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English
entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
+ **Fetch first**: parallel sessions push to master — check
+ `git show origin/master:src/lib/version.ts` before picking a number
+ (a v0.22.0 collision has already happened once).
- **AuditEvent on every mutation** — append-only.
- **Soft-delete** via `active=false`.
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.
diff --git a/prisma/migrations/20260706042808_v0_22_pantry_used_log/migration.sql b/prisma/migrations/20260706042808_v0_22_pantry_used_log/migration.sql
new file mode 100644
index 0000000..73930ae
--- /dev/null
+++ b/prisma/migrations/20260706042808_v0_22_pantry_used_log/migration.sql
@@ -0,0 +1,5 @@
+-- AlterEnum
+ALTER TYPE "ItemLogType" ADD VALUE 'USED';
+
+-- AlterTable
+ALTER TABLE "ItemLog" ADD COLUMN "amount" DECIMAL(10,2);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5e00980..fc79d6c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -420,6 +420,7 @@ enum ItemLogType {
PURCHASED
REPAIRED
DISPOSED
+ USED // consumable pulled from the pantry; `amount` holds how much
}
model ItemLog {
@@ -430,6 +431,7 @@ model ItemLog {
date DateTime @default(now())
notes String?
cost Decimal? @db.Decimal(10, 2)
+ amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
actorId String?
createdAt DateTime @default(now())
diff --git a/src/app/(dashboard)/kitchen/pantry/page.tsx b/src/app/(dashboard)/kitchen/pantry/page.tsx
index 50df554..62a18ac 100644
--- a/src/app/(dashboard)/kitchen/pantry/page.tsx
+++ b/src/app/(dashboard)/kitchen/pantry/page.tsx
@@ -1,23 +1,15 @@
import { db } from "@/lib/db";
+import { sessionContext } from "@/lib/api/authenticate";
+import { listPantry } from "@/lib/services/pantry";
import { PantryClient } from "@/components/pantry/pantry-client";
export const metadata = { title: "Pantry & Freezer" };
export const dynamic = "force-dynamic";
export default async function PantryPage() {
+ const ctx = await sessionContext();
const [items, locations, plants] = await Promise.all([
- db.item.findMany({
- where: { kind: "CONSUMABLE", active: true },
- orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
- 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: { name: true } } } },
- },
- }),
+ listPantry(ctx),
db.location.findMany({
where: {
active: true,
@@ -32,27 +24,5 @@ export default async function PantryPage() {
db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }),
]);
- const serialized = items.map(i => ({
- id: i.id,
- name: i.name,
- description: i.description,
- quantity: Number(i.quantity),
- unit: i.unit,
- packageSize: i.description,
- storedAt: i.storedAt,
- bestBefore: i.bestBefore,
- source: i.source,
- notes: i.notes,
- pantryCategory: i.pantryCategory,
- pantrySubcategory: i.pantrySubcategory,
- pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
- priceUnit: i.priceUnit,
- locationId: i.location?.id ?? null,
- 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: { label: { name: string } }) => l.label.name),
- }));
-
- return ;
+ return ;
}
diff --git a/src/app/api/pantry/[id]/route.ts b/src/app/api/pantry/[id]/route.ts
deleted file mode 100644
index 7aa433b..0000000
--- a/src/app/api/pantry/[id]/route.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { NextResponse } from "next/server";
-import { z } from "zod";
-import { ItemSource } from "@prisma/client";
-import { db } from "@/lib/db";
-import { requireAuth } 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(),
- quantity: z.number().min(0).optional(),
- unit: z.string().nullable().optional(),
- packageSize: z.string().nullable().optional(),
- locationId: z.string().nullable().optional(),
- storedAt: z.string().nullable().optional(),
- bestBefore: z.string().nullable().optional(),
- source: z.nativeEnum(ItemSource).nullable().optional(),
- plantId: z.string().nullable().optional(),
- pantryCategory: z.string().nullable().optional(),
- pantrySubcategory: z.string().nullable().optional(),
- 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 } }) {
- try {
- const session = await requireAuth();
- const data = updateSchema.parse(await req.json());
-
- await db.item.update({
- where: { id: params.id },
- data: {
- ...(data.name !== undefined && { name: data.name }),
- ...(data.description !== undefined && { description: data.description ?? data.packageSize ?? null }),
- ...(data.quantity !== undefined && { quantity: data.quantity }),
- ...(data.unit !== undefined && { unit: data.unit ?? null }),
- ...(data.locationId !== undefined && { locationId: data.locationId ?? null }),
- ...(data.storedAt !== undefined && { storedAt: data.storedAt ? new Date(data.storedAt) : null }),
- ...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }),
- ...(data.source !== undefined && { source: data.source ?? null }),
- ...(data.plantId !== undefined && { plantId: data.plantId ?? null }),
- ...(data.pantryCategory !== undefined && { pantryCategory: data.pantryCategory ?? null }),
- ...(data.pantrySubcategory !== undefined && { pantrySubcategory: data.pantrySubcategory ?? null }),
- ...(data.pricePaid !== undefined && { pricePaid: data.pricePaid ?? null }),
- ...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }),
- ...(data.notes !== undefined && { notes: data.notes ?? null }),
- },
- });
-
- 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,
- 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 });
- console.error(err);
- return NextResponse.json({ error: "Server error" }, { status: 500 });
- }
-}
-
-export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
- try {
- const session = await requireAuth();
- await db.item.update({ where: { id: params.id }, data: { active: false } });
- await db.auditEvent.create({
- data: { action: "pantry.delete", entityId: params.id, actorId: session.user.id },
- });
- return NextResponse.json({ ok: true });
- } catch (err) {
- if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- console.error(err);
- return NextResponse.json({ error: "Server error" }, { status: 500 });
- }
-}
diff --git a/src/app/api/pantry/[id]/use/route.ts b/src/app/api/pantry/[id]/use/route.ts
deleted file mode 100644
index b211b20..0000000
--- a/src/app/api/pantry/[id]/use/route.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { NextResponse } from "next/server";
-import { z } from "zod";
-import { db } from "@/lib/db";
-import { requireAuth } from "@/lib/auth";
-
-const schema = z.object({
- amount: z.number().positive(),
- notes: z.string().nullable().optional(),
-});
-
-export async function POST(req: Request, { params }: { params: { id: string } }) {
- try {
- const session = await requireAuth();
- const { amount, notes } = schema.parse(await req.json());
-
- const item = await db.item.findUnique({ where: { id: params.id } });
- if (!item || !item.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
-
- const newQty = Math.max(0, Number(item.quantity) - amount);
-
- await db.item.update({ where: { id: params.id }, data: { quantity: newQty } });
-
- await db.itemLog.create({
- data: {
- itemId: params.id,
- type: "NOTE",
- notes: `Used ${amount} ${item.unit ?? ""}${notes ? ` — ${notes}` : ""}`.trim(),
- actorId: session.user.id,
- },
- });
-
- await db.auditEvent.create({
- data: {
- action: "pantry.use",
- entityId: params.id,
- actorId: session.user.id,
- payload: { amount, remaining: newQty, notes },
- },
- });
-
- return NextResponse.json({ ok: true, remaining: newQty });
- } 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 });
- console.error(err);
- return NextResponse.json({ error: "Server error" }, { status: 500 });
- }
-}
diff --git a/src/app/api/pantry/bulk-use/route.ts b/src/app/api/pantry/bulk-use/route.ts
deleted file mode 100644
index d9b8bf0..0000000
--- a/src/app/api/pantry/bulk-use/route.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { NextResponse } from "next/server";
-import { z } from "zod";
-import { db } from "@/lib/db";
-import { requireAuth } from "@/lib/auth";
-
-const schema = z.object({
- pulls: z.array(z.object({
- id: z.string(),
- amount: z.number().positive(),
- })).min(1),
- notes: z.string().nullable().optional(),
-});
-
-export async function POST(req: Request) {
- try {
- const session = await requireAuth();
- const { pulls, notes } = schema.parse(await req.json());
-
- const ids = pulls.map(p => p.id);
- const items = await db.item.findMany({
- where: { id: { in: ids }, kind: "CONSUMABLE", active: true },
- select: { id: true, name: true, quantity: true, unit: true },
- });
-
- const itemMap = new Map(items.map(i => [i.id, i]));
- const results: { id: string; remaining: number }[] = [];
-
- await db.$transaction(async tx => {
- for (const pull of pulls) {
- const item = itemMap.get(pull.id);
- if (!item) continue;
- const remaining = Math.max(0, Number(item.quantity) - pull.amount);
- await tx.item.update({ where: { id: pull.id }, data: { quantity: remaining } });
- await tx.itemLog.create({
- data: {
- itemId: pull.id,
- type: "NOTE",
- notes: `Used ${pull.amount} ${item.unit ?? ""}${notes ? ` — ${notes}` : ""}`.trim(),
- actorId: session.user.id,
- },
- });
- results.push({ id: pull.id, remaining });
- }
-
- await tx.auditEvent.create({
- data: {
- action: "pantry.bulk-use",
- actorId: session.user.id,
- payload: { pulls: pulls.map(p => ({ id: p.id, amount: p.amount })), notes },
- },
- });
- });
-
- return NextResponse.json({ ok: true, results });
- } 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 });
- console.error(err);
- return NextResponse.json({ error: "Server error" }, { status: 500 });
- }
-}
diff --git a/src/app/api/pantry/catalog/route.ts b/src/app/api/pantry/catalog/route.ts
deleted file mode 100644
index e0427ee..0000000
--- a/src/app/api/pantry/catalog/route.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { NextResponse } from "next/server";
-import { db } from "@/lib/db";
-import { requireAuth } from "@/lib/auth";
-
-// Returns distinct pantry item names + their most-recent settings for autocomplete.
-export async function GET(req: Request) {
- await requireAuth();
- const { searchParams } = new URL(req.url);
- const q = searchParams.get("q")?.trim().toLowerCase() ?? "";
-
- // Grab all consumable items (active + inactive so we remember old entries).
- const items = await db.item.findMany({
- where: {
- kind: "CONSUMABLE",
- ...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
- },
- orderBy: { updatedAt: "desc" },
- select: {
- name: true, description: true, unit: true, source: true,
- pantryCategory: true, pantrySubcategory: true,
- priceUnit: true, pricePaid: true,
- locationId: true, plantId: true,
- location: { select: { id: true, name: true } },
- },
- take: 200,
- });
-
- // Deduplicate by name (case-insensitive), keep the most-recent record per name.
- const seen = new Map();
- for (const item of items) {
- const key = item.name.toLowerCase();
- if (!seen.has(key)) seen.set(key, item);
- }
-
- // 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
- .filter(c => c.pantrySubcategory)
- .map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
-
- return NextResponse.json({
- suggestions: Array.from(seen.values()).map(i => ({
- name: i.name,
- description: i.description,
- unit: i.unit,
- source: i.source,
- pantryCategory: i.pantryCategory,
- pantrySubcategory: i.pantrySubcategory,
- priceUnit: i.priceUnit,
- pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
- locationId: i.locationId,
- locationName: i.location?.name ?? null,
- plantId: i.plantId,
- })),
- categories,
- subcategories,
- labels: allLabels.map(l => l.name),
- });
-}
diff --git a/src/app/api/pantry/route.ts b/src/app/api/pantry/route.ts
deleted file mode 100644
index 54a60df..0000000
--- a/src/app/api/pantry/route.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { NextResponse } from "next/server";
-import { z } from "zod";
-import { ItemSource } from "@prisma/client";
-import { db } from "@/lib/db";
-import { requireAuth } 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;
-
-function serialize(i: any) {
- return {
- id: i.id, name: i.name, description: i.description,
- quantity: Number(i.quantity), unit: i.unit,
- packageSize: i.description,
- storedAt: i.storedAt, bestBefore: i.bestBefore,
- source: i.source, notes: i.notes,
- pantryCategory: i.pantryCategory,
- pantrySubcategory: i.pantrySubcategory,
- pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
- priceUnit: i.priceUnit,
- locationId: i.location?.id ?? null,
- 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({
- where: { kind: "CONSUMABLE", active: true },
- orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
- select: PANTRY_SELECT,
- });
- return NextResponse.json(items.map(serialize));
-}
-
-const createSchema = z.object({
- name: z.string().min(1),
- description: z.string().nullable().optional(),
- quantity: z.number().min(0).default(1),
- unit: z.string().nullable().optional(),
- packageSize: z.string().nullable().optional(),
- locationId: z.string().nullable().optional(),
- storedAt: z.string().nullable().optional(),
- bestBefore: z.string().nullable().optional(),
- source: z.nativeEnum(ItemSource).nullable().optional(),
- plantId: z.string().nullable().optional(),
- pantryCategory: z.string().nullable().optional(),
- pantrySubcategory: z.string().nullable().optional(),
- 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) {
- try {
- const session = await requireAuth();
- const data = createSchema.parse(await req.json());
-
- const item = await db.item.create({
- data: {
- name: data.name,
- description: data.description ?? data.packageSize ?? null,
- kind: "CONSUMABLE",
- quantity: data.quantity,
- unit: data.unit ?? null,
- locationId: data.locationId ?? null,
- storedAt: data.storedAt ? new Date(data.storedAt) : null,
- bestBefore: data.bestBefore ? new Date(data.bestBefore) : null,
- source: data.source ?? null,
- plantId: data.plantId ?? null,
- pantryCategory: data.pantryCategory ?? null,
- pantrySubcategory: data.pantrySubcategory ?? null,
- pricePaid: data.pricePaid ?? null,
- priceUnit: data.priceUnit ?? null,
- notes: data.notes ?? null,
- },
- 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 } },
- });
-
- // 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 });
- console.error(err);
- return NextResponse.json({ error: "Server error" }, { status: 500 });
- }
-}
diff --git a/src/app/api/v1/pantry/[id]/route.ts b/src/app/api/v1/pantry/[id]/route.ts
new file mode 100644
index 0000000..e5084ec
--- /dev/null
+++ b/src/app/api/v1/pantry/[id]/route.ts
@@ -0,0 +1,26 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { pantryUpdateInput } from "@/lib/api/schemas";
+import { updatePantryItem, deletePantryItem } from "@/lib/services/pantry";
+
+// PATCH /api/v1/pantry/:id
+export async function PATCH(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const input = pantryUpdateInput.parse(await req.json());
+ return NextResponse.json(await updatePantryItem(ctx, params.id, input));
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
+
+// DELETE /api/v1/pantry/:id — soft-delete (active=false)
+export async function DELETE(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ await deletePantryItem(ctx, params.id);
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/pantry/[id]/use/route.ts b/src/app/api/v1/pantry/[id]/use/route.ts
new file mode 100644
index 0000000..b191b71
--- /dev/null
+++ b/src/app/api/v1/pantry/[id]/use/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { pantryUseInput } from "@/lib/api/schemas";
+import { usePantryItem } from "@/lib/services/pantry";
+
+// POST /api/v1/pantry/:id/use — pull an amount; logs a USED ItemLog
+export async function POST(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const input = pantryUseInput.parse(await req.json());
+ const result = await usePantryItem(ctx, params.id, input);
+ return NextResponse.json({ ok: true, ...result });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/pantry/bulk-use/route.ts b/src/app/api/v1/pantry/bulk-use/route.ts
new file mode 100644
index 0000000..4cc8f39
--- /dev/null
+++ b/src/app/api/v1/pantry/bulk-use/route.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { pantryBulkUseInput } from "@/lib/api/schemas";
+import { bulkUsePantry } from "@/lib/services/pantry";
+
+// POST /api/v1/pantry/bulk-use — pull several items at once.
+// Responds with `skipped` for any pull whose item no longer exists.
+export async function POST(req: Request) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const input = pantryBulkUseInput.parse(await req.json());
+ const result = await bulkUsePantry(ctx, input);
+ return NextResponse.json({ ok: result.skipped.length === 0, ...result });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/pantry/catalog/route.ts b/src/app/api/v1/pantry/catalog/route.ts
new file mode 100644
index 0000000..a8b68f0
--- /dev/null
+++ b/src/app/api/v1/pantry/catalog/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { pantryCatalog } from "@/lib/services/pantry";
+
+// GET /api/v1/pantry/catalog?q= — autocomplete data for the add/edit dialog
+export async function GET(req: Request) {
+ try {
+ const ctx = await authenticateRequest(req, "items:read");
+ const q = new URL(req.url).searchParams.get("q") ?? undefined;
+ return NextResponse.json(await pantryCatalog(ctx, q));
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/pantry/route.ts b/src/app/api/v1/pantry/route.ts
new file mode 100644
index 0000000..d04c3b2
--- /dev/null
+++ b/src/app/api/v1/pantry/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { pantryCreateInput } from "@/lib/api/schemas";
+import { listPantry, createPantryItem } from "@/lib/services/pantry";
+
+// Pantry items are consumable Items, so they ride the items:* scopes.
+
+// GET /api/v1/pantry
+export async function GET(req: Request) {
+ try {
+ const ctx = await authenticateRequest(req, "items:read");
+ return NextResponse.json(await listPantry(ctx));
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
+
+// POST /api/v1/pantry
+export async function POST(req: Request) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const input = pantryCreateInput.parse(await req.json());
+ return NextResponse.json(await createPantryItem(ctx, input), { status: 201 });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/components/pantry/pantry-client.tsx b/src/components/pantry/pantry-client.tsx
index 63fe833..9f0992f 100644
--- a/src/components/pantry/pantry-client.tsx
+++ b/src/components/pantry/pantry-client.tsx
@@ -57,7 +57,7 @@ function ItemRow({ item, locations, plants, onRefresh }: {
async function handleDelete() {
setDeleting(true);
try {
- const res = await fetch(`/api/pantry/${item.id}`, { method: "DELETE" });
+ const res = await fetch(`/api/v1/pantry/${item.id}`, { method: "DELETE" });
if (res.ok) onRefresh();
} finally {
setDeleting(false);
@@ -207,7 +207,7 @@ export function PantryClient({ initialItems, locations, plants }: {
const [items, setItems] = useState(initialItems);
async function refresh() {
- const res = await fetch("/api/pantry");
+ const res = await fetch("/api/v1/pantry");
if (res.ok) setItems(await res.json());
}
diff --git a/src/components/pantry/pantry-item-dialog.tsx b/src/components/pantry/pantry-item-dialog.tsx
index 781f8eb..84457ec 100644
--- a/src/components/pantry/pantry-item-dialog.tsx
+++ b/src/components/pantry/pantry-item-dialog.tsx
@@ -138,7 +138,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
// Load catalog on open (for name autocomplete suggestions)
useEffect(() => {
if (!open) return;
- fetch("/api/pantry/catalog")
+ fetch("/api/v1/pantry/catalog")
.then(r => r.json())
.then(setCatalog)
.catch(() => {});
@@ -160,7 +160,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
clearTimeout(debounceRef.current);
if (val.length < 2) { setNameSuggestions([]); setShowDropdown(false); return; }
debounceRef.current = setTimeout(async () => {
- const res = await fetch(`/api/pantry/catalog?q=${encodeURIComponent(val)}`);
+ const res = await fetch(`/api/v1/pantry/catalog?q=${encodeURIComponent(val)}`);
if (!res.ok) return;
const data: CatalogData = await res.json();
setNameSuggestions(data.suggestions.slice(0, 6));
@@ -232,7 +232,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
kind: "CONSUMABLE",
tags,
};
- const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", {
+ const res = await fetch(item ? `/api/v1/pantry/${item.id}` : "/api/v1/pantry", {
method: item ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
diff --git a/src/components/pantry/pull-dialog.tsx b/src/components/pantry/pull-dialog.tsx
index 03994d8..0d4828f 100644
--- a/src/components/pantry/pull-dialog.tsx
+++ b/src/components/pantry/pull-dialog.tsx
@@ -72,7 +72,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
setSaving(true);
setError(null);
try {
- const res = await fetch("/api/pantry/bulk-use", {
+ const res = await fetch("/api/v1/pantry/bulk-use", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -80,7 +80,21 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
notes: reason.trim() || null,
}),
});
- if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error ?? "Failed");
+ if (data.skipped?.length > 0) {
+ // Someone else deleted these since the list loaded — say so instead of
+ // pretending the whole pull worked.
+ const names = data.skipped.map(
+ (s: { id: string }) => items.find(i => i.id === s.id)?.name ?? "an item",
+ );
+ setPulls({});
+ setError(
+ `Couldn't pull ${names.join(", ")} — no longer in the pantry. Everything else was pulled.`,
+ );
+ onSuccess();
+ return;
+ }
setOpen(false);
reset();
onSuccess();
diff --git a/src/components/pantry/use-item-dialog.tsx b/src/components/pantry/use-item-dialog.tsx
index 75782d9..d2187b7 100644
--- a/src/components/pantry/use-item-dialog.tsx
+++ b/src/components/pantry/use-item-dialog.tsx
@@ -43,7 +43,7 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
setSaving(true);
setError(null);
try {
- const res = await fetch(`/api/pantry/${item.id}/use`, {
+ const res = await fetch(`/api/v1/pantry/${item.id}/use`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null }),
diff --git a/src/lib/api/authenticate.ts b/src/lib/api/authenticate.ts
index 03b1e1e..930774f 100644
--- a/src/lib/api/authenticate.ts
+++ b/src/lib/api/authenticate.ts
@@ -81,6 +81,11 @@ export async function authenticateRequest(req: Request, required?: string): Prom
return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" };
}
+ return sessionContext();
+}
+
+/** AuthContext for server components (cookie session only, full scope). */
+export async function sessionContext(): Promise {
const session = await getSession();
if (!session) throw new ApiError(401, "Unauthorized");
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };
diff --git a/src/lib/api/schemas.ts b/src/lib/api/schemas.ts
index b1654f8..2523303 100644
--- a/src/lib/api/schemas.ts
+++ b/src/lib/api/schemas.ts
@@ -59,12 +59,51 @@ export const itemCreateInput = z.object({
export const itemUpdateInput = itemCreateInput.partial();
export const itemLogInput = z.object({
- type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]),
+ type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED", "USED"]),
notes: z.string().optional(),
cost: z.coerce.number().nonnegative().nullable().optional(),
+ amount: z.coerce.number().nonnegative().nullable().optional(),
date: z.string().optional(),
});
+// --- Pantry (consumable items; the UI's Kitchen → Pantry & Freezer) ---
+
+export const pantryCreateInput = z.object({
+ name: z.string().min(1),
+ description: z.string().nullable().optional(),
+ quantity: z.coerce.number().min(0).default(1),
+ unit: z.string().nullable().optional(),
+ packageSize: z.string().nullable().optional(), // legacy alias for description
+ locationId: z.string().nullable().optional(),
+ storedAt: z.string().nullable().optional(),
+ bestBefore: z.string().nullable().optional(),
+ source: z.enum(["GARDEN", "HOMEMADE", "STORE", "FARMERS_MARKET", "GIFTED", "OTHER"]).nullable().optional(),
+ plantId: z.string().nullable().optional(),
+ pantryCategory: z.string().nullable().optional(),
+ pantrySubcategory: z.string().nullable().optional(),
+ pricePaid: z.coerce.number().nonnegative().nullable().optional(),
+ priceUnit: z.string().nullable().optional(),
+ notes: z.string().nullable().optional(),
+ tags: z.array(z.string()).optional(),
+});
+
+export const pantryUpdateInput = pantryCreateInput.partial();
+
+export const pantryUseInput = z.object({
+ amount: z.coerce.number().positive(),
+ notes: z.string().nullable().optional(),
+});
+
+export const pantryBulkUseInput = z.object({
+ pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1),
+ notes: z.string().nullable().optional(),
+});
+
+export type PantryCreateInput = z.infer;
+export type PantryUpdateInput = z.infer;
+export type PantryUseInput = z.infer;
+export type PantryBulkUseInput = z.infer;
+
export type PlantLogInput = z.infer;
export type TaskCreateInput = z.infer;
export type TaskCompleteInput = z.infer;
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index 75b00fd..62bf6de 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -8,6 +8,15 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.23.0",
+ date: "2026-07-06",
+ changes: [
+ "Pantry: pulls are now recorded properly — each one saves the exact amount taken, so we can later see how fast things get used and what a dinner or trip actually consumed.",
+ "Pantry: if someone else deleted an item while you had the Pull dialog open, it now tells you which items couldn't be pulled instead of quietly skipping them.",
+ "Behind the scenes: the Pantry now runs on the same modern plumbing as Inventory and Animals, so API tokens (and the future Claude connection) can read and update it too.",
+ ],
+ },
{
version: "0.22.0",
date: "2026-07-04",
diff --git a/src/lib/inventory/item-fields.ts b/src/lib/inventory/item-fields.ts
index 9ecbc40..8f73034 100644
--- a/src/lib/inventory/item-fields.ts
+++ b/src/lib/inventory/item-fields.ts
@@ -7,6 +7,7 @@ export const ITEM_LOG_LABELS: Record = {
PURCHASED: "Purchased",
REPAIRED: "Repaired",
DISPOSED: "Disposed",
+ USED: "Used",
};
export const ITEM_LOG_COLORS: Record = {
@@ -16,8 +17,11 @@ export const ITEM_LOG_COLORS: Record = {
PURCHASED: "text-green-600 dark:text-green-400",
REPAIRED: "text-violet-600 dark:text-violet-400",
DISPOSED: "text-red-600 dark:text-red-400",
+ USED: "text-teal-600 dark:text-teal-400",
};
+// Types offered in the manual "add log" dropdown for durable items.
+// USED is written by pantry pulls, not entered by hand, so it's not listed.
export const ITEM_LOG_TYPES: ItemLogType[] = [
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
];
diff --git a/src/lib/pantry/core.test.ts b/src/lib/pantry/core.test.ts
new file mode 100644
index 0000000..8c50695
--- /dev/null
+++ b/src/lib/pantry/core.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import { normalizeTags, planBulkUse, serializePantryItem, type PantryRow } from "./core";
+
+describe("normalizeTags", () => {
+ it("trims, drops empties, and dedupes", () => {
+ expect(normalizeTags([" Poultry ", "Pre-cooked", "", " ", "Poultry"])).toEqual([
+ "Poultry",
+ "Pre-cooked",
+ ]);
+ });
+
+ it("keeps distinct casings distinct (labels are case-sensitive)", () => {
+ expect(normalizeTags(["poultry", "Poultry"])).toEqual(["poultry", "Poultry"]);
+ });
+});
+
+describe("serializePantryItem", () => {
+ const row: PantryRow = {
+ id: "i1",
+ name: "Chicken thighs",
+ description: "2 lb pack",
+ quantity: "3.50", // Prisma Decimal stringifies like this
+ unit: "lbs",
+ storedAt: new Date("2026-06-01"),
+ bestBefore: new Date("2026-09-01"),
+ source: "STORE",
+ notes: null,
+ pantryCategory: "Meat",
+ pantrySubcategory: "Poultry",
+ pricePaid: "8.99",
+ priceUnit: "lb",
+ location: { id: "l1", name: "Chest freezer" },
+ plant: null,
+ labels: [{ label: { name: "Pre-cooked" } }],
+ };
+
+ it("converts Decimals to numbers and flattens relations", () => {
+ const dto = serializePantryItem(row);
+ expect(dto.quantity).toBe(3.5);
+ expect(dto.pricePaid).toBe(8.99);
+ expect(dto.locationId).toBe("l1");
+ expect(dto.locationName).toBe("Chest freezer");
+ expect(dto.packageSize).toBe("2 lb pack");
+ expect(dto.tags).toEqual(["Pre-cooked"]);
+ });
+
+ it("handles null price, location, and plant; formats plant with variety", () => {
+ const dto = serializePantryItem({
+ ...row,
+ pricePaid: null,
+ location: null,
+ plant: { id: "p1", commonName: "Tomato", variety: "Roma" },
+ });
+ expect(dto.pricePaid).toBeNull();
+ expect(dto.locationId).toBeNull();
+ expect(dto.plantName).toBe("Tomato (Roma)");
+ });
+});
+
+describe("planBulkUse", () => {
+ const items = [
+ { id: "a", name: "Flour", quantity: 10, unit: "lbs" },
+ { id: "b", name: "Eggs", quantity: 12, unit: "each" },
+ ];
+
+ it("computes remaining quantities per pull", () => {
+ const plan = planBulkUse(items, [
+ { id: "a", amount: 2 },
+ { id: "b", amount: 6 },
+ ]);
+ expect(plan.skipped).toEqual([]);
+ expect(plan.updates).toEqual([
+ { id: "a", name: "Flour", unit: "lbs", amount: 2, remaining: 8 },
+ { id: "b", name: "Eggs", unit: "each", amount: 6, remaining: 6 },
+ ]);
+ });
+
+ it("reports pulls whose item is missing instead of silently dropping them", () => {
+ const plan = planBulkUse(items, [
+ { id: "a", amount: 1 },
+ { id: "gone", amount: 5 },
+ ]);
+ expect(plan.skipped).toEqual(["gone"]);
+ expect(plan.updates).toHaveLength(1);
+ });
+
+ it("accumulates repeated pulls of the same item", () => {
+ const plan = planBulkUse(items, [
+ { id: "b", amount: 4 },
+ { id: "b", amount: 4 },
+ ]);
+ expect(plan.updates).toEqual([
+ { id: "b", name: "Eggs", unit: "each", amount: 8, remaining: 4 },
+ ]);
+ });
+
+ it("caps at zero — never goes negative, and logs only what was there", () => {
+ const plan = planBulkUse(items, [{ id: "a", amount: 25 }]);
+ expect(plan.updates).toEqual([
+ { id: "a", name: "Flour", unit: "lbs", amount: 10, remaining: 0 },
+ ]);
+ });
+});
diff --git a/src/lib/pantry/core.ts b/src/lib/pantry/core.ts
new file mode 100644
index 0000000..f00549f
--- /dev/null
+++ b/src/lib/pantry/core.ts
@@ -0,0 +1,125 @@
+// Pure pantry logic — tag normalization, row serialization, and bulk-pull
+// planning. No DB access; the service layer (src/lib/services/pantry.ts)
+// applies these against Prisma.
+
+/** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */
+export function normalizeTags(tags: string[]): string[] {
+ return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
+}
+
+// The DB row shape the pantry queries select. Decimals arrive as anything
+// Number() can coerce (Prisma Decimal serializes via toString).
+export type PantryRow = {
+ id: string;
+ name: string;
+ description: string | null;
+ quantity: unknown;
+ unit: string | null;
+ storedAt: Date | null;
+ bestBefore: Date | null;
+ source: string | null;
+ notes: string | null;
+ pantryCategory: string | null;
+ pantrySubcategory: string | null;
+ pricePaid: unknown | null;
+ priceUnit: string | null;
+ location: { id: string; name: string } | null;
+ plant: { id: string; commonName: string; variety: string | null } | null;
+ labels: { label: { name: string } }[];
+};
+
+export type PantryItemDto = {
+ id: string;
+ name: string;
+ description: string | null;
+ quantity: number;
+ unit: string | null;
+ packageSize: string | null;
+ storedAt: Date | null;
+ bestBefore: Date | null;
+ source: string | null;
+ notes: string | null;
+ pantryCategory: string | null;
+ pantrySubcategory: string | null;
+ pricePaid: number | null;
+ priceUnit: string | null;
+ locationId: string | null;
+ locationName: string | null;
+ plantId: string | null;
+ plantName: string | null;
+ tags: string[];
+};
+
+/** Flatten a pantry DB row into the client shape (Decimals → numbers). */
+export function serializePantryItem(i: PantryRow): PantryItemDto {
+ return {
+ id: i.id,
+ name: i.name,
+ description: i.description,
+ quantity: Number(i.quantity),
+ unit: i.unit,
+ packageSize: i.description, // the UI labels `description` as "package size"
+ storedAt: i.storedAt,
+ bestBefore: i.bestBefore,
+ source: i.source,
+ notes: i.notes,
+ pantryCategory: i.pantryCategory,
+ pantrySubcategory: i.pantrySubcategory,
+ pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
+ priceUnit: i.priceUnit,
+ locationId: i.location?.id ?? null,
+ 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 => l.label.name),
+ };
+}
+
+export type BulkPull = { id: string; amount: number };
+export type BulkUseUpdate = {
+ id: string;
+ name: string;
+ unit: string | null;
+ amount: number; // how much actually comes off (capped at what's there)
+ remaining: number;
+};
+export type BulkUsePlan = {
+ updates: BulkUseUpdate[];
+ /** Pull ids that matched no live pantry item — the caller must surface these. */
+ skipped: string[];
+};
+
+/**
+ * Turn requested pulls into quantity updates against the items on hand.
+ * Repeated pulls of the same item accumulate; quantities never go below zero.
+ */
+export function planBulkUse(
+ items: { id: string; name: string; quantity: number; unit: string | null }[],
+ pulls: BulkPull[],
+): BulkUsePlan {
+ const remaining = new Map(items.map(i => [i.id, i]));
+ const updates = new Map();
+ const skipped: string[] = [];
+
+ for (const pull of pulls) {
+ const item = remaining.get(pull.id);
+ if (!item) {
+ skipped.push(pull.id);
+ continue;
+ }
+ const prev = updates.get(pull.id);
+ const before = prev ? prev.remaining : item.quantity;
+ const taken = Math.min(pull.amount, before);
+ updates.set(pull.id, {
+ id: item.id,
+ name: item.name,
+ unit: item.unit,
+ amount: (prev?.amount ?? 0) + taken,
+ remaining: before - taken,
+ });
+ }
+
+ return { updates: Array.from(updates.values()), skipped };
+}
diff --git a/src/lib/services/items.ts b/src/lib/services/items.ts
index 1f608ad..342e55d 100644
--- a/src/lib/services/items.ts
+++ b/src/lib/services/items.ts
@@ -200,6 +200,7 @@ export async function addItemLog(ctx: AuthContext, itemId: string, input: ItemLo
date: input.date ? new Date(input.date) : new Date(),
notes: input.notes?.trim() || null,
cost: input.cost ?? null,
+ amount: input.amount ?? null,
actorId: ctx.userId,
},
});
diff --git a/src/lib/services/pantry.ts b/src/lib/services/pantry.ts
new file mode 100644
index 0000000..10a4b3e
--- /dev/null
+++ b/src/lib/services/pantry.ts
@@ -0,0 +1,291 @@
+// Pantry service — DB work + AuditEvent for consumable items (Kitchen →
+// Pantry & Freezer). Shared by the /api/v1/pantry routes, the UI (which calls
+// those routes), and later the MCP server. Pure logic lives in
+// src/lib/pantry/core.ts.
+import { db } from "@/lib/db";
+import { ApiError, type AuthContext } from "@/lib/api/authenticate";
+import {
+ normalizeTags,
+ planBulkUse,
+ serializePantryItem,
+ type PantryItemDto,
+} from "@/lib/pantry/core";
+import type {
+ PantryBulkUseInput,
+ PantryCreateInput,
+ PantryUpdateInput,
+ PantryUseInput,
+} from "@/lib/api/schemas";
+
+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;
+
+function toDate(s?: string | null): Date | null {
+ return s ? new Date(s) : null;
+}
+
+/** Upsert labels for the tag names, then replace this item's label links. */
+async function syncTags(itemId: string, tags: string[]) {
+ const names = normalizeTags(tags);
+ 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 listPantry(_ctx: AuthContext): Promise {
+ const items = await db.item.findMany({
+ where: { kind: "CONSUMABLE", active: true },
+ orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
+ select: PANTRY_SELECT,
+ });
+ return items.map(serializePantryItem);
+}
+
+export async function createPantryItem(
+ ctx: AuthContext,
+ input: PantryCreateInput,
+): Promise {
+ const item = await db.item.create({
+ data: {
+ name: input.name,
+ description: input.description ?? input.packageSize ?? null,
+ kind: "CONSUMABLE",
+ quantity: input.quantity,
+ unit: input.unit ?? null,
+ locationId: input.locationId ?? null,
+ storedAt: toDate(input.storedAt),
+ bestBefore: toDate(input.bestBefore),
+ source: input.source ?? null,
+ plantId: input.plantId ?? null,
+ pantryCategory: input.pantryCategory ?? null,
+ pantrySubcategory: input.pantrySubcategory ?? null,
+ pricePaid: input.pricePaid ?? null,
+ priceUnit: input.priceUnit ?? null,
+ notes: input.notes ?? null,
+ },
+ select: { id: true, name: true },
+ });
+
+ if (input.tags?.length) await syncTags(item.id, input.tags);
+
+ await db.auditEvent.create({
+ data: {
+ action: "pantry.create",
+ entityId: item.id,
+ actorId: ctx.userId,
+ payload: { name: item.name, via: ctx.via },
+ },
+ });
+
+ const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
+ return serializePantryItem(full);
+}
+
+export async function updatePantryItem(
+ ctx: AuthContext,
+ id: string,
+ input: PantryUpdateInput,
+): Promise {
+ const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true } });
+ if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
+ throw new ApiError(404, "Pantry item not found");
+
+ await db.item.update({
+ where: { id },
+ data: {
+ ...(input.name !== undefined && { name: input.name }),
+ ...(input.description !== undefined && { description: input.description ?? input.packageSize ?? null }),
+ ...(input.quantity !== undefined && { quantity: input.quantity }),
+ ...(input.unit !== undefined && { unit: input.unit ?? null }),
+ ...(input.locationId !== undefined && { locationId: input.locationId ?? null }),
+ ...(input.storedAt !== undefined && { storedAt: toDate(input.storedAt) }),
+ ...(input.bestBefore !== undefined && { bestBefore: toDate(input.bestBefore) }),
+ ...(input.source !== undefined && { source: input.source ?? null }),
+ ...(input.plantId !== undefined && { plantId: input.plantId ?? null }),
+ ...(input.pantryCategory !== undefined && { pantryCategory: input.pantryCategory ?? null }),
+ ...(input.pantrySubcategory !== undefined && { pantrySubcategory: input.pantrySubcategory ?? null }),
+ ...(input.pricePaid !== undefined && { pricePaid: input.pricePaid ?? null }),
+ ...(input.priceUnit !== undefined && { priceUnit: input.priceUnit ?? null }),
+ ...(input.notes !== undefined && { notes: input.notes ?? null }),
+ },
+ });
+
+ if (input.tags !== undefined) await syncTags(id, input.tags ?? []);
+
+ const item = await db.item.findUniqueOrThrow({ where: { id }, select: PANTRY_SELECT });
+
+ await db.auditEvent.create({
+ data: {
+ action: "pantry.update",
+ entityId: id,
+ actorId: ctx.userId,
+ payload: { name: item.name, via: ctx.via },
+ },
+ });
+
+ return serializePantryItem(item);
+}
+
+export async function deletePantryItem(ctx: AuthContext, id: string): Promise {
+ const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true, name: true } });
+ if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
+ throw new ApiError(404, "Pantry item not found");
+
+ await db.item.update({ where: { id }, data: { active: false } });
+ await db.auditEvent.create({
+ data: {
+ action: "pantry.delete",
+ entityId: id,
+ actorId: ctx.userId,
+ payload: { name: existing.name, via: ctx.via },
+ },
+ });
+}
+
+/** Pull a single item; wraps bulkUsePantry so both paths log identically. */
+export async function usePantryItem(
+ ctx: AuthContext,
+ id: string,
+ input: PantryUseInput,
+): Promise<{ remaining: number }> {
+ const result = await bulkUsePantry(ctx, {
+ pulls: [{ id, amount: input.amount }],
+ notes: input.notes ?? null,
+ });
+ if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
+ return { remaining: result.results[0].remaining };
+}
+
+export type BulkUseResult = {
+ results: { id: string; remaining: number }[];
+ /** Requested items that no longer exist — the UI must tell the user. */
+ skipped: { id: string }[];
+};
+
+export async function bulkUsePantry(
+ ctx: AuthContext,
+ input: PantryBulkUseInput,
+): Promise {
+ const ids = input.pulls.map(p => p.id);
+ const notes = input.notes?.trim() || null;
+
+ return db.$transaction(async tx => {
+ const items = await tx.item.findMany({
+ where: { id: { in: ids }, kind: "CONSUMABLE", active: true },
+ select: { id: true, name: true, quantity: true, unit: true },
+ });
+
+ const plan = planBulkUse(
+ items.map(i => ({ id: i.id, name: i.name, quantity: Number(i.quantity), unit: i.unit })),
+ input.pulls,
+ );
+
+ for (const u of plan.updates) {
+ await tx.item.update({ where: { id: u.id }, data: { quantity: u.remaining } });
+ await tx.itemLog.create({
+ data: {
+ itemId: u.id,
+ type: "USED",
+ amount: u.amount,
+ notes,
+ actorId: ctx.userId,
+ },
+ });
+ }
+
+ await tx.auditEvent.create({
+ data: {
+ action: "pantry.use",
+ actorId: ctx.userId,
+ payload: {
+ pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })),
+ skipped: plan.skipped,
+ notes,
+ via: ctx.via,
+ },
+ },
+ });
+
+ return {
+ results: plan.updates.map(u => ({ id: u.id, remaining: u.remaining })),
+ skipped: plan.skipped.map(id => ({ id })),
+ };
+ });
+}
+
+/** Distinct names + most-recent settings, categories, and labels for autocomplete. */
+export async function pantryCatalog(_ctx: AuthContext, q?: string) {
+ const query = q?.trim() ?? "";
+
+ // All consumables, active + inactive, so old entries keep autocompleting.
+ const items = await db.item.findMany({
+ where: {
+ kind: "CONSUMABLE",
+ ...(query ? { name: { contains: query, mode: "insensitive" } } : {}),
+ },
+ orderBy: { updatedAt: "desc" },
+ select: {
+ name: true, description: true, unit: true, source: true,
+ pantryCategory: true, pantrySubcategory: true,
+ priceUnit: true, pricePaid: true,
+ locationId: true, plantId: true,
+ location: { select: { id: true, name: true } },
+ },
+ take: 200,
+ });
+
+ // Deduplicate by name (case-insensitive), keeping the most-recent per name.
+ const seen = new Map();
+ for (const item of items) {
+ const key = item.name.toLowerCase();
+ if (!seen.has(key)) seen.set(key, item);
+ }
+
+ 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
+ .filter(c => c.pantrySubcategory)
+ .map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
+
+ return {
+ suggestions: Array.from(seen.values()).map(i => ({
+ name: i.name,
+ description: i.description,
+ unit: i.unit,
+ source: i.source,
+ pantryCategory: i.pantryCategory,
+ pantrySubcategory: i.pantrySubcategory,
+ priceUnit: i.priceUnit,
+ pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
+ locationId: i.locationId,
+ locationName: i.location?.name ?? null,
+ plantId: i.plantId,
+ })),
+ categories,
+ subcategories,
+ labels: allLabels.map(l => l.name),
+ };
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 1b5548b..d6b3ad3 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
-export const APP_VERSION = "0.22.0";
+export const APP_VERSION = "0.23.0";