// 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? 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? // 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?" } 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]) }