v0.1.0 — Moon Base initial scaffold: auth, garden plant registry + log

This commit is contained in:
Bonna Moon
2026-06-15 16:14:48 -05:00
commit 99918fffbc
47 changed files with 2764 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
-- Moon Base v0.1 — initial schema
-- Creates auth, audit log, and the garden plant registry.
-- Enums
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'MEMBER');
CREATE TYPE "PlantCategory" AS ENUM (
'CANOPY_TREE', 'UNDERSTORY_TREE', 'LARGE_SHRUB', 'SHRUB',
'HERB', 'GROUNDCOVER', 'VINE', 'ANNUAL', 'PERENNIAL',
'BULB', 'MUSHROOM', 'OTHER'
);
CREATE TYPE "PlantLogType" AS ENUM (
'OBSERVATION', 'CARE', 'HARVEST', 'TREATMENT', 'TRANSPLANT', 'DIED'
);
-- User
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"username" TEXT,
"name" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'MEMBER',
"active" BOOLEAN NOT NULL DEFAULT true,
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- AuditEvent
CREATE TABLE "AuditEvent" (
"id" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entityId" TEXT,
"actorId" TEXT,
"payload" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AuditEvent_entityId_idx" ON "AuditEvent"("entityId");
CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt");
-- Plant
CREATE TABLE "Plant" (
"id" TEXT NOT NULL,
"commonName" TEXT NOT NULL,
"species" TEXT,
"variety" TEXT,
"category" "PlantCategory" NOT NULL,
"zone" TEXT,
"plantedAt" TIMESTAMP(3),
"source" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Plant_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Plant_category_idx" ON "Plant"("category");
CREATE INDEX "Plant_zone_idx" ON "Plant"("zone");
CREATE INDEX "Plant_active_idx" ON "Plant"("active");
-- PlantLog
CREATE TABLE "PlantLog" (
"id" TEXT NOT NULL,
"plantId" TEXT NOT NULL,
"type" "PlantLogType" NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notes" TEXT,
"quantity" TEXT,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlantLog_pkey" PRIMARY KEY ("id"),
CONSTRAINT "PlantLog_plantId_fkey" FOREIGN KEY ("plantId") REFERENCES "Plant"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE INDEX "PlantLog_plantId_idx" ON "PlantLog"("plantId");
CREATE INDEX "PlantLog_date_idx" ON "PlantLog"("date");
-- Provision admin accounts (bonna + tony)
-- Passwords are bcrypt hashes of temporary values — change on first login.
-- bonna: moonbase! | tony: moonbase!
INSERT INTO "User" ("id", "email", "username", "name", "passwordHash", "role", "active", "mustChangePassword", "createdAt", "updatedAt")
VALUES
('cluser0000000bonna', 'bonna@moonbase.local', 'bonna', 'Bonna Moon',
'$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'ADMIN', true, true,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cluser0000000tony0', 'tony@moonbase.local', 'tony', 'Tony Moon',
'$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'ADMIN', true, true,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("username") DO UPDATE SET "updatedAt" = CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

113
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,113 @@
// 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
}
// ---------------------------------------------------------------------------
// 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])
}
// ---------------------------------------------------------------------------
// 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 // tomatoes, squash, beans — seasonal
PERENNIAL // asparagus, rhubarb, perennial veg
BULB // garlic, onions, tulips
MUSHROOM // shiitake log, oyster bed, etc.
OTHER
}
model Plant {
id String @id @default(cuid())
commonName String
species String? // scientific name (optional)
variety String? // cultivar / variety name
category PlantCategory
zone String? // location on the property, freeform ("Back fence guild", "Front yard")
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
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs PlantLog[]
@@index([category])
@@index([zone])
@@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
}
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])
}

75
prisma/seed.ts Normal file
View File

@@ -0,0 +1,75 @@
import { PrismaClient, PlantCategory } from "@prisma/client";
import { hashSync } from "bcryptjs";
const db = new PrismaClient();
async function main() {
console.log("Seeding Moon Base...");
// Admin accounts — only created if they don't exist yet.
const password = hashSync("moonbase!", 10);
await db.user.upsert({
where: { username: "bonna" },
update: {},
create: {
id: "cluser0000000bonna",
email: "bonna@moonbase.local",
username: "bonna",
name: "Bonna Moon",
passwordHash: password,
role: "ADMIN",
mustChangePassword: true,
},
});
await db.user.upsert({
where: { username: "tony" },
update: {},
create: {
id: "cluser0000000tony0",
email: "tony@moonbase.local",
username: "tony",
name: "Tony Moon",
passwordHash: password,
role: "ADMIN",
mustChangePassword: true,
},
});
if (process.env.SEED_MODE === "minimal") {
console.log("Minimal seed complete (admin accounts only).");
return;
}
// Sample plants for dev — a small starter food forest
const plants = [
{ commonName: "Apple", variety: "Honeycrisp", category: PlantCategory.CANOPY_TREE, zone: "Back yard, north fence", source: "Jung's Nursery", plantedAt: new Date("2024-04-15") },
{ commonName: "Apple", variety: "Liberty", category: PlantCategory.CANOPY_TREE, zone: "Back yard, north fence", source: "Jung's Nursery", plantedAt: new Date("2024-04-15") },
{ commonName: "Pear", variety: "Bartlett", category: PlantCategory.CANOPY_TREE, zone: "Back yard, east side", source: "Jung's Nursery", plantedAt: new Date("2024-04-20") },
{ commonName: "Elderberry", variety: "Bob Gordon", category: PlantCategory.UNDERSTORY_TREE, zone: "Back fence, south", source: "Raintree Nursery", plantedAt: new Date("2024-05-01") },
{ commonName: "Serviceberry", species: "Amelanchier canadensis", category: PlantCategory.UNDERSTORY_TREE, zone: "Front yard", source: "local nursery", plantedAt: new Date("2023-04-10") },
{ commonName: "Hazelnut", variety: "American", category: PlantCategory.LARGE_SHRUB, zone: "Back yard, west fence", source: "Oikos Tree Crops", plantedAt: new Date("2024-04-15") },
{ commonName: "Comfrey", species: "Symphytum officinale", category: PlantCategory.HERB, zone: "Under apple trees", source: "from seed", plantedAt: new Date("2024-05-15"), notes: "Chop-and-drop around the apples. Cut before it flowers to prevent spreading." },
{ commonName: "Raspberry", variety: "Heritage", category: PlantCategory.SHRUB, zone: "Back fence, east", source: "division", plantedAt: new Date("2023-05-01") },
{ commonName: "Strawberry", variety: "Ozark Beauty", category: PlantCategory.GROUNDCOVER, zone: "Under pear tree", source: "Jung's Nursery", plantedAt: new Date("2024-05-10") },
{ commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") },
];
for (const p of plants) {
await db.plant.upsert({
where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` },
update: {},
create: {
id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`,
...p,
},
});
}
console.log("Demo seed complete.");
}
main()
.catch(console.error)
.finally(() => db.$disconnect());