// 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 { let cur: string | null = candidateParentId; const seen = new Set(); 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; }