v0.11.0 — Plant types: the reusable "kind" record
A rich-but-all-optional reference record per kind of plant (care, sun, water, bloom & harvest windows, toxic-to-pets, edible parts, spacing), distinct from a specific planting or a group. - PlantType model + optional Plant.plantTypeId; migration + idempotent backfill that seeds 247 kinds from the built-in plant library and links plantings by species/common name (wired into the deploy entrypoint) - /api/plant-types CRUD; new "Plant types" section (browse by category, edit a kind, see every spot you've planted it) - New plants auto-link to their type on create; plant detail shows "More about…" - PlantType added to backup/restore; bump APP_VERSION 0.11.0 + changelog Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
<span>{placeName}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantType && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<Link href={`/garden/types/${plant.plantType.id}`} className="hover:text-foreground underline-offset-2 hover:underline">
|
||||
More about {plant.plantType.commonName}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantedAt && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CalendarDays className="h-4 w-4 shrink-0" />
|
||||
|
||||
163
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal file
163
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal file
@@ -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: <Sun className="h-4 w-4" />, label: "Sun", value: type.sun });
|
||||
if (type.water) facts.push({ icon: <Droplets className="h-4 w-4" />, label: "Water", value: type.water });
|
||||
if (bloom) facts.push({ icon: <Flower2 className="h-4 w-4" />, label: "Blooms", value: bloom });
|
||||
if (harvest) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Harvest", value: harvest });
|
||||
if (type.edibleParts) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Edible", value: type.edibleParts });
|
||||
if (type.spacing) facts.push({ icon: <Ruler className="h-4 w-4" />, label: "Spacing", value: type.spacing });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/garden/types" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-semibold">{type.commonName}</h1>
|
||||
{type.species && <p className="text-sm text-muted-foreground italic">{type.species}</p>}
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-auto shrink-0">{CATEGORY_LABELS[type.category]}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EditPlantTypeButton plantType={formFields} />
|
||||
<DeletePlantTypeButton id={type.id} name={type.commonName} />
|
||||
</div>
|
||||
|
||||
{type.toxicToPets && (
|
||||
<div className="flex items-center gap-2 text-sm rounded-lg border border-[hsl(var(--ember)/.4)] bg-[hsl(var(--ember)/.06)] px-3 py-2 text-[hsl(var(--ember))]">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Toxic to pets — keep away from animals.
|
||||
</div>
|
||||
)}
|
||||
{type.toxicToPets === false && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="text-[hsl(var(--leaf))]">✓</span> Safe for pets.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||
{facts.map((fct, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">{fct.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{fct.label}</p>
|
||||
<p className="truncate">{fct.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(type.careNotes || type.notes) && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-3 text-sm">
|
||||
{type.careNotes && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Care</p>
|
||||
<p className="whitespace-pre-wrap">{type.careNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
{type.notes && (
|
||||
<div className={type.careNotes ? "pt-2 border-t" : ""}>
|
||||
<p className="whitespace-pre-wrap">{type.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold mb-3">
|
||||
Your plantings {type.plants.length > 0 && <span className="text-sm text-muted-foreground font-normal">· {type.plants.length}</span>}
|
||||
</h2>
|
||||
{type.plants.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
|
||||
None planted yet — this is general info you can keep regardless.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{type.plants.map((p) => (
|
||||
<Link key={p.id} href={`/garden/${p.id}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent transition-colors text-sm">
|
||||
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<span className="font-medium">
|
||||
{p.commonName}{p.variety && <span className="text-muted-foreground font-normal"> · {p.variety}</span>}
|
||||
</span>
|
||||
{p.location?.name && (
|
||||
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />{p.location.name}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/app/(dashboard)/garden/types/page.tsx
Normal file
77
src/app/(dashboard)/garden/types/page.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Plant types</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
General knowledge about each kind of plant — care, sun, bloom & harvest, toxicity.
|
||||
{" "}{types.length} {types.length === 1 ? "kind" : "kinds"} in your library.
|
||||
</p>
|
||||
</div>
|
||||
<AddPlantTypeButton />
|
||||
</div>
|
||||
|
||||
{types.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">No plant types yet</p>
|
||||
<p className="text-sm mt-1">Add one, or they'll appear here as you add plants.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{byCategory.map((group) => (
|
||||
<div key={group.category} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{CATEGORY_LABELS[group.category]}
|
||||
</p>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{group.types.map((t) => (
|
||||
<Link key={t.id} href={`/garden/types/${t.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
|
||||
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{t.commonName}</p>
|
||||
{t.species && <p className="text-xs text-muted-foreground italic truncate">{t.species}</p>}
|
||||
</div>
|
||||
{t.toxicToPets && (
|
||||
<Badge variant="outline" className="gap-1 text-xs text-[hsl(var(--ember))] border-[hsl(var(--ember)/.4)]">
|
||||
<AlertTriangle className="h-3 w-3" /> Toxic
|
||||
</Badge>
|
||||
)}
|
||||
{t._count.plants > 0 && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t._count.plants} planted
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
104
src/app/api/plant-types/[id]/route.ts
Normal file
104
src/app/api/plant-types/[id]/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
91
src/app/api/plant-types/route.ts
Normal file
91
src/app/api/plant-types/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,22 @@ async function resolveZone(locationId?: string, zone?: string): Promise<string |
|
||||
return zone?.trim() || null;
|
||||
}
|
||||
|
||||
// Link a new planting to its "kind" record — by species first, else common name.
|
||||
async function resolvePlantTypeId(species: string | null, commonName: string): Promise<string | null> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user