Files
Moonbase/prisma/schema.prisma
Bonna Moon a50f7eaa49 v0.28.0 — Fix null toxicToPets on type create; Root vegetable category; sweet potato knowledge
The plant-type CREATE schema rejected toxicToPets: null (the dialog's
'Unknown'), failing every manually-entered type with 'expected boolean,
received null' — now nullable like the PATCH schema always was. New
ROOT_VEGETABLE PlantCategory (enum migration + labels/order). Garden
brain gains Sweet potato (slips in June, dig before frost, warm cure),
and knowledgeFor/tipsForMonth now prefer the most specific name match so
'Japanese sweet potato' gets sweet-potato advice instead of Potato's
nightshade rotation and hilling (regression-tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:27:51 -05:00

501 lines
17 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[]
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
ROOT_VEGETABLE // carrots, beets, sweet potatoes, radishes
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 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?
uwExtensionUrl String? // linked UWMadison 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? // 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([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])
}