Files
Moonbase/src/lib/services/items.ts
tonym b0118d3fec v0.13.0 — Inventory (HomeBox): durable items that nest
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>
2026-06-16 00:12:03 -05:00

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