v0.23.0 — Pantry: structured USED pull logs + move to /api/v1 service layer
Pantry now runs on the v1 API like Inventory/Animals: service layer in src/lib/services/pantry.ts, pure logic + tests in src/lib/pantry/core.ts, thin routes at /api/v1/pantry/* riding the items:* scopes. Pulls write ItemLog type USED with a structured amount (queryable consumption). Bulk-use runs its reads inside the transaction, accumulates repeated pulls, and reports skipped items instead of silently dropping them — the Pull dialog now surfaces those by name. Old /api/pantry routes deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
12
CLAUDE.md
12
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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ItemLogType" ADD VALUE 'USED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ItemLog" ADD COLUMN "amount" DECIMAL(10,2);
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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 <PantryClient initialItems={serialized} locations={locations} plants={plants} />;
|
||||
return <PantryClient initialItems={items} locations={locations} plants={plants} />;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, typeof items[number]>();
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
26
src/app/api/v1/pantry/[id]/route.ts
Normal file
26
src/app/api/v1/pantry/[id]/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
16
src/app/api/v1/pantry/[id]/use/route.ts
Normal file
16
src/app/api/v1/pantry/[id]/use/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
17
src/app/api/v1/pantry/bulk-use/route.ts
Normal file
17
src/app/api/v1/pantry/bulk-use/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/pantry/catalog/route.ts
Normal file
14
src/app/api/v1/pantry/catalog/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
27
src/app/api/v1/pantry/route.ts
Normal file
27
src/app/api/v1/pantry/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<AuthContext> {
|
||||
const session = await getSession();
|
||||
if (!session) throw new ApiError(401, "Unauthorized");
|
||||
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };
|
||||
|
||||
@@ -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<typeof pantryCreateInput>;
|
||||
export type PantryUpdateInput = z.infer<typeof pantryUpdateInput>;
|
||||
export type PantryUseInput = z.infer<typeof pantryUseInput>;
|
||||
export type PantryBulkUseInput = z.infer<typeof pantryBulkUseInput>;
|
||||
|
||||
export type PlantLogInput = z.infer<typeof plantLogInput>;
|
||||
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
|
||||
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
|
||||
PURCHASED: "Purchased",
|
||||
REPAIRED: "Repaired",
|
||||
DISPOSED: "Disposed",
|
||||
USED: "Used",
|
||||
};
|
||||
|
||||
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
|
||||
@@ -16,8 +17,11 @@ export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
|
||||
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",
|
||||
];
|
||||
|
||||
103
src/lib/pantry/core.test.ts
Normal file
103
src/lib/pantry/core.test.ts
Normal file
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
125
src/lib/pantry/core.ts
Normal file
125
src/lib/pantry/core.ts
Normal file
@@ -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<string, BulkUseUpdate>();
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
291
src/lib/services/pantry.ts
Normal file
291
src/lib/services/pantry.ts
Normal file
@@ -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<PantryItemDto[]> {
|
||||
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<PantryItemDto> {
|
||||
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<PantryItemDto> {
|
||||
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<void> {
|
||||
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<BulkUseResult> {
|
||||
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<string, (typeof items)[number]>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user