Files
Moonbase/prisma/schema.prisma
tonym 5f4c482636 v0.16.0 — HomeBox CSV import + sold lifecycle + custom fields
Bring an existing HomeBox inventory straight in, and capture the fields HomeBox
has that we were missing (skipping asset IDs per Tony).

- Item: soldTo/soldPrice/soldDate/soldNotes + flexible customFields (JSON, e.g.
  VIN / oil weight for vehicles); both editable in the item dialog and shown on
  the detail page
- Pure HomeBox CSV parser (src/lib/inventory/homebox-import.ts, +5 tests) —
  RFC4180 quoting (commas/newlines/escaped quotes in fields), HomeBox's 0001-*
  null dates and 0 = empty price, semicolon labels, HB.field.* custom columns,
  nested 'A / B' location paths
- Import service + /api/v1/items/import: find-or-creates the nested locations
  and labels, creates items, recomputes location paths, returns a summary
- Inventory 'Import' button: paste/upload CSV, review every row with checkboxes
  (uncheck to skip), then import. Preview parses client-side (parser is pure)
- Verified end-to-end: nested locations, split labels, sold + custom fields,
  skip, quoted names, null-date handling. tsc + 38 tests + build green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:01:17 -05:00

470 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Moon Base — property management for the Moon homestead.
// Tracks the food forest (plants, care logs, harvests), home maintenance,
// and equipment. v0.1 ships the garden module; home + equipment come next.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
enum UserRole {
ADMIN // full access — add/edit/delete anything
MEMBER // can log observations and care, but not delete records
}
model User {
id String @id @default(cuid())
email String @unique
username String? @unique
name String
passwordHash String
role UserRole @default(MEMBER)
active Boolean @default(true)
mustChangePassword Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apiTokens ApiToken[]
}
// ---------------------------------------------------------------------------
// API tokens — personal access tokens for the REST API + MCP server.
// Only the SHA-256 hash of the raw token is stored; the raw value (mb_…) is
// shown once at creation. `scopes` gate what a token can do (e.g. "plants:read").
// ---------------------------------------------------------------------------
model ApiToken {
id String @id @default(cuid())
name String // human label ("Home Assistant", "phone shortcut")
tokenHash String @unique // sha256(raw)
prefix String // first 8 chars of the raw token, shown in the UI
scopes String[] // e.g. ["plants:read","items:write"]; ["*"] = full
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
// ---------------------------------------------------------------------------
// Audit log — append-only, every mutation lands here
// ---------------------------------------------------------------------------
model AuditEvent {
id String @id @default(cuid())
action String // e.g. "plant.create", "plant.log.add"
entityId String?
actorId String?
payload Json?
createdAt DateTime @default(now())
@@index([entityId])
@@index([createdAt])
}
// ---------------------------------------------------------------------------
// Location — shared hierarchical place tree (garden areas, buildings, rooms,
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
// stock all hang off a Location instead of a freeform string.
// ---------------------------------------------------------------------------
enum LocationKind {
AREA // outdoor garden zone / farm field ("Back fence guild")
BUILDING // house, garage, barn, shed
ROOM // kitchen, basement, pantry room
STORAGE // shelf, bin, cabinet, drawer, freezer
OTHER
}
model Location {
id String @id @default(cuid())
name String
kind LocationKind @default(AREA)
parentId String?
parent Location? @relation("LocationTree", fields: [parentId], references: [id], onDelete: SetNull)
children Location[] @relation("LocationTree")
path String? // denormalized "House Basement Shelf B" for breadcrumbs
notes String?
qrSlug String? @unique // short slug printed on a QR label → resolves here
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
items Item[]
animals Animal[]
@@index([parentId])
@@index([kind])
@@index([active])
}
// ---------------------------------------------------------------------------
// Garden — food forest plant registry
// ---------------------------------------------------------------------------
enum PlantCategory {
CANOPY_TREE // apple, pear, cherry, walnut — the tall ones
UNDERSTORY_TREE // serviceberry, elderberry, pawpaw
LARGE_SHRUB // hazelnuts, currants, gooseberries
SHRUB // roses, raspberries, blueberries
HERB // comfrey, yarrow, mint, chives
GROUNDCOVER // strawberries, clover, creeping thyme
VINE // grapes, hops, kiwi
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
ANNUAL // legacy, treated as annual vegetable
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
PERENNIAL // asparagus, rhubarb, perennial veg
PERENNIAL_FLOWER // coneflower, bee balm, black-eyed susan
BULB // garlic, onions, tulips, daffodils
MUSHROOM // shiitake log, oyster bed, etc.
OTHER
}
model PlantGroup {
id String @id @default(cuid())
name String @unique
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
@@index([active])
}
model Plant {
id String @id @default(cuid())
commonName String
species String? // scientific name (optional)
variety String? // cultivar / variety name
category PlantCategory
zone String? // legacy freeform location — kept during the Location migration (dropped in Release B)
locationId String? // shared Location spine
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
plantTypeId String? // optional link to the "kind" reference record
plantType PlantType? @relation(fields: [plantTypeId], references: [id], onDelete: SetNull)
plantedAt DateTime? // when it went in the ground
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
notes String? // general notes about this plant
groupId String? // optional: belongs to a PlantGroup (e.g. "Irises")
group PlantGroup? @relation(fields: [groupId], references: [id])
imageUrl String? // thumbnail fetched from Wikipedia on add/edit
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs PlantLog[]
tasks Task[]
@@index([category])
@@index([zone])
@@index([locationId])
@@index([plantTypeId])
@@index([active])
@@index([groupId])
}
// PlantType — the "kind" of plant (e.g. "Lily of the valley"), holding general
// reusable reference info. Rich but every field optional; seeded from the
// built-in plant library (src/lib/garden/plant-db.ts). Individual Plant rows
// optionally point at their type.
model PlantType {
id String @id @default(cuid())
commonName String
species String? // scientific name
category PlantCategory @default(OTHER)
careNotes String? // general care: pruning, watering, feeding
sun String? // "Full sun", "Part shade", "Shade"
water String? // "Low", "Moderate", "High"
bloomStart Int? // month 112
bloomEnd Int? // month 112
harvestStart Int? // month 112
harvestEnd Int? // month 112
toxicToPets Boolean? // null = unknown
edibleParts String? // "Fruit, young leaves"
spacing String? // "1015 ft", "18 in"
imageUrl String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
@@index([commonName])
@@index([category])
@@index([active])
}
enum PlantLogType {
OBSERVATION // general note — "looking healthy", "saw first flowers"
CARE // watering, mulching, pruning, fertilizing, chop-and-drop
HARVEST // picked fruit, veg, herb, flower
TREATMENT // pest or disease treatment
TRANSPLANT // moved to a different spot
DIED // plant died or was removed
}
// ---------------------------------------------------------------------------
// Tasks / reminders
// ---------------------------------------------------------------------------
enum TaskCategory {
GARDEN
HOME
EQUIPMENT
OTHER
}
enum RecurrenceType {
ONCE // one-time task, no repeat
CUSTOM // every N days (intervalDays)
MONTHLY // same day every month (uses nextDue day-of-month)
YEARLY // same month every year (month field)
}
model Task {
id String @id @default(cuid())
title String
notes String?
category TaskCategory @default(GARDEN)
plantId String?
plant Plant? @relation(fields: [plantId], references: [id])
itemId String?
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
animalId String?
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days
month Int? // 112: for YEARLY, which month
nextDue DateTime
lastDone DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completions TaskCompletion[]
@@index([nextDue])
@@index([plantId])
@@index([itemId])
@@index([animalId])
@@index([active])
}
model TaskCompletion {
id String @id @default(cuid())
taskId String
task Task @relation(fields: [taskId], references: [id])
doneAt DateTime @default(now())
notes String?
actorId String?
@@index([taskId])
}
model PlantLog {
id String @id @default(cuid())
plantId String
plant Plant @relation(fields: [plantId], references: [id])
type PlantLogType
date DateTime @default(now())
notes String?
quantity String? // freeform for harvests: "2 lbs", "1 gallon", "a big handful"
actorId String?
createdAt DateTime @default(now())
@@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) // purchase price
purchaseDate DateTime?
purchaseFrom String? // where it was bought
warrantyExpiry DateTime?
warrantyDetails String? // freeform warranty terms
lifetimeWarranty Boolean @default(false)
insured Boolean @default(false)
serial String?
modelNumber String?
brand String? // brand / manufacturer
// Sold lifecycle (HomeBox parity)
soldTo String?
soldPrice Decimal? @db.Decimal(10, 2)
soldDate DateTime?
soldNotes String?
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
customFields Json?
// 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])
}
// ---------------------------------------------------------------------------
// Animals & pets — livestock and household pets, leaves in a Location with
// their own care log (vet, feeding, weight, meds) and recurring reminders.
// ---------------------------------------------------------------------------
enum AnimalLogType {
NOTE
FEEDING
VET
WEIGHT
MEDICATION
BREEDING
ACQUIRED
DIED
}
model Animal {
id String @id @default(cuid())
name String
species String? // "Dog", "Cat", "Chicken", "Goat"
breed String?
sex String? // freeform: "Female", "Male", "Wether"
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
birthdate DateTime?
acquiredAt DateTime?
source String?
notes String?
imageUrl String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs AnimalLog[]
tasks Task[]
@@index([locationId])
@@index([species])
@@index([active])
}
model AnimalLog {
id String @id @default(cuid())
animalId String
animal Animal @relation(fields: [animalId], references: [id], onDelete: Cascade)
type AnimalLogType
date DateTime @default(now())
notes String?
weight Decimal? @db.Decimal(10, 2) // for WEIGHT entries
actorId String?
createdAt DateTime @default(now())
@@index([animalId])
@@index([date])
}