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

@@ -100,6 +100,7 @@ model Location {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
items Item[]
@@index([parentId])
@@index([kind])
@@ -236,6 +237,8 @@ model Task {
category TaskCategory @default(GARDEN)
plantId String?
plant Plant? @relation(fields: [plantId], references: [id])
itemId String?
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days
month Int? // 112: for YEARLY, which month
@@ -248,6 +251,7 @@ model Task {
@@index([nextDue])
@@index([plantId])
@@index([itemId])
@@index([active])
}
@@ -276,3 +280,118 @@ model PlantLog {
@@index([plantId])
@@index([date])
}
// ---------------------------------------------------------------------------
// Inventory — things you own. Durable items (tools, gear) and, later,
// consumables (food, supplies) share this one table, told apart by `kind`.
// Items nest: an Item lives in a Location OR inside another Item (toolbox →
// drill). Consumable-only fields (barcode, bestBefore, minStock) are unused
// by the durable UI but present so Pantry needs no schema change.
// ---------------------------------------------------------------------------
enum ItemKind {
DURABLE // tools, gear, equipment — "where is it?"
CONSUMABLE // food, feed, supplies — "do I have it?" (Pantry phase)
}
model Item {
id String @id @default(cuid())
name String
description String?
kind ItemKind @default(DURABLE)
quantity Decimal @default(1) @db.Decimal(10, 2)
unit String? // "each", "lbs", "bottles"
// Where it lives — a place, or inside another item (containers are items too).
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
parentItemId String?
parentItem Item? @relation("ItemNesting", fields: [parentItemId], references: [id], onDelete: SetNull)
containedItems Item[] @relation("ItemNesting")
// Durable attributes
value Decimal? @db.Decimal(10, 2)
purchaseDate DateTime?
warrantyExpiry DateTime?
serial String?
modelNumber String?
brand String?
// Consumable attributes (Pantry phase)
barcode String?
bestBefore DateTime?
minStock Decimal? @db.Decimal(10, 2)
// Shared
qrSlug String? @unique // short slug for a printed QR label
imageUrl String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
labels LabelOnItem[]
attachments ItemAttachment[]
logs ItemLog[]
tasks Task[]
@@index([locationId])
@@index([parentItemId])
@@index([kind])
@@index([active])
@@index([name])
@@index([barcode])
}
model Label {
id String @id @default(cuid())
name String @unique
color String?
items LabelOnItem[]
}
model LabelOnItem {
itemId String
labelId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
@@id([itemId, labelId])
@@index([labelId])
}
model ItemAttachment {
id String @id @default(cuid())
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
kind String // "PHOTO" | "MANUAL" | "RECEIPT"
url String
name String?
createdAt DateTime @default(now())
@@index([itemId])
}
enum ItemLogType {
NOTE
MAINTENANCE
MOVED
PURCHASED
REPAIRED
DISPOSED
}
model ItemLog {
id String @id @default(cuid())
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
type ItemLogType
date DateTime @default(now())
notes String?
cost Decimal? @db.Decimal(10, 2)
actorId String?
createdAt DateTime @default(now())
@@index([itemId])
@@index([date])
}