Harvest logs now carry a structured amount+unit (legacy freeform quantity kept for old rows) and can simultaneously create a GARDEN-source pantry item via addPlantLog's new pantry input. PlantTypes can link a UW–Madison Extension article, searched through /api/v1/plant-types/uw-search (proxy to hort.extension.wisc.edu's WordPress REST API); the link shows on the type page and each plant. The garden log dialog now posts to /api/v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
500 lines
17 KiB
Plaintext
500 lines
17 KiB
Plaintext
// 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[]
|
||
tasks Task[]
|
||
|
||
@@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[]
|
||
pantryItems Item[]
|
||
|
||
@@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 1–12
|
||
bloomEnd Int? // month 1–12
|
||
harvestStart Int? // month 1–12
|
||
harvestEnd Int? // month 1–12
|
||
toxicToPets Boolean? // null = unknown
|
||
edibleParts String? // "Fruit, young leaves"
|
||
spacing String? // "10–15 ft", "18 in"
|
||
imageUrl String?
|
||
uwExtensionUrl String? // linked UW–Madison Extension article (hort.extension.wisc.edu)
|
||
uwExtensionTitle String? // its title, shown as the link text
|
||
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)
|
||
locationId String?
|
||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||
recurrence RecurrenceType @default(YEARLY)
|
||
intervalDays Int? // for CUSTOM: every N days
|
||
month Int? // 1–12: 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([locationId])
|
||
@@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? // legacy freeform harvests ("a big handful") — new logs use amount+unit
|
||
amount Decimal? @db.Decimal(10, 2) // structured harvest quantity (queryable totals)
|
||
unit String? // "lbs", "each", "quarts" — pairs with amount
|
||
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?"
|
||
}
|
||
|
||
enum ItemSource {
|
||
GARDEN // grown/harvested here
|
||
HOMEMADE // made/preserved ourselves
|
||
STORE // grocery or big-box store
|
||
FARMERS_MARKET // local market
|
||
GIFTED // given by someone
|
||
OTHER
|
||
}
|
||
|
||
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
|
||
barcode String?
|
||
storedAt DateTime? // date frozen / preserved / stored
|
||
bestBefore DateTime? // expiry / best-by date
|
||
minStock Decimal? @db.Decimal(10, 2)
|
||
source ItemSource? // where it came from
|
||
plantId String? // link to a garden plant (harvest source)
|
||
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
|
||
pantryCategory String? // e.g. "Meat", "Produce", "Dairy"
|
||
pantrySubcategory String? // e.g. "Beef", "Poultry", "Berries"
|
||
pricePaid Decimal? @db.Decimal(10, 2) // how much was paid
|
||
priceUnit String? // "package", "lb", "oz", "kg", etc.
|
||
|
||
// 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])
|
||
@@index([plantId])
|
||
@@index([pantryCategory])
|
||
}
|
||
|
||
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
|
||
USED // consumable pulled from the pantry; `amount` holds how much
|
||
}
|
||
|
||
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)
|
||
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
|
||
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])
|
||
}
|