Livestock and household pets as leaves on the Location tree, mirroring Garden. - Animal model (species/breed/sex/birthdate/location, soft-delete) + AnimalLog (feeding, vet, weight, medication, breeding, acquired, died); Task.animalId for recurring care reminders - API-first /api/v1/animals (CRUD) + logs, animals:read/write scopes; animals service shared with the coming MCP - Animals pages: list grouped by species with auto-computed age, detail with facts + latest weight + care log; add/edit/remove + log entry. Nav entry. Backup/restore cover the new tables - Pure age.ts (newborn/days/months/years) with tests; verified end-to-end against a running server (create, vet + weigh-in logs, read-back) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
6.7 KiB
TypeScript
155 lines
6.7 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { unzipSync, strFromU8 } from "fflate";
|
|
import { z } from "zod";
|
|
import { UserRole } from "@prisma/client";
|
|
import { db } from "@/lib/db";
|
|
import { requireAuth, isAdmin } from "@/lib/auth";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
// A malformed/crafted backup must not be able to inject arbitrary auth rows.
|
|
// users is the security boundary, so it's validated strictly (known fields only,
|
|
// role constrained to the enum). Other tables are app-owned data — we only check
|
|
// they're arrays; Prisma rejects any unknown columns on insert.
|
|
class BadBackup extends Error {}
|
|
|
|
const userSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
email: z.string(),
|
|
username: z.string().nullable().optional(),
|
|
name: z.string(),
|
|
passwordHash: z.string(),
|
|
role: z.nativeEnum(UserRole).optional(),
|
|
active: z.boolean().optional(),
|
|
mustChangePassword: z.boolean().optional(),
|
|
createdAt: z.string().optional(),
|
|
updatedAt: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export async function POST(req: Request) {
|
|
let session;
|
|
try {
|
|
session = await requireAuth();
|
|
} catch {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
if (!isAdmin(session.user.role)) {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
const form = await req.formData();
|
|
const file = form.get("file") as File | null;
|
|
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
|
|
|
let files: Record<string, Uint8Array>;
|
|
try {
|
|
files = unzipSync(new Uint8Array(await file.arrayBuffer()));
|
|
} catch {
|
|
throw new BadBackup("not a valid zip");
|
|
}
|
|
|
|
const parse = (name: string): unknown => {
|
|
if (!files[name]) throw new BadBackup(`missing ${name}`);
|
|
try {
|
|
return JSON.parse(strFromU8(files[name]));
|
|
} catch {
|
|
throw new BadBackup(`invalid JSON in ${name}`);
|
|
}
|
|
};
|
|
const asArray = (name: string): unknown[] => {
|
|
const v = parse(name);
|
|
if (!Array.isArray(v)) throw new BadBackup(`${name} is not an array`);
|
|
return v;
|
|
};
|
|
|
|
const manifest = parse("manifest.json") as { version?: number; exportedAt?: string };
|
|
if (manifest?.version !== 1) {
|
|
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
|
}
|
|
|
|
// Older backups won't have newer tables — tolerate their absence.
|
|
const locations = files["locations.json"] ? asArray("locations.json") : [];
|
|
const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : [];
|
|
const users = z.array(userSchema).parse(asArray("users.json"));
|
|
const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : [];
|
|
const plantGroups = asArray("plantGroups.json");
|
|
const plants = asArray("plants.json");
|
|
const plantLogs = asArray("plantLogs.json");
|
|
const tasks = asArray("tasks.json");
|
|
const taskCompletions = asArray("taskCompletions.json");
|
|
const labels = files["labels.json"] ? asArray("labels.json") : [];
|
|
const items = files["items.json"] ? asArray("items.json") : [];
|
|
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
|
|
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
|
|
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
|
|
const animals = files["animals.json"] ? asArray("animals.json") : [];
|
|
const animalLogs = files["animalLogs.json"] ? asArray("animalLogs.json") : [];
|
|
const auditEvents = asArray("auditEvents.json");
|
|
|
|
// 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.itemLog.deleteMany();
|
|
await tx.itemAttachment.deleteMany();
|
|
await tx.labelOnItem.deleteMany();
|
|
await tx.animalLog.deleteMany();
|
|
await tx.task.deleteMany();
|
|
await tx.item.deleteMany();
|
|
await tx.label.deleteMany();
|
|
await tx.animal.deleteMany();
|
|
await tx.plant.deleteMany();
|
|
await tx.plantGroup.deleteMany();
|
|
await tx.plantType.deleteMany();
|
|
await tx.location.deleteMany();
|
|
await tx.apiToken.deleteMany();
|
|
await tx.auditEvent.deleteMany();
|
|
await tx.user.deleteMany();
|
|
|
|
if (users.length) await tx.user.createMany({ data: users });
|
|
if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens as never });
|
|
// 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 as never });
|
|
if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never });
|
|
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
|
|
if (plants.length) await tx.plant.createMany({ data: plants as never });
|
|
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
|
|
if (labels.length) await tx.label.createMany({ data: labels as never });
|
|
// Item self-references parentItemId; one createMany statement validates the
|
|
// FK only after all rows exist — order-independent.
|
|
if (items.length) await tx.item.createMany({ data: items as never });
|
|
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
|
|
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
|
|
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
|
|
if (animals.length) await tx.animal.createMany({ data: animals as never });
|
|
if (animalLogs.length) await tx.animalLog.createMany({ data: animalLogs as never });
|
|
if (tasks.length) await tx.task.createMany({ data: tasks as never });
|
|
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
|
|
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
|
|
});
|
|
|
|
await db.auditEvent.create({
|
|
data: {
|
|
action: "admin.restore",
|
|
actorId: session.user.id,
|
|
payload: { exportedAt: manifest.exportedAt, restoredAt: new Date().toISOString() },
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
|
|
} catch (err) {
|
|
if (err instanceof BadBackup || err instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{ error: "The backup file is invalid or corrupted." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
console.error("restore failed:", err);
|
|
return NextResponse.json({ error: "Restore failed." }, { status: 500 });
|
|
}
|
|
}
|