287 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
}
|