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>
107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
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 });
|
|
}
|
|
}
|