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:
2026-06-15 23:46:07 -05:00
parent 9c0312676a
commit cdb21ed58a
20 changed files with 984 additions and 8 deletions

View File

@@ -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)),

View File

@@ -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 });

View 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 });
}
}

View 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 });
}
}

View File

@@ -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,