The first inventory vertical on the shared spine. Things you own — tools, gear, equipment — each living in a Location or inside another Item. - Item model (kind DURABLE|CONSUMABLE; consumable fields present but unused so Pantry needs no migration), self-nesting via parentItemId (toolbox holds drill, with a cycle guard), value/warranty/serial/brand/qty, qrSlug, photo - Label (m2m) + ItemAttachment (photos/manuals/receipts) + ItemLog (maintenance/repair/move/dispose) history; Task.itemId - API-first: /api/v1/items (CRUD), labels, logs, multipart attachments — the UI consumes the same scoped endpoints. items service shared with the coming MCP - Inventory pages: tree grouped by Location with items nested inside items; item detail with warranty status, photos, contents, history; add/edit/delete, log, photo upload. Nav entry. Backup/restore cover the new tables - Pure value.ts (warrantyStatus, totalValue) with tests; verified end-to-end against a running server (nest, cycle-guard 400, log 201) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
7.0 KiB
TypeScript
174 lines
7.0 KiB
TypeScript
// Inventory service — DB work + AuditEvent for items. Shared by /api/v1 routes,
|
|
// the UI (which calls those routes), and later the MCP server.
|
|
import { createId } from "@paralleldrive/cuid2";
|
|
import { Prisma } from "@prisma/client";
|
|
import { db } from "@/lib/db";
|
|
import { isAdmin } from "@/lib/auth";
|
|
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
|
|
import type { ItemCreateInput, ItemUpdateInput, ItemLogInput } from "@/lib/api/schemas";
|
|
|
|
const listInclude = {
|
|
location: { select: { id: true, name: true } },
|
|
parentItem: { select: { id: true, name: true } },
|
|
labels: { include: { label: true } },
|
|
_count: { select: { containedItems: { where: { active: true } }, logs: true } },
|
|
} satisfies Prisma.ItemInclude;
|
|
|
|
function toDate(s?: string | null): Date | null {
|
|
return s ? new Date(s) : null;
|
|
}
|
|
|
|
export function listItems(
|
|
_ctx: AuthContext,
|
|
opts?: { q?: string; locationId?: string; kind?: "DURABLE" | "CONSUMABLE" },
|
|
) {
|
|
return db.item.findMany({
|
|
where: {
|
|
active: true,
|
|
...(opts?.q ? { name: { contains: opts.q, mode: "insensitive" } } : {}),
|
|
...(opts?.locationId ? { locationId: opts.locationId } : {}),
|
|
...(opts?.kind ? { kind: opts.kind } : {}),
|
|
},
|
|
orderBy: { name: "asc" },
|
|
include: listInclude,
|
|
});
|
|
}
|
|
|
|
export async function getItem(_ctx: AuthContext, id: string) {
|
|
const item = await db.item.findUnique({
|
|
where: { id },
|
|
include: {
|
|
...listInclude,
|
|
attachments: { orderBy: { createdAt: "asc" } },
|
|
logs: { orderBy: { date: "desc" }, take: 50 },
|
|
containedItems: {
|
|
where: { active: true },
|
|
orderBy: { name: "asc" },
|
|
include: { _count: { select: { containedItems: { where: { active: true } } } } },
|
|
},
|
|
},
|
|
});
|
|
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
|
return item;
|
|
}
|
|
|
|
// Walk up from a candidate parent; if we reach the item itself it's a cycle.
|
|
async function wouldCycle(itemId: string, candidateParentId: string): Promise<boolean> {
|
|
let cur: string | null = candidateParentId;
|
|
const seen = new Set<string>();
|
|
while (cur) {
|
|
if (cur === itemId) return true;
|
|
if (seen.has(cur)) break;
|
|
seen.add(cur);
|
|
const parent: { parentItemId: string | null } | null = await db.item.findUnique({
|
|
where: { id: cur },
|
|
select: { parentItemId: true },
|
|
});
|
|
cur = parent?.parentItemId ?? null;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
|
|
const { labelIds, ...r } = input;
|
|
const item = await db.item.create({
|
|
data: {
|
|
name: r.name.trim(),
|
|
description: r.description?.trim() || null,
|
|
kind: r.kind ?? "DURABLE",
|
|
quantity: r.quantity ?? 1,
|
|
unit: r.unit?.trim() || null,
|
|
locationId: r.locationId || null,
|
|
parentItemId: r.parentItemId || null,
|
|
value: r.value ?? null,
|
|
purchaseDate: toDate(r.purchaseDate),
|
|
warrantyExpiry: toDate(r.warrantyExpiry),
|
|
serial: r.serial?.trim() || null,
|
|
modelNumber: r.modelNumber?.trim() || null,
|
|
brand: r.brand?.trim() || null,
|
|
barcode: r.barcode?.trim() || null,
|
|
notes: r.notes?.trim() || null,
|
|
imageUrl: r.imageUrl || null,
|
|
qrSlug: createId().slice(0, 10),
|
|
...(labelIds?.length ? { labels: { create: labelIds.map((labelId) => ({ labelId })) } } : {}),
|
|
},
|
|
include: listInclude,
|
|
});
|
|
await db.auditEvent.create({
|
|
data: { action: "item.create", entityId: item.id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
|
|
});
|
|
return item;
|
|
}
|
|
|
|
export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdateInput) {
|
|
const existing = await db.item.findUnique({ where: { id }, select: { active: true } });
|
|
if (!existing || !existing.active) throw new ApiError(404, "Item not found");
|
|
|
|
if (input.parentItemId) {
|
|
if (input.parentItemId === id) throw new ApiError(400, "An item can't contain itself");
|
|
if (await wouldCycle(id, input.parentItemId))
|
|
throw new ApiError(400, "That would put the item inside one of its own contents");
|
|
}
|
|
|
|
const { labelIds, ...r } = input;
|
|
const data: Prisma.ItemUpdateInput = {};
|
|
if (r.name !== undefined) data.name = r.name.trim();
|
|
if (r.description !== undefined) data.description = r.description?.trim() || null;
|
|
if (r.kind !== undefined) data.kind = r.kind;
|
|
if (r.quantity !== undefined) data.quantity = r.quantity;
|
|
if (r.unit !== undefined) data.unit = r.unit?.trim() || null;
|
|
if (r.locationId !== undefined) data.location = r.locationId ? { connect: { id: r.locationId } } : { disconnect: true };
|
|
if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true };
|
|
if (r.value !== undefined) data.value = r.value;
|
|
if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate);
|
|
if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry);
|
|
if (r.serial !== undefined) data.serial = r.serial?.trim() || null;
|
|
if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
|
|
if (r.brand !== undefined) data.brand = r.brand?.trim() || null;
|
|
if (r.barcode !== undefined) data.barcode = r.barcode?.trim() || null;
|
|
if (r.notes !== undefined) data.notes = r.notes?.trim() || null;
|
|
if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null;
|
|
|
|
const item = await db.$transaction(async (tx) => {
|
|
if (labelIds) {
|
|
await tx.labelOnItem.deleteMany({ where: { itemId: id } });
|
|
if (labelIds.length) await tx.labelOnItem.createMany({ data: labelIds.map((labelId) => ({ itemId: id, labelId })) });
|
|
}
|
|
return tx.item.update({ where: { id }, data, include: listInclude });
|
|
});
|
|
|
|
await db.auditEvent.create({
|
|
data: { action: "item.update", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
|
|
});
|
|
return item;
|
|
}
|
|
|
|
export async function deleteItem(ctx: AuthContext, id: string) {
|
|
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
|
|
const item = await db.item.findUnique({ where: { id }, select: { active: true, name: true } });
|
|
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
|
await db.item.update({ where: { id }, data: { active: false } });
|
|
await db.auditEvent.create({
|
|
data: { action: "item.delete", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
|
|
});
|
|
}
|
|
|
|
export async function addItemLog(ctx: AuthContext, itemId: string, input: ItemLogInput) {
|
|
const item = await db.item.findUnique({ where: { id: itemId }, select: { active: true } });
|
|
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
|
const log = await db.itemLog.create({
|
|
data: {
|
|
itemId,
|
|
type: input.type,
|
|
date: input.date ? new Date(input.date) : new Date(),
|
|
notes: input.notes?.trim() || null,
|
|
cost: input.cost ?? null,
|
|
actorId: ctx.userId,
|
|
},
|
|
});
|
|
await db.auditEvent.create({
|
|
data: { action: "item.log.add", entityId: itemId, actorId: ctx.userId, payload: { type: input.type, via: ctx.via } },
|
|
});
|
|
return log;
|
|
}
|