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`
|
`serial`/`modelNumber`/`brand`, a **sold** lifecycle, and freeform `customFields`
|
||||||
(JSON, e.g. VIN). Maintenance = a `Task` linked via `Task.itemId`. **HomeBox CSV
|
(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`).
|
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
|
`kind` is DURABLE|CONSUMABLE. Shares the Location spine.
|
||||||
needs no migration). 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
|
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
|
||||||
tree, and `Task`s can link to an animal (`Task.animalId`).
|
tree, and `Task`s can link to an animal (`Task.animalId`).
|
||||||
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
|
- **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
|
- **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"
|
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.
|
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.
|
- **AuditEvent on every mutation** — append-only.
|
||||||
- **Soft-delete** via `active=false`.
|
- **Soft-delete** via `active=false`.
|
||||||
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.
|
- **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
|
PURCHASED
|
||||||
REPAIRED
|
REPAIRED
|
||||||
DISPOSED
|
DISPOSED
|
||||||
|
USED // consumable pulled from the pantry; `amount` holds how much
|
||||||
}
|
}
|
||||||
|
|
||||||
model ItemLog {
|
model ItemLog {
|
||||||
@@ -430,6 +431,7 @@ model ItemLog {
|
|||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
notes String?
|
notes String?
|
||||||
cost Decimal? @db.Decimal(10, 2)
|
cost Decimal? @db.Decimal(10, 2)
|
||||||
|
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
|
||||||
actorId String?
|
actorId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { sessionContext } from "@/lib/api/authenticate";
|
||||||
|
import { listPantry } from "@/lib/services/pantry";
|
||||||
import { PantryClient } from "@/components/pantry/pantry-client";
|
import { PantryClient } from "@/components/pantry/pantry-client";
|
||||||
|
|
||||||
export const metadata = { title: "Pantry & Freezer" };
|
export const metadata = { title: "Pantry & Freezer" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function PantryPage() {
|
export default async function PantryPage() {
|
||||||
|
const ctx = await sessionContext();
|
||||||
const [items, locations, plants] = await Promise.all([
|
const [items, locations, plants] = await Promise.all([
|
||||||
db.item.findMany({
|
listPantry(ctx),
|
||||||
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 } } } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
db.location.findMany({
|
db.location.findMany({
|
||||||
where: {
|
where: {
|
||||||
active: true,
|
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 } }),
|
db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const serialized = items.map(i => ({
|
return <PantryClient initialItems={items} locations={locations} plants={plants} />;
|
||||||
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} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
async function handleDelete() {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
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();
|
if (res.ok) onRefresh();
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
@@ -207,7 +207,7 @@ export function PantryClient({ initialItems, locations, plants }: {
|
|||||||
const [items, setItems] = useState(initialItems);
|
const [items, setItems] = useState(initialItems);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const res = await fetch("/api/pantry");
|
const res = await fetch("/api/v1/pantry");
|
||||||
if (res.ok) setItems(await res.json());
|
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)
|
// Load catalog on open (for name autocomplete suggestions)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
fetch("/api/pantry/catalog")
|
fetch("/api/v1/pantry/catalog")
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(setCatalog)
|
.then(setCatalog)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -160,7 +160,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
if (val.length < 2) { setNameSuggestions([]); setShowDropdown(false); return; }
|
if (val.length < 2) { setNameSuggestions([]); setShowDropdown(false); return; }
|
||||||
debounceRef.current = setTimeout(async () => {
|
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;
|
if (!res.ok) return;
|
||||||
const data: CatalogData = await res.json();
|
const data: CatalogData = await res.json();
|
||||||
setNameSuggestions(data.suggestions.slice(0, 6));
|
setNameSuggestions(data.suggestions.slice(0, 6));
|
||||||
@@ -232,7 +232,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
kind: "CONSUMABLE",
|
kind: "CONSUMABLE",
|
||||||
tags,
|
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",
|
method: item ? "PATCH" : "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/pantry/bulk-use", {
|
const res = await fetch("/api/v1/pantry/bulk-use", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -80,7 +80,21 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
|||||||
notes: reason.trim() || null,
|
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);
|
setOpen(false);
|
||||||
reset();
|
reset();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/pantry/${item.id}/use`, {
|
const res = await fetch(`/api/v1/pantry/${item.id}/use`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null }),
|
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 { 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();
|
const session = await getSession();
|
||||||
if (!session) throw new ApiError(401, "Unauthorized");
|
if (!session) throw new ApiError(401, "Unauthorized");
|
||||||
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };
|
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 itemUpdateInput = itemCreateInput.partial();
|
||||||
|
|
||||||
export const itemLogInput = z.object({
|
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(),
|
notes: z.string().optional(),
|
||||||
cost: z.coerce.number().nonnegative().nullable().optional(),
|
cost: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
|
amount: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
date: z.string().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 PlantLogInput = z.infer<typeof plantLogInput>;
|
||||||
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
|
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
|
||||||
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
|
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.22.0",
|
||||||
date: "2026-07-04",
|
date: "2026-07-04",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
|
|||||||
PURCHASED: "Purchased",
|
PURCHASED: "Purchased",
|
||||||
REPAIRED: "Repaired",
|
REPAIRED: "Repaired",
|
||||||
DISPOSED: "Disposed",
|
DISPOSED: "Disposed",
|
||||||
|
USED: "Used",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
|
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",
|
PURCHASED: "text-green-600 dark:text-green-400",
|
||||||
REPAIRED: "text-violet-600 dark:text-violet-400",
|
REPAIRED: "text-violet-600 dark:text-violet-400",
|
||||||
DISPOSED: "text-red-600 dark:text-red-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[] = [
|
export const ITEM_LOG_TYPES: ItemLogType[] = [
|
||||||
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
|
"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(),
|
date: input.date ? new Date(input.date) : new Date(),
|
||||||
notes: input.notes?.trim() || null,
|
notes: input.notes?.trim() || null,
|
||||||
cost: input.cost ?? null,
|
cost: input.cost ?? null,
|
||||||
|
amount: input.amount ?? null,
|
||||||
actorId: ctx.userId,
|
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`.
|
// 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