v0.10.0 — Location spine: garden moved onto a shared place tree
Completes the Location feature (schema + migration landed earlier under 98cabc5).
- Hierarchical Location model is the shared spine; unlimited nesting
- Garden add/edit now picks a reusable Location (inline create) instead of
freeform text; garden groups By Location off the real tree
- Dual-write keeps legacy Plant.zone in sync with location.name (Release A)
- /api/locations CRUD + /api/locations/[id]/log (logs a location + its sub-locations)
- Location tree pure-core (buildTree/computePath/descendantIds) with tests
- Location added to backup/restore export
- Bump APP_VERSION 0.10.0 + plain-English changelog entry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,18 +16,18 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
}
|
||||
|
||||
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
|
||||
const [plant, allPlants, groups] = await Promise.all([
|
||||
const [plant, locations, groups] = await Promise.all([
|
||||
db.plant.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { logs: { orderBy: { date: "desc" } } },
|
||||
include: { logs: { orderBy: { date: "desc" } }, location: { select: { id: true, name: true } } },
|
||||
}),
|
||||
db.plant.findMany({ where: { active: true }, select: { zone: 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" } }),
|
||||
]);
|
||||
|
||||
if (!plant || !plant.active) notFound();
|
||||
|
||||
const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
||||
const placeName = plant.location?.name ?? plant.zone ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
@@ -48,7 +48,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EditPlantButton plant={plant} zones={zones} groups={groups} />
|
||||
<EditPlantButton plant={plant} locations={locations} groups={groups} />
|
||||
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
|
||||
</div>
|
||||
|
||||
@@ -60,10 +60,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-2 text-sm">
|
||||
{plant.zone && (
|
||||
{placeName && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="h-4 w-4 shrink-0" />
|
||||
<span>{plant.zone}</span>
|
||||
<span>{placeName}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantedAt && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GardenGrid } from "@/components/garden/garden-grid";
|
||||
export const metadata = { title: "Garden" };
|
||||
|
||||
export default async function GardenPage() {
|
||||
const [plants, groups] = await Promise.all([
|
||||
const [plants, groups, locations] = await Promise.all([
|
||||
db.plant.findMany({
|
||||
where: { active: true },
|
||||
orderBy: [{ commonName: "asc" }],
|
||||
@@ -13,6 +13,7 @@ export default async function GardenPage() {
|
||||
_count: { select: { logs: true } },
|
||||
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
|
||||
group: { select: { id: true, name: true } },
|
||||
location: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
db.plantGroup.findMany({
|
||||
@@ -20,10 +21,13 @@ export default async function GardenPage() {
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { plants: true } } },
|
||||
}),
|
||||
db.location.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -33,7 +37,7 @@ export default async function GardenPage() {
|
||||
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
|
||||
</p>
|
||||
</div>
|
||||
<AddPlantButton groups={groups} zones={zones} />
|
||||
<AddPlantButton groups={groups} locations={locations} />
|
||||
</div>
|
||||
|
||||
<GardenGrid plants={plants} groups={groups} />
|
||||
|
||||
@@ -10,8 +10,9 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
|
||||
const [locations, users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
|
||||
await Promise.all([
|
||||
db.location.findMany(),
|
||||
db.user.findMany(),
|
||||
db.plantGroup.findMany(),
|
||||
db.plant.findMany(),
|
||||
@@ -24,11 +25,12 @@ export async function GET() {
|
||||
const manifest = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
|
||||
tables: ["locations", "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)),
|
||||
"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,6 +27,8 @@ 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.
|
||||
const locations = files["locations.json"] ? parse("locations.json") : [];
|
||||
const users = parse("users.json");
|
||||
const plantGroups = parse("plantGroups.json");
|
||||
const plants = parse("plants.json");
|
||||
@@ -35,17 +37,21 @@ export async function POST(req: Request) {
|
||||
const taskCompletions = parse("taskCompletions.json");
|
||||
const auditEvents = parse("auditEvents.json");
|
||||
|
||||
// Restore inside a transaction — wipe then reload in FK order
|
||||
// Restore inside a transaction — wipe then reload in FK order.
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.taskCompletion.deleteMany();
|
||||
await tx.plantLog.deleteMany();
|
||||
await tx.task.deleteMany();
|
||||
await tx.plant.deleteMany();
|
||||
await tx.plantGroup.deleteMany();
|
||||
await tx.location.deleteMany();
|
||||
await tx.auditEvent.deleteMany();
|
||||
await tx.user.deleteMany();
|
||||
|
||||
if (users.length) await tx.user.createMany({ data: users });
|
||||
// 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 (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 });
|
||||
|
||||
71
src/app/api/locations/[id]/log/route.ts
Normal file
71
src/app/api/locations/[id]/log/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { descendantIds } from "@/lib/locations/tree";
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||
notes: z.string().optional(),
|
||||
quantity: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
});
|
||||
|
||||
// POST /api/locations/[id]/log — append a log to every active plant in this
|
||||
// location and its sub-locations (e.g. "watered the whole back yard").
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
|
||||
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||
if (!location || !location.active)
|
||||
return NextResponse.json({ error: "Location not found" }, { status: 404 });
|
||||
|
||||
// This location + everything nested under it.
|
||||
const allRows = await db.location.findMany({ select: { id: true, parentId: true, name: true } });
|
||||
const locationIds = descendantIds(params.id, allRows, true);
|
||||
|
||||
const plants = await db.plant.findMany({
|
||||
where: { locationId: { in: locationIds }, active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (plants.length === 0)
|
||||
return NextResponse.json({ error: "No plants found in this location" }, { status: 404 });
|
||||
|
||||
const data = schema.parse(await req.json());
|
||||
const date = data.date ? new Date(data.date) : new Date();
|
||||
|
||||
const logs = await db.$transaction(
|
||||
plants.map((p) =>
|
||||
db.plantLog.create({
|
||||
data: {
|
||||
plantId: p.id,
|
||||
type: data.type,
|
||||
date,
|
||||
notes: data.notes?.trim() || null,
|
||||
quantity: data.quantity?.trim() || null,
|
||||
actorId: session.user.id,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "location.log",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { location: location.name, type: data.type, plantCount: logs.length },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ count: logs.length }, { status: 201 });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError)
|
||||
return NextResponse.json({ error: err.issues[0]?.message }, { 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 });
|
||||
}
|
||||
}
|
||||
106
src/app/api/locations/[id]/route.ts
Normal file
106
src/app/api/locations/[id]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { LocationKind } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||
import { recomputeAllPaths, wouldCreateCycle } from "@/lib/locations/db";
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
kind: z.nativeEnum(LocationKind).optional(),
|
||||
parentId: z.string().nullable().optional(), // null → move to top level
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||
if (!location || !location.active)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const data = updateSchema.parse(await req.json());
|
||||
|
||||
// Guard against cycles when re-parenting.
|
||||
if (data.parentId) {
|
||||
if (await wouldCreateCycle(params.id, data.parentId))
|
||||
return NextResponse.json(
|
||||
{ error: "A location can't be moved under itself or its own descendant" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const parent = await db.location.findUnique({ where: { id: data.parentId } });
|
||||
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await db.$transaction(async (tx) => {
|
||||
const u = await tx.location.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name.trim() } : {}),
|
||||
...(data.kind !== undefined ? { kind: data.kind } : {}),
|
||||
...(data.parentId !== undefined ? { parentId: data.parentId } : {}),
|
||||
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
|
||||
},
|
||||
});
|
||||
await recomputeAllPaths(tx as unknown as typeof db);
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
action: "location.update",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: u.name },
|
||||
},
|
||||
});
|
||||
return u;
|
||||
});
|
||||
|
||||
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 location = await db.location.findUnique({ where: { id: params.id } });
|
||||
if (!location || !location.active)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Don't orphan live records — require the location be emptied first.
|
||||
const [plantCount, childCount] = await Promise.all([
|
||||
db.plant.count({ where: { locationId: params.id, active: true } }),
|
||||
db.location.count({ where: { parentId: params.id, active: true } }),
|
||||
]);
|
||||
if (plantCount > 0 || childCount > 0)
|
||||
return NextResponse.json(
|
||||
{ error: "Move the plants and sub-locations out of this location first" },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
await db.location.update({ where: { id: params.id }, data: { active: false } });
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "location.delete",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: location.name },
|
||||
},
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
78
src/app/api/locations/route.ts
Normal file
78
src/app/api/locations/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { LocationKind } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { recomputeAllPaths } from "@/lib/locations/db";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
kind: z.nativeEnum(LocationKind).optional(),
|
||||
parentId: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /api/locations — flat list of active locations (client builds the tree).
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const locations = await db.location.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { plants: { where: { active: true } } } } },
|
||||
});
|
||||
return NextResponse.json(locations);
|
||||
} 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/locations — create a location node.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const data = createSchema.parse(await req.json());
|
||||
|
||||
const name = data.name.trim();
|
||||
if (!name) return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
|
||||
// Validate parent exists (if given).
|
||||
if (data.parentId) {
|
||||
const parent = await db.location.findUnique({ where: { id: data.parentId } });
|
||||
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
|
||||
}
|
||||
|
||||
const location = await db.$transaction(async (tx) => {
|
||||
const created = await tx.location.create({
|
||||
data: {
|
||||
name,
|
||||
kind: data.kind ?? LocationKind.AREA,
|
||||
parentId: data.parentId || null,
|
||||
notes: data.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
await recomputeAllPaths(tx as unknown as typeof db);
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
action: "location.create",
|
||||
entityId: created.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: created.name, kind: created.kind, parentId: created.parentId },
|
||||
},
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
return NextResponse.json(location, { 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 });
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,23 @@ const updateSchema = z.object({
|
||||
variety: z.string().optional(),
|
||||
category: z.string().min(1),
|
||||
zone: z.string().optional(),
|
||||
locationId: z.string().nullable().optional(),
|
||||
plantedAt: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
groupId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// Dual-write helper (Release A): keep the legacy `zone` string in sync with the
|
||||
// chosen location's name until Release B drops the column.
|
||||
async function resolveZone(locationId?: string | null, zone?: string): Promise<string | null> {
|
||||
if (locationId) {
|
||||
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
|
||||
if (loc) return loc.name;
|
||||
}
|
||||
return zone?.trim() || null;
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
@@ -35,6 +46,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
||||
? await fetchPlantImageUrl(species, commonName)
|
||||
: undefined;
|
||||
|
||||
const zone = await resolveZone(data.locationId, data.zone);
|
||||
|
||||
const updated = await db.plant.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
@@ -42,7 +55,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
||||
species,
|
||||
variety: data.variety?.trim() || null,
|
||||
category: data.category as PlantCategory,
|
||||
zone: data.zone?.trim() || null,
|
||||
zone, // dual-write: kept in sync with location.name until Release B
|
||||
locationId: data.locationId ?? null,
|
||||
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
||||
source: data.source?.trim() || null,
|
||||
notes: data.notes?.trim() || null,
|
||||
|
||||
@@ -10,12 +10,24 @@ const createSchema = z.object({
|
||||
variety: z.string().optional(),
|
||||
category: z.string().min(1),
|
||||
zone: z.string().optional(),
|
||||
locationId: z.string().optional(),
|
||||
plantedAt: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
groupId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Dual-write helper (Release A): resolve a locationId to its name so the legacy
|
||||
// `zone` column stays populated until Release B drops it. Returns the zone
|
||||
// string to stamp, falling back to the freeform zone when no location is given.
|
||||
async function resolveZone(locationId?: string, zone?: string): Promise<string | null> {
|
||||
if (locationId) {
|
||||
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
|
||||
if (loc) return loc.name;
|
||||
}
|
||||
return zone?.trim() || null;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
@@ -25,6 +37,7 @@ export async function POST(req: Request) {
|
||||
const species = data.species?.trim() || null;
|
||||
const commonName = data.commonName.trim();
|
||||
const imageUrl = await fetchPlantImageUrl(species, commonName);
|
||||
const zone = await resolveZone(data.locationId, data.zone);
|
||||
|
||||
const plant = await db.plant.create({
|
||||
data: {
|
||||
@@ -32,7 +45,8 @@ export async function POST(req: Request) {
|
||||
species,
|
||||
variety: data.variety?.trim() || null,
|
||||
category: data.category as PlantCategory,
|
||||
zone: data.zone?.trim() || null,
|
||||
zone, // dual-write: kept in sync with location.name until Release B
|
||||
locationId: data.locationId || null,
|
||||
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