Files
Calbook/app/api/admin/backup/route.ts

287 lines
12 KiB
TypeScript

import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/session";
import { prisma } from "@/lib/prisma";
import { reEncryptWithNewKey } from "@/lib/crypto";
const SKIP_SETTING_KEYS = new Set(["PUBLIC_URL", "NEXTAUTH_URL", "APP_BASE_URL"]);
export async function GET() {
try {
await requireAdmin();
const caldavKey = process.env.CALDAV_ENCRYPTION_KEY ?? "";
const [
settings,
users,
calendarConns,
appointments,
busyBlocks,
deliveryIssues,
syncRuns
] = await Promise.all([
prisma.setting.findMany(),
prisma.user.findMany({ select: { id: true, name: true, email: true, hashedPassword: true, role: true, slug: true, bio: true, avatarUrl: true, timezone: true, isActive: true, createdAt: true, updatedAt: true } }),
prisma.calendarConn.findMany({ select: { id: true, userId: true, name: true, bookingAllowedWeekdays: true, bookingDayStartTime: true, bookingDayEndTime: true, bookingDayRangesJson: true, url: true, username: true, notificationEmail: true, encryptedPassword: true, color: true, syncEnabled: true, lastSyncedAt: true, createdAt: true, updatedAt: true } }),
prisma.appointment.findMany(),
prisma.busyBlock.findMany(),
prisma.deliveryIssue.findMany(),
prisma.calendarSyncRun.findMany({ include: { entries: true } })
]);
const backup = {
version: 1,
exportedAt: new Date().toISOString(),
caldavEncryptionKey: caldavKey || undefined,
settings: settings.filter((s) => !SKIP_SETTING_KEYS.has(s.key)).map((s) => ({ key: s.key, value: s.value })),
users,
calendarConns,
appointments,
busyBlocks,
deliveryIssues,
syncRuns
};
const json = JSON.stringify(backup, null, 2);
return new NextResponse(json, {
status: 200,
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="calbook-backup-${new Date().toISOString().slice(0, 10)}.json"`
}
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "UNAUTHORIZED") return NextResponse.json({ message: "Nicht autorisiert" }, { status: 401 });
if (error.message === "FORBIDDEN") return NextResponse.json({ message: "Keine Admin-Rechte" }, { status: 403 });
}
return NextResponse.json({ message: "Backup konnte nicht erstellt werden" }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
await requireAdmin();
const body = await req.json();
if (!body || typeof body !== "object" || !body.version) {
return NextResponse.json({ message: "Ungültiges Backup-Format" }, { status: 400 });
}
const importedAt = new Date().toISOString();
const steps: Array<{
label: string;
status: "ok" | "error" | "skipped";
detail: string;
}> = [];
const userIdMap = new Map<string, string>();
const backupCaldavKey: string =
typeof body.caldavEncryptionKey === "string" && body.caldavEncryptionKey.length >= 32
? body.caldavEncryptionKey
: "";
async function addStep(label: string, fn: () => Promise<string>) {
try {
const detail = await fn();
steps.push({ label, status: "ok", detail });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
steps.push({ label, status: "error", detail: msg });
}
}
await addStep("Einstellungen", async () => {
if (!Array.isArray(body.settings)) return "Keine Settings im Backup";
const filtered = body.settings.filter((s: { key: string }) => !SKIP_SETTING_KEYS.has(s.key));
await prisma.$transaction(
filtered.map((s: { key: string; value: string }) =>
prisma.setting.upsert({
where: { key: s.key },
create: { key: s.key, value: s.value },
update: { value: s.value }
})
)
);
return `${filtered.length} Settings wiederhergestellt`;
});
await addStep("Benutzer", async () => {
if (!Array.isArray(body.users)) return "Keine Benutzer im Backup";
const existingEmails = await prisma.user.findMany({ select: { email: true, id: true } });
const emailToExistingId = new Map(existingEmails.map((u) => [u.email, u.id]));
let created = 0;
let updated = 0;
for (const u of body.users) {
const hasPw = typeof u.hashedPassword === "string" && u.hashedPassword.length > 0;
const existingId = emailToExistingId.get(u.email);
if (existingId) {
const updateData: Record<string, unknown> = {
name: u.name, role: u.role, slug: u.slug,
bio: u.bio ?? null, avatarUrl: u.avatarUrl ?? null,
timezone: u.timezone ?? "Europe/Berlin", isActive: u.isActive ?? true
};
if (hasPw) updateData.hashedPassword = u.hashedPassword;
await prisma.user.update({ where: { email: u.email }, data: updateData });
userIdMap.set(u.id, existingId);
updated++;
} else {
const createdUser = await prisma.user.create({
data: {
name: u.name, email: u.email, hashedPassword: hasPw ? u.hashedPassword : "",
role: u.role ?? "STAFF",
slug: u.slug ?? u.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
bio: u.bio ?? null, avatarUrl: u.avatarUrl ?? null,
timezone: u.timezone ?? "Europe/Berlin", isActive: u.isActive ?? true
}
});
userIdMap.set(u.id, createdUser.id);
created++;
}
}
return `${created} erstellt, ${updated} aktualisiert`;
});
await addStep("Kalender-Verbindungen", async () => {
if (!Array.isArray(body.calendarConns)) return "Keine Kalender im Backup";
let created = 0;
let updated = 0;
let reEncrypted = 0;
for (const cc of body.calendarConns) {
const resolvedUserId = userIdMap.get(cc.userId) ?? cc.userId;
const resolvedPassword = backupCaldavKey && cc.encryptedPassword
? reEncryptWithNewKey(String(cc.encryptedPassword), backupCaldavKey)
: (cc.encryptedPassword ?? "");
if (backupCaldavKey && cc.encryptedPassword && resolvedPassword !== cc.encryptedPassword) reEncrypted++;
const exists = await prisma.calendarConn.findUnique({ where: { id: cc.id } });
if (exists) {
await prisma.calendarConn.update({
where: { id: cc.id },
data: {
name: cc.name, bookingAllowedWeekdays: cc.bookingAllowedWeekdays ?? "0,1,2,3,4",
bookingDayStartTime: cc.bookingDayStartTime ?? "09:00",
bookingDayEndTime: cc.bookingDayEndTime ?? "17:00",
bookingDayRangesJson: cc.bookingDayRangesJson ?? null,
url: cc.url, username: cc.username,
notificationEmail: cc.notificationEmail ?? null,
encryptedPassword: resolvedPassword,
color: cc.color ?? null, syncEnabled: cc.syncEnabled ?? true
}
});
updated++;
} else {
await prisma.calendarConn.create({
data: {
id: cc.id, userId: resolvedUserId, name: cc.name,
bookingAllowedWeekdays: cc.bookingAllowedWeekdays ?? "0,1,2,3,4",
bookingDayStartTime: cc.bookingDayStartTime ?? "09:00",
bookingDayEndTime: cc.bookingDayEndTime ?? "17:00",
bookingDayRangesJson: cc.bookingDayRangesJson ?? null,
url: cc.url, username: cc.username,
notificationEmail: cc.notificationEmail ?? null,
encryptedPassword: resolvedPassword,
color: cc.color ?? null, syncEnabled: cc.syncEnabled ?? true
}
});
created++;
}
}
const reEncNote = backupCaldavKey && reEncrypted > 0 ? `, ${reEncrypted} Passwörter neu verschlüsselt` : "";
return `${created} erstellt, ${updated} aktualisiert${reEncNote}`;
});
await addStep("Termine", async () => {
if (!Array.isArray(body.appointments)) return "Keine Termine im Backup";
let created = 0;
let updated = 0;
for (const a of body.appointments) {
const resolvedStaffId = userIdMap.get(a.staffId) ?? a.staffId;
const exists = await prisma.appointment.findUnique({ where: { id: a.id } });
if (exists) {
await prisma.appointment.update({
where: { id: a.id },
data: {
bookingGroupId: a.bookingGroupId ?? null, staffId: resolvedStaffId,
customerFirstName: a.customerFirstName, customerLastName: a.customerLastName,
customerEmail: a.customerEmail, customerPhone: a.customerPhone ?? null,
notes: a.notes ?? null, startAt: new Date(a.startAt), endAt: new Date(a.endAt),
durationMinutes: a.durationMinutes ?? 60, status: a.status ?? "CONFIRMED",
cancellationToken: a.cancellationToken, calendarEventUid: a.calendarEventUid ?? null,
cancelledAt: a.cancelledAt ? new Date(a.cancelledAt) : null,
noShowAt: a.noShowAt ? new Date(a.noShowAt) : null,
reminder24hSentAt: a.reminder24hSentAt ? new Date(a.reminder24hSentAt) : null,
reminder2hSentAt: a.reminder2hSentAt ? new Date(a.reminder2hSentAt) : null
}
});
updated++;
} else {
await prisma.appointment.create({
data: {
id: a.id, bookingGroupId: a.bookingGroupId ?? null, staffId: resolvedStaffId,
customerFirstName: a.customerFirstName, customerLastName: a.customerLastName,
customerEmail: a.customerEmail, customerPhone: a.customerPhone ?? null,
notes: a.notes ?? null, startAt: new Date(a.startAt), endAt: new Date(a.endAt),
durationMinutes: a.durationMinutes ?? 60, status: a.status ?? "CONFIRMED",
cancellationToken: a.cancellationToken, calendarEventUid: a.calendarEventUid ?? null,
cancelledAt: a.cancelledAt ? new Date(a.cancelledAt) : null,
noShowAt: a.noShowAt ? new Date(a.noShowAt) : null,
reminder24hSentAt: a.reminder24hSentAt ? new Date(a.reminder24hSentAt) : null,
reminder2hSentAt: a.reminder2hSentAt ? new Date(a.reminder2hSentAt) : null
}
});
created++;
}
}
return `${created} erstellt, ${updated} aktualisiert`;
});
await addStep("Zustellfehler", async () => {
if (!Array.isArray(body.deliveryIssues)) return "Keine Zustellfehler im Backup";
let created = 0;
let updated = 0;
for (const di of body.deliveryIssues) {
const exists = await prisma.deliveryIssue.findUnique({ where: { id: di.id } });
if (exists) {
await prisma.deliveryIssue.update({
where: { id: di.id },
data: {
channel: di.channel, operation: di.operation, target: di.target,
lastError: di.lastError, attemptCount: di.attemptCount ?? 1,
firstSeenAt: new Date(di.firstSeenAt),
lastSeenAt: di.lastSeenAt ? new Date(di.lastSeenAt) : undefined,
resolvedAt: di.resolvedAt ? new Date(di.resolvedAt) : null
}
});
updated++;
} else {
await prisma.deliveryIssue.create({
data: {
id: di.id, channel: di.channel, operation: di.operation, target: di.target,
lastError: di.lastError, attemptCount: di.attemptCount ?? 1,
firstSeenAt: new Date(di.firstSeenAt),
lastSeenAt: di.lastSeenAt ? new Date(di.lastSeenAt) : new Date(di.firstSeenAt),
resolvedAt: di.resolvedAt ? new Date(di.resolvedAt) : null
}
});
created++;
}
}
return `${created} erstellt, ${updated} aktualisiert`;
});
const hasErrors = steps.some((s) => s.status === "error");
return NextResponse.json({
message: hasErrors ? "Import mit Fehlern abgeschlossen" : "Import erfolgreich",
importedAt,
steps
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "UNAUTHORIZED") return NextResponse.json({ message: "Nicht autorisiert" }, { status: 401 });
if (error.message === "FORBIDDEN") return NextResponse.json({ message: "Keine Admin-Rechte" }, { status: 403 });
}
return NextResponse.json({ message: "Import fehlgeschlagen" }, { status: 500 });
}
}