diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 59c4876..1f48567 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -14,4 +14,7 @@ echo "Running Prisma migrations..." echo "Running Location-spine backfill (idempotent)..." ./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs." +echo "Running plant-type backfill (idempotent)..." +./node_modules/.bin/tsx prisma/scripts/backfill-plant-types.ts || echo " Backfill skipped/failed (non-fatal); check logs." + exec "$@" diff --git a/package.json b/package.json index 59577b3..7b54d08 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "db:migrate:deploy": "prisma migrate deploy", "db:seed": "tsx prisma/seed.ts", "db:backfill-locations": "tsx prisma/scripts/backfill-locations.ts", + "db:backfill-plant-types": "tsx prisma/scripts/backfill-plant-types.ts", "db:studio": "prisma studio", "db:reset": "prisma migrate reset --force", "db:backup": "sh scripts/db-backup.sh", diff --git a/prisma/migrations/20260616000001_v0_11_plant_types/migration.sql b/prisma/migrations/20260616000001_v0_11_plant_types/migration.sql new file mode 100644 index 0000000..d83df37 --- /dev/null +++ b/prisma/migrations/20260616000001_v0_11_plant_types/migration.sql @@ -0,0 +1,42 @@ +-- v0.11.0 — Plant types (the "kind" record) +-- A rich-but-all-optional reference record per kind of plant, plus an optional +-- link from each planting. Seeded/linked by prisma/scripts/backfill-plant-types.ts. + +-- CreateTable +CREATE TABLE "PlantType" ( + "id" TEXT NOT NULL, + "commonName" TEXT NOT NULL, + "species" TEXT, + "category" "PlantCategory" NOT NULL DEFAULT 'OTHER', + "careNotes" TEXT, + "sun" TEXT, + "water" TEXT, + "bloomStart" INTEGER, + "bloomEnd" INTEGER, + "harvestStart" INTEGER, + "harvestEnd" INTEGER, + "toxicToPets" BOOLEAN, + "edibleParts" TEXT, + "spacing" TEXT, + "imageUrl" TEXT, + "notes" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlantType_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PlantType_commonName_idx" ON "PlantType"("commonName"); +CREATE INDEX "PlantType_category_idx" ON "PlantType"("category"); +CREATE INDEX "PlantType_active_idx" ON "PlantType"("active"); + +-- AlterTable: optional link from a planting to its kind +ALTER TABLE "Plant" ADD COLUMN "plantTypeId" TEXT; + +-- CreateIndex +CREATE INDEX "Plant_plantTypeId_idx" ON "Plant"("plantTypeId"); + +-- AddForeignKey +ALTER TABLE "Plant" ADD CONSTRAINT "Plant_plantTypeId_fkey" FOREIGN KEY ("plantTypeId") REFERENCES "PlantType"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d889439..19354a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -126,6 +126,8 @@ model Plant { 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 @@ -141,10 +143,42 @@ model Plant { @@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 diff --git a/prisma/scripts/backfill-plant-types.ts b/prisma/scripts/backfill-plant-types.ts new file mode 100644 index 0000000..56b8253 --- /dev/null +++ b/prisma/scripts/backfill-plant-types.ts @@ -0,0 +1,23 @@ +/** + * v0.11.0 Plant-type backfill. Seeds the PlantType library from the built-in + * plant DB and links existing plantings to their kind. Idempotent — runs on + * deploy (docker-entrypoint.sh) and can be run manually: + * + * npx tsx prisma/scripts/backfill-plant-types.ts + */ +import { PrismaClient } from "@prisma/client"; +import { syncPlantTypesFromLibrary } from "../../src/lib/garden/plant-types"; + +const db = new PrismaClient(); + +async function main() { + const r = await syncPlantTypesFromLibrary(db); + console.log(`Plant types: library has ${r.types} kinds; linked ${r.linked} planting(s).`); +} + +main() + .catch((e) => { + console.error("Plant-type backfill failed:", e); + process.exit(1); + }) + .finally(() => db.$disconnect()); diff --git a/prisma/seed.ts b/prisma/seed.ts index 6007262..a753fe9 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,6 @@ import { PrismaClient, PlantCategory } from "@prisma/client"; import { hashSync } from "bcryptjs"; +import { syncPlantTypesFromLibrary } from "../src/lib/garden/plant-types"; const db = new PrismaClient(); @@ -87,6 +88,10 @@ async function main() { }); } + // Seed the PlantType library and link the sample plants to their kind. + const pt = await syncPlantTypesFromLibrary(db); + console.log(`Seeded ${pt.types} plant types; linked ${pt.linked} plantings.`); + console.log("Demo seed complete."); } diff --git a/src/app/(dashboard)/garden/[id]/page.tsx b/src/app/(dashboard)/garden/[id]/page.tsx index 3e51593..cf7cec1 100644 --- a/src/app/(dashboard)/garden/[id]/page.tsx +++ b/src/app/(dashboard)/garden/[id]/page.tsx @@ -3,7 +3,7 @@ import { db } from "@/lib/db"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { MapPin, CalendarDays, ChevronLeft, Sprout, Package } from "lucide-react"; +import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen } from "lucide-react"; import { formatDate } from "@/lib/utils"; import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories"; import { AddLogButton } from "@/components/garden/add-log-button"; @@ -19,7 +19,11 @@ export default async function PlantDetailPage({ params }: { params: { id: string const [plant, locations, groups] = await Promise.all([ db.plant.findUnique({ where: { id: params.id }, - include: { logs: { orderBy: { date: "desc" } }, location: { select: { id: true, name: true } } }, + include: { + logs: { orderBy: { date: "desc" } }, + location: { select: { id: true, name: true } }, + plantType: { select: { id: true, commonName: true } }, + }, }), db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }), db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }), @@ -66,6 +70,14 @@ export default async function PlantDetailPage({ params }: { params: { id: string {placeName} )} + {plant.plantType && ( +
+ + + More about {plant.plantType.commonName} + +
+ )} {plant.plantedAt && (
diff --git a/src/app/(dashboard)/garden/types/[id]/page.tsx b/src/app/(dashboard)/garden/types/[id]/page.tsx new file mode 100644 index 0000000..a538e57 --- /dev/null +++ b/src/app/(dashboard)/garden/types/[id]/page.tsx @@ -0,0 +1,163 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { db } from "@/lib/db"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf, +} from "lucide-react"; +import { CATEGORY_LABELS } from "@/lib/garden/categories"; +import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog"; +import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button"; + +const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function monthRange(start: number | null, end: number | null): string | null { + if (start && end) return `${MONTHS[start]}–${MONTHS[end]}`; + if (start) return `from ${MONTHS[start]}`; + if (end) return `through ${MONTHS[end]}`; + return null; +} + +export async function generateMetadata({ params }: { params: { id: string } }) { + const t = await db.plantType.findUnique({ where: { id: params.id }, select: { commonName: true } }); + return { title: t?.commonName ?? "Plant type" }; +} + +export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) { + const type = await db.plantType.findUnique({ + where: { id: params.id }, + include: { + plants: { + where: { active: true }, + orderBy: { commonName: "asc" }, + include: { location: { select: { name: true } } }, + }, + }, + }); + + if (!type || !type.active) notFound(); + + const bloom = monthRange(type.bloomStart, type.bloomEnd); + const harvest = monthRange(type.harvestStart, type.harvestEnd); + + const formFields: PlantTypeFields = { + id: type.id, + commonName: type.commonName, + species: type.species, + category: type.category, + careNotes: type.careNotes, + sun: type.sun, + water: type.water, + bloomStart: type.bloomStart, + bloomEnd: type.bloomEnd, + harvestStart: type.harvestStart, + harvestEnd: type.harvestEnd, + toxicToPets: type.toxicToPets, + edibleParts: type.edibleParts, + spacing: type.spacing, + notes: type.notes, + }; + + const facts: { icon: React.ReactNode; label: string; value: string }[] = []; + if (type.sun) facts.push({ icon: , label: "Sun", value: type.sun }); + if (type.water) facts.push({ icon: , label: "Water", value: type.water }); + if (bloom) facts.push({ icon: , label: "Blooms", value: bloom }); + if (harvest) facts.push({ icon: , label: "Harvest", value: harvest }); + if (type.edibleParts) facts.push({ icon: , label: "Edible", value: type.edibleParts }); + if (type.spacing) facts.push({ icon: , label: "Spacing", value: type.spacing }); + + return ( +
+
+ + + +
+

{type.commonName}

+ {type.species &&

{type.species}

} +
+ {CATEGORY_LABELS[type.category]} +
+ +
+ + +
+ + {type.toxicToPets && ( +
+ + Toxic to pets — keep away from animals. +
+ )} + {type.toxicToPets === false && ( +
+ Safe for pets. +
+ )} + + {facts.length > 0 && ( + + + {facts.map((fct, i) => ( +
+ {fct.icon} +
+

{fct.label}

+

{fct.value}

+
+
+ ))} +
+
+ )} + + {(type.careNotes || type.notes) && ( + + + {type.careNotes && ( +
+

Care

+

{type.careNotes}

+
+ )} + {type.notes && ( +
+

{type.notes}

+
+ )} +
+
+ )} + +
+

+ Your plantings {type.plants.length > 0 && · {type.plants.length}} +

+ {type.plants.length === 0 ? ( +
+ None planted yet — this is general info you can keep regardless. +
+ ) : ( +
+ {type.plants.map((p) => ( + + + + {p.commonName}{p.variety && · {p.variety}} + + {p.location?.name && ( + + {p.location.name} + + )} + + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/garden/types/page.tsx b/src/app/(dashboard)/garden/types/page.tsx new file mode 100644 index 0000000..d96459b --- /dev/null +++ b/src/app/(dashboard)/garden/types/page.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; +import { db } from "@/lib/db"; +import { Badge } from "@/components/ui/badge"; +import { BookOpen, ChevronRight, Leaf, AlertTriangle } from "lucide-react"; +import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories"; +import { AddPlantTypeButton } from "@/components/garden/plant-type-dialog"; + +export const metadata = { title: "Plant types" }; + +export default async function PlantTypesPage() { + const types = await db.plantType.findMany({ + where: { active: true }, + orderBy: { commonName: "asc" }, + include: { _count: { select: { plants: { where: { active: true } } } } }, + }); + + const byCategory = CATEGORY_ORDER.map((cat) => ({ + category: cat, + types: types.filter((t) => t.category === cat), + })).filter((g) => g.types.length > 0); + + return ( +
+
+
+

Plant types

+

+ General knowledge about each kind of plant — care, sun, bloom & harvest, toxicity. + {" "}{types.length} {types.length === 1 ? "kind" : "kinds"} in your library. +

+
+ +
+ + {types.length === 0 ? ( +
+ +

No plant types yet

+

Add one, or they'll appear here as you add plants.

+
+ ) : ( +
+ {byCategory.map((group) => ( +
+

+ {CATEGORY_LABELS[group.category]} +

+
+ {group.types.map((t) => ( + + +
+

{t.commonName}

+ {t.species &&

{t.species}

} +
+ {t.toxicToPets && ( + + Toxic + + )} + {t._count.plants > 0 && ( + + {t._count.plants} planted + + )} + + + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts index 17094db..24b4188 100644 --- a/src/app/api/admin/backup/route.ts +++ b/src/app/api/admin/backup/route.ts @@ -10,9 +10,10 @@ export async function GET() { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const [locations, users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] = + const [locations, plantTypes, users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] = await Promise.all([ db.location.findMany(), + db.plantType.findMany(), db.user.findMany(), db.plantGroup.findMany(), db.plant.findMany(), @@ -25,12 +26,13 @@ export async function GET() { const manifest = { version: 1, exportedAt: new Date().toISOString(), - tables: ["locations", "users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"], + tables: ["locations", "plantTypes", "users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"], }; const zip = zipSync({ "manifest.json": strToU8(JSON.stringify(manifest, null, 2)), "locations.json": strToU8(JSON.stringify(locations, null, 2)), + "plantTypes.json": strToU8(JSON.stringify(plantTypes, null, 2)), "users.json": strToU8(JSON.stringify(users, null, 2)), "plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)), "plants.json": strToU8(JSON.stringify(plants, null, 2)), diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts index 09bc27c..1568a4a 100644 --- a/src/app/api/admin/restore/route.ts +++ b/src/app/api/admin/restore/route.ts @@ -27,8 +27,9 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 }); } - // Older backups (pre-v0.10) won't have locations.json — tolerate its absence. + // Older backups won't have newer tables — tolerate their absence. const locations = files["locations.json"] ? parse("locations.json") : []; + const plantTypes = files["plantTypes.json"] ? parse("plantTypes.json") : []; const users = parse("users.json"); const plantGroups = parse("plantGroups.json"); const plants = parse("plants.json"); @@ -44,6 +45,7 @@ export async function POST(req: Request) { await tx.task.deleteMany(); await tx.plant.deleteMany(); await tx.plantGroup.deleteMany(); + await tx.plantType.deleteMany(); await tx.location.deleteMany(); await tx.auditEvent.deleteMany(); await tx.user.deleteMany(); @@ -52,6 +54,7 @@ export async function POST(req: Request) { // Location self-references parentId; a single createMany is one statement, // so the FK is validated only after every row exists — order-independent. if (locations.length) await tx.location.createMany({ data: locations }); + if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes }); if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups }); if (plants.length) await tx.plant.createMany({ data: plants }); if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs }); diff --git a/src/app/api/plant-types/[id]/route.ts b/src/app/api/plant-types/[id]/route.ts new file mode 100644 index 0000000..78a6eb2 --- /dev/null +++ b/src/app/api/plant-types/[id]/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { PlantCategory, Prisma } from "@prisma/client"; +import { db } from "@/lib/db"; +import { requireAuth, isAdmin } from "@/lib/auth"; + +const month = z.coerce.number().int().min(1).max(12).nullable(); + +// All optional — a PATCH only touches the fields it sends. Empty string clears +// a text field; null clears a numeric/boolean one. +const updateSchema = z.object({ + commonName: z.string().min(1).optional(), + species: z.string().optional(), + category: z.nativeEnum(PlantCategory).optional(), + careNotes: z.string().optional(), + sun: z.string().optional(), + water: z.string().optional(), + bloomStart: month.optional(), + bloomEnd: month.optional(), + harvestStart: month.optional(), + harvestEnd: month.optional(), + toxicToPets: z.boolean().nullable().optional(), + edibleParts: z.string().optional(), + spacing: z.string().optional(), + imageUrl: z.string().optional(), + notes: z.string().optional(), +}); + +const TEXT_FIELDS = [ + "species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes", +] as const; + +export async function PATCH(req: Request, { params }: { params: { id: string } }) { + try { + const session = await requireAuth(); + const existing = await db.plantType.findUnique({ where: { id: params.id } }); + if (!existing || !existing.active) + return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = await req.json(); + const data = updateSchema.parse(body); + + const update: Prisma.PlantTypeUpdateInput = {}; + if (data.commonName !== undefined) update.commonName = data.commonName.trim(); + if (data.category !== undefined) update.category = data.category; + if (data.toxicToPets !== undefined) update.toxicToPets = data.toxicToPets; + for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) { + if (data[k] !== undefined) update[k] = data[k]; + } + for (const k of TEXT_FIELDS) { + if (data[k] !== undefined) update[k] = data[k]?.trim() || null; + } + + const updated = await db.plantType.update({ where: { id: params.id }, data: update }); + + await db.auditEvent.create({ + data: { + action: "plant_type.update", + entityId: params.id, + actorId: session.user.id, + payload: { commonName: updated.commonName }, + }, + }); + + return NextResponse.json(updated); + } catch (err) { + if (err instanceof z.ZodError) + return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 }); + if (err instanceof Error && err.message === "Unauthorized") + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} + +export async function DELETE(_req: Request, { params }: { params: { id: string } }) { + try { + const session = await requireAuth(); + if (!isAdmin(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const existing = await db.plantType.findUnique({ where: { id: params.id } }); + if (!existing || !existing.active) + return NextResponse.json({ error: "Not found" }, { status: 404 }); + + // Soft-delete; plantings keep their plantTypeId (the row just goes inactive). + await db.plantType.update({ where: { id: params.id }, data: { active: false } }); + await db.auditEvent.create({ + data: { + action: "plant_type.delete", + entityId: params.id, + actorId: session.user.id, + payload: { commonName: existing.commonName }, + }, + }); + + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof Error && err.message === "Unauthorized") + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/plant-types/route.ts b/src/app/api/plant-types/route.ts new file mode 100644 index 0000000..00caad2 --- /dev/null +++ b/src/app/api/plant-types/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { PlantCategory } from "@prisma/client"; +import { db } from "@/lib/db"; +import { requireAuth } from "@/lib/auth"; + +const month = z.coerce.number().int().min(1).max(12); + +const createSchema = z.object({ + commonName: z.string().min(1), + species: z.string().optional(), + category: z.nativeEnum(PlantCategory).optional(), + careNotes: z.string().optional(), + sun: z.string().optional(), + water: z.string().optional(), + bloomStart: month.optional(), + bloomEnd: month.optional(), + harvestStart: month.optional(), + harvestEnd: month.optional(), + toxicToPets: z.boolean().optional(), + edibleParts: z.string().optional(), + spacing: z.string().optional(), + imageUrl: z.string().optional(), + notes: z.string().optional(), +}); + +// GET /api/plant-types — list active kinds with a planting count. +export async function GET() { + try { + await requireAuth(); + const types = await db.plantType.findMany({ + where: { active: true }, + orderBy: { commonName: "asc" }, + include: { _count: { select: { plants: { where: { active: true } } } } }, + }); + return NextResponse.json(types); + } catch (err) { + if (err instanceof Error && err.message === "Unauthorized") + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} + +// POST /api/plant-types — create a kind. +export async function POST(req: Request) { + try { + const session = await requireAuth(); + const data = createSchema.parse(await req.json()); + const commonName = data.commonName.trim(); + if (!commonName) return NextResponse.json({ error: "Name is required" }, { status: 400 }); + + const created = await db.plantType.create({ + data: { + commonName, + species: data.species?.trim() || null, + category: data.category ?? PlantCategory.OTHER, + careNotes: data.careNotes?.trim() || null, + sun: data.sun?.trim() || null, + water: data.water?.trim() || null, + bloomStart: data.bloomStart ?? null, + bloomEnd: data.bloomEnd ?? null, + harvestStart: data.harvestStart ?? null, + harvestEnd: data.harvestEnd ?? null, + toxicToPets: data.toxicToPets ?? null, + edibleParts: data.edibleParts?.trim() || null, + spacing: data.spacing?.trim() || null, + imageUrl: data.imageUrl?.trim() || null, + notes: data.notes?.trim() || null, + }, + }); + + await db.auditEvent.create({ + data: { + action: "plant_type.create", + entityId: created.id, + actorId: session.user.id, + payload: { commonName: created.commonName, category: created.category }, + }, + }); + + return NextResponse.json(created, { status: 201 }); + } catch (err) { + if (err instanceof z.ZodError) + return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 }); + if (err instanceof Error && err.message === "Unauthorized") + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/plants/route.ts b/src/app/api/plants/route.ts index 6c29212..8caa3a7 100644 --- a/src/app/api/plants/route.ts +++ b/src/app/api/plants/route.ts @@ -28,6 +28,22 @@ async function resolveZone(locationId?: string, zone?: string): Promise { + if (species) { + const bySpecies = await db.plantType.findFirst({ + where: { active: true, species: { equals: species, mode: "insensitive" } }, + select: { id: true }, + }); + if (bySpecies) return bySpecies.id; + } + const byCommon = await db.plantType.findFirst({ + where: { active: true, commonName: { equals: commonName, mode: "insensitive" } }, + select: { id: true }, + }); + return byCommon?.id ?? null; +} + export async function POST(req: Request) { try { const session = await requireAuth(); @@ -38,6 +54,7 @@ export async function POST(req: Request) { const commonName = data.commonName.trim(); const imageUrl = await fetchPlantImageUrl(species, commonName); const zone = await resolveZone(data.locationId, data.zone); + const plantTypeId = await resolvePlantTypeId(species, commonName); const plant = await db.plant.create({ data: { @@ -47,6 +64,7 @@ export async function POST(req: Request) { category: data.category as PlantCategory, zone, // dual-write: kept in sync with location.name until Release B locationId: data.locationId || null, + plantTypeId, plantedAt: data.plantedAt ? new Date(data.plantedAt) : null, source: data.source?.trim() || null, notes: data.notes?.trim() || null, diff --git a/src/components/garden/delete-plant-type-button.tsx b/src/components/garden/delete-plant-type-button.tsx new file mode 100644 index 0000000..78b01b3 --- /dev/null +++ b/src/components/garden/delete-plant-type-button.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Trash2 } from "lucide-react"; + +export function DeletePlantTypeButton({ id, name }: { id: string; name: string }) { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + async function handleDelete() { + setBusy(true); + const res = await fetch(`/api/plant-types/${id}`, { method: "DELETE" }); + setBusy(false); + if (!res.ok) { + toast({ title: "Error", description: "Could not delete.", variant: "destructive" }); + return; + } + toast({ title: "Deleted", description: `"${name}" removed from your plant types.` }); + setOpen(false); + router.push("/garden/types"); + router.refresh(); + } + + return ( + <> + + + + + Delete “{name}”? + + This removes the plant-type reference. Your individual plantings stay put — + they just won't be linked to a type anymore. + + + + + + + + + + ); +} diff --git a/src/components/garden/plant-type-dialog.tsx b/src/components/garden/plant-type-dialog.tsx new file mode 100644 index 0000000..f60dfe8 --- /dev/null +++ b/src/components/garden/plant-type-dialog.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { PlantCategory } from "@prisma/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { Plus, Pencil } from "lucide-react"; +import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories"; + +export type PlantTypeFields = { + id?: string; + commonName: string; + species: string | null; + category: PlantCategory; + careNotes: string | null; + sun: string | null; + water: string | null; + bloomStart: number | null; + bloomEnd: number | null; + harvestStart: number | null; + harvestEnd: number | null; + toxicToPets: boolean | null; + edibleParts: string | null; + spacing: string | null; + notes: string | null; +}; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const NONE = "__none__"; + +function blank(): PlantTypeFields { + return { + commonName: "", species: null, category: "OTHER", careNotes: null, + sun: null, water: null, bloomStart: null, bloomEnd: null, + harvestStart: null, harvestEnd: null, toxicToPets: null, + edibleParts: null, spacing: null, notes: null, + }; +} + +function MonthSelect({ value, onChange, placeholder }: { + value: number | null; onChange: (v: number | null) => void; placeholder: string; +}) { + return ( + + ); +} + +function PlantTypeDialog({ + trigger, title, initial, onSaved, +}: { + trigger: React.ReactNode; + title: string; + initial: PlantTypeFields; + onSaved: () => void; +}) { + const [open, setOpen] = useState(false); + const [f, setF] = useState(initial); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + const { toast } = useToast(); + + function set(key: K, value: PlantTypeFields[K]) { + setF((p) => ({ ...p, [key]: value })); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!f.commonName.trim()) { setError("Please enter the plant name."); return; } + setError(""); + setBusy(true); + + const payload: Record = { + commonName: f.commonName.trim(), + species: f.species ?? "", + category: f.category, + careNotes: f.careNotes ?? "", + sun: f.sun ?? "", + water: f.water ?? "", + edibleParts: f.edibleParts ?? "", + spacing: f.spacing ?? "", + notes: f.notes ?? "", + toxicToPets: f.toxicToPets, + }; + // Month fields: only send when set (empty would fail the 1–12 check). + for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) { + if (f[k] != null) payload[k] = f[k]; + } + + const res = await fetch(f.id ? `/api/plant-types/${f.id}` : "/api/plant-types", { + method: f.id ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + setBusy(false); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" }); + return; + } + toast({ title: f.id ? "Saved!" : "Plant type added!" }); + setOpen(false); + onSaved(); + router.refresh(); + } + + return ( + <> + { setF(initial); setOpen(true); }}>{trigger} + + + {title} +
+
+
+ + set("commonName", e.target.value)} + placeholder="Lily of the valley" /> + {error &&

{error}

} +
+ +
+ + set("species", e.target.value)} + placeholder="Convallaria majalis" /> +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+ set("bloomStart", v)} placeholder="From" /> + to + set("bloomEnd", v)} placeholder="To" /> +
+
+
+ +
+ set("harvestStart", v)} placeholder="From" /> + to + set("harvestEnd", v)} placeholder="To" /> +
+
+ +
+ + +
+
+ + set("spacing", e.target.value)} placeholder="10–15 ft" /> +
+ +
+ + set("edibleParts", e.target.value)} + placeholder="Fruit, young leaves" /> +
+ +
+ +