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>
79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
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 });
|
|
}
|
|
}
|