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";