Add Backup & Restore page (zip download + drag-drop upload)

This commit is contained in:
Bonna Moon
2026-06-15 21:47:49 -05:00
parent 9ec4457552
commit c12e607be7
6 changed files with 277 additions and 1 deletions

View File

@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { zipSync, strToU8 } from "fflate";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
export async function GET() {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
await Promise.all([
db.user.findMany(),
db.plantGroup.findMany(),
db.plant.findMany(),
db.plantLog.findMany(),
db.task.findMany(),
db.taskCompletion.findMany(),
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]);
const manifest = {
version: 1,
exportedAt: new Date().toISOString(),
tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
};
const zip = zipSync({
"manifest.json": strToU8(JSON.stringify(manifest, 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)),
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)),
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
});
const date = new Date().toISOString().slice(0, 10);
return new NextResponse(zip, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="moonbase-backup-${date}.zip"`,
},
});
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.error(err);
return NextResponse.json({ error: "Backup failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { unzipSync, strFromU8 } from "fflate";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
export async function POST(req: Request) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
const buf = await file.arrayBuffer();
const files = unzipSync(new Uint8Array(buf));
const parse = (name: string) => {
if (!files[name]) throw new Error(`Missing ${name} in zip`);
return JSON.parse(strFromU8(files[name]));
};
const manifest = parse("manifest.json");
if (manifest.version !== 1) {
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
}
const users = parse("users.json");
const plantGroups = parse("plantGroups.json");
const plants = parse("plants.json");
const plantLogs = parse("plantLogs.json");
const tasks = parse("tasks.json");
const taskCompletions = parse("taskCompletions.json");
const auditEvents = parse("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.task.deleteMany();
await tx.plant.deleteMany();
await tx.plantGroup.deleteMany();
await tx.auditEvent.deleteMany();
await tx.user.deleteMany();
if (users.length) await tx.user.createMany({ data: users });
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 });
if (tasks.length) await tx.task.createMany({ data: tasks });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents });
});
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 Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.error(err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}