Files
Moonbase/src/app/api/admin/restore/route.ts
tonym 39be24c2fc v0.14.0 — Animals & Pets
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>
2026-06-16 00:23:53 -05:00

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