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>
This commit is contained in:
2026-06-16 00:12:03 -05:00
parent 0904790dca
commit b0118d3fec
25 changed files with 1613 additions and 5 deletions

View File

@@ -22,6 +22,40 @@ export const taskCreateInput = z.object({
export const taskCompleteInput = z.object({ notes: z.string().optional() });
// --- Inventory ---
export const itemCreateInput = z.object({
name: z.string().min(1),
description: z.string().optional(),
kind: z.enum(["DURABLE", "CONSUMABLE"]).default("DURABLE"),
quantity: z.coerce.number().nonnegative().optional(),
unit: z.string().optional(),
locationId: z.string().nullable().optional(),
parentItemId: z.string().nullable().optional(),
value: z.coerce.number().nonnegative().nullable().optional(),
purchaseDate: z.string().nullable().optional(),
warrantyExpiry: z.string().nullable().optional(),
serial: z.string().optional(),
modelNumber: z.string().optional(),
brand: z.string().optional(),
barcode: z.string().optional(),
notes: z.string().optional(),
imageUrl: z.string().nullable().optional(),
labelIds: z.array(z.string()).optional(),
});
export const itemUpdateInput = itemCreateInput.partial();
export const itemLogInput = z.object({
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]),
notes: z.string().optional(),
cost: z.coerce.number().nonnegative().nullable().optional(),
date: z.string().optional(),
});
export type PlantLogInput = z.infer<typeof plantLogInput>;
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
export type ItemCreateInput = z.infer<typeof itemCreateInput>;
export type ItemUpdateInput = z.infer<typeof itemUpdateInput>;
export type ItemLogInput = z.infer<typeof itemLogInput>;

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.13.0",
date: "2026-06-16",
changes: [
"New Inventory section — keep track of the things you own: tools, gear, equipment. Record where each one lives, its value, brand, serial number, and warranty (you'll get a heads-up when one's expiring). Items can hold other items, so a toolbox shows the tools inside it. Add photos, labels to group things, and a maintenance/repair history per item.",
],
},
{
version: "0.12.0",
date: "2026-06-15",

View File

@@ -0,0 +1,26 @@
import { ItemLogType } from "@prisma/client";
export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
NOTE: "Note",
MAINTENANCE: "Maintenance",
MOVED: "Moved",
PURCHASED: "Purchased",
REPAIRED: "Repaired",
DISPOSED: "Disposed",
};
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
NOTE: "text-slate-600 dark:text-slate-400",
MAINTENANCE: "text-amber-600 dark:text-amber-400",
MOVED: "text-blue-600 dark:text-blue-400",
PURCHASED: "text-green-600 dark:text-green-400",
REPAIRED: "text-violet-600 dark:text-violet-400",
DISPOSED: "text-red-600 dark:text-red-400",
};
export const ITEM_LOG_TYPES: ItemLogType[] = [
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
];
export const ATTACHMENT_KINDS = ["PHOTO", "MANUAL", "RECEIPT"] as const;
export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number];

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { warrantyStatus, totalValue } from "./value";
const now = new Date("2026-06-16T00:00:00Z");
describe("warrantyStatus", () => {
it("returns none when there's no expiry", () => {
expect(warrantyStatus(null, now)).toBe("none");
expect(warrantyStatus(undefined, now)).toBe("none");
});
it("flags expired warranties", () => {
expect(warrantyStatus(new Date("2026-06-15T00:00:00Z"), now)).toBe("expired");
});
it("flags warranties expiring within the window", () => {
expect(warrantyStatus(new Date("2026-07-01T00:00:00Z"), now)).toBe("expiring"); // ~15 days
});
it("treats a comfortably future expiry as active", () => {
expect(warrantyStatus(new Date("2027-01-01T00:00:00Z"), now)).toBe("active");
});
it("honors a custom soon window", () => {
expect(warrantyStatus(new Date("2026-08-01T00:00:00Z"), now, 90)).toBe("expiring");
expect(warrantyStatus(new Date("2026-08-01T00:00:00Z"), now, 7)).toBe("active");
});
});
describe("totalValue", () => {
it("sums value × quantity", () => {
expect(totalValue([{ value: 2, quantity: 5 }, { value: 100, quantity: 1 }])).toBe(110);
});
it("defaults quantity to 1 and missing value to 0", () => {
expect(totalValue([{ value: 50 }, { quantity: 3 }, {}])).toBe(50);
});
it("accepts Decimal-style strings", () => {
expect(totalValue([{ value: "19.99", quantity: "2" }])).toBeCloseTo(39.98);
});
it("is zero for an empty inventory", () => {
expect(totalValue([])).toBe(0);
});
});

View File

@@ -0,0 +1,39 @@
// Pure inventory helpers — warranty status + total value. Decimal columns come
// back as strings/Decimal from Prisma, so callers pass plain numbers here.
export type WarrantyStatus = "none" | "active" | "expiring" | "expired";
/**
* Where a warranty stands relative to `now`. "expiring" = within `soonDays`.
*/
export function warrantyStatus(
warrantyExpiry: Date | string | null | undefined,
now: Date,
soonDays = 30,
): WarrantyStatus {
if (!warrantyExpiry) return "none";
const exp = new Date(warrantyExpiry).getTime();
const t = now.getTime();
if (Number.isNaN(exp)) return "none";
if (exp < t) return "expired";
if (exp - t <= soonDays * 86_400_000) return "expiring";
return "active";
}
export const WARRANTY_LABELS: Record<WarrantyStatus, string> = {
none: "",
active: "Under warranty",
expiring: "Warranty expiring soon",
expired: "Warranty expired",
};
type Valued = { value?: number | string | null; quantity?: number | string | null };
/** Sum of value × quantity across items (missing value counts as 0). */
export function totalValue(items: Valued[]): number {
return items.reduce((sum, i) => {
const v = i.value != null ? Number(i.value) : 0;
const q = i.quantity != null ? Number(i.quantity) : 1;
return sum + (Number.isFinite(v) ? v * (Number.isFinite(q) ? q : 1) : 0);
}, 0);
}

173
src/lib/services/items.ts Normal file
View File

@@ -0,0 +1,173 @@
// 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;
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.12.0";
export const APP_VERSION = "0.13.0";