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:
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
26
src/lib/inventory/item-fields.ts
Normal file
26
src/lib/inventory/item-fields.ts
Normal 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];
|
||||
46
src/lib/inventory/value.test.ts
Normal file
46
src/lib/inventory/value.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
src/lib/inventory/value.ts
Normal file
39
src/lib/inventory/value.ts
Normal 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
173
src/lib/services/items.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user