import { NextResponse } from "next/server"; import { createAuditLog, getRollbackMetadata } from "@/lib/audit-log"; import { APPROVAL_FLOW, canManageUsers } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; type Context = { params: { id: string; }; }; function asRecord(value: unknown, label: string) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} ist im Änderungsverlauf nicht vollständig hinterlegt.`); } return value as Record; } function asString(value: unknown, label: string) { if (typeof value !== "string" || value.length === 0) { throw new Error(`${label} fehlt im Änderungsverlauf.`); } return value; } function asNullableString(value: unknown) { return typeof value === "string" && value.length > 0 ? value : null; } function asDate(value: unknown, label: string) { if (value === null || value === undefined || value === "") { return null; } const parsed = new Date(asString(value, label)); if (Number.isNaN(parsed.getTime())) { throw new Error(`${label} ist ungültig.`); } return parsed; } function asNumber(value: unknown, label: string) { if (typeof value !== "number" || Number.isNaN(value)) { throw new Error(`${label} ist ungültig.`); } return value; } function asApprovalPermissions(value: unknown) { if (!Array.isArray(value)) { return [] as ("CHAIR_A" | "CHAIR_B" | "FINANCE")[]; } return value.filter( (entry): entry is "CHAIR_A" | "CHAIR_B" | "FINANCE" => entry === "CHAIR_A" || entry === "CHAIR_B" || entry === "FINANCE" ); } export async function POST(_: Request, { params }: Context) { const viewer = await getCurrentViewer(); if (!viewer) { return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); } if (!canManageUsers(viewer.role)) { return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Zustände zurücksetzen." }, { status: 403 }); } const auditLog = await prisma.auditLog.findUnique({ where: { id: params.id } }); if (!auditLog) { return NextResponse.json({ error: "Log-Eintrag nicht gefunden." }, { status: 404 }); } const rollback = getRollbackMetadata(auditLog.metadata); if (!rollback) { return NextResponse.json({ error: "Dieser Log-Eintrag enthält noch keinen wiederherstellbaren Zustand." }, { status: 400 }); } try { await prisma.$transaction(async (tx) => { switch (rollback.kind) { case "budget.create": { const created = asRecord(rollback.created, "Budget"); const budgetId = asString(created.id, "Budget-ID"); const budget = await tx.budget.findUnique({ where: { id: budgetId }, include: { _count: { select: { expenses: true } } } }); if (!budget) { throw new Error("Das angelegte Budget existiert nicht mehr."); } if (budget._count.expenses > 0) { throw new Error("Das Budget hat bereits Ausgaben und kann nicht automatisch entfernt werden."); } await tx.budget.delete({ where: { id: budgetId } }); if (rollback.createdWorkingGroup) { const createdWorkingGroup = asRecord(rollback.createdWorkingGroup, "Arbeitsgruppe"); const workingGroupId = asString(createdWorkingGroup.id, "AG-ID"); const workingGroup = await tx.workingGroup.findUnique({ where: { id: workingGroupId }, include: { _count: { select: { members: true, budgets: true, expenses: true } } } }); if ( workingGroup && workingGroup._count.members === 0 && workingGroup._count.budgets === 0 && workingGroup._count.expenses === 0 ) { await tx.workingGroup.delete({ where: { id: workingGroup.id } }); } } break; } case "budget.update": { const previous = asRecord(rollback.previous, "Budget"); const budgetId = asString(previous.id, "Budget-ID"); await tx.budget.update({ where: { id: budgetId }, data: { name: asString(previous.name, "Budgetname"), totalBudget: asNumber(previous.totalBudget, "Budgetbetrag"), releasedAmount: asNumber(previous.releasedAmount ?? 0, "Zus\u00e4tzliche Mittel\u00fcbergabe"), colorCode: asString(previous.colorCode, "Budgetfarbe") } }); break; } case "budget.delete": { const deleted = asRecord(rollback.deleted, "Budget"); await tx.budget.create({ data: { id: asString(deleted.id, "Budget-ID"), name: asString(deleted.name, "Budgetname"), totalBudget: asNumber(deleted.totalBudget, "Budgetbetrag"), releasedAmount: asNumber(deleted.releasedAmount ?? 0, "Zus\u00e4tzliche Mittel\u00fcbergabe"), colorCode: asString(deleted.colorCode, "Budgetfarbe"), workingGroupId: asString(deleted.workingGroupId, "AG-ID"), periodId: asString(deleted.periodId, "Zeitraum-ID"), createdAt: asDate(deleted.createdAt, "Budget erstellt am") ?? new Date() } }); break; } case "workingGroup.delete": { const deleted = asRecord(rollback.deleted, "Arbeitsgruppe"); await tx.workingGroup.create({ data: { id: asString(deleted.id, "AG-ID"), name: asString(deleted.name, "AG-Name"), createdAt: asDate(deleted.createdAt, "AG erstellt am") ?? new Date() } }); break; } case "workingGroup.create": { const created = asRecord(rollback.created, "Arbeitsgruppe"); const workingGroupId = asString(created.id, "AG-ID"); const workingGroup = await tx.workingGroup.findUnique({ where: { id: workingGroupId }, include: { _count: { select: { members: true, budgets: true, expenses: true } } } }); if (!workingGroup) { throw new Error("Die angelegte AG existiert nicht mehr."); } if (workingGroup._count.members > 0 || workingGroup._count.budgets > 0 || workingGroup._count.expenses > 0) { throw new Error("Die AG wird bereits verwendet und kann nicht automatisch entfernt werden."); } await tx.workingGroup.delete({ where: { id: workingGroupId } }); break; } case "workingGroup.update": { const previous = asRecord(rollback.previous, "Arbeitsgruppe"); const workingGroupId = asString(previous.id, "AG-ID"); await tx.workingGroup.update({ where: { id: workingGroupId }, data: { name: asString(previous.name, "AG-Name") } }); break; } case "period.create": { const created = asRecord(rollback.created, "Zeitraum"); const periodId = asString(created.id, "Zeitraum-ID"); const period = await tx.accountingPeriod.findUnique({ where: { id: periodId }, include: { _count: { select: { budgets: true, expenses: true } } } }); if (!period) { throw new Error("Der angelegte Zeitraum existiert nicht mehr."); } if (period.isCurrent) { throw new Error("Der aktuell aktive Zeitraum kann nicht automatisch entfernt werden."); } if (period._count.expenses > 0) { throw new Error("Der Zeitraum enthält bereits Ausgaben und kann nicht automatisch entfernt werden."); } await tx.budget.deleteMany({ where: { periodId } }); await tx.accountingPeriod.delete({ where: { id: periodId } }); break; } case "period.delete": { const deleted = asRecord(rollback.deleted, "Zeitraum"); await tx.accountingPeriod.create({ data: { id: asString(deleted.id, "Zeitraum-ID"), name: asString(deleted.name, "Zeitraumname"), startsAt: asDate(deleted.startsAt, "Zeitraumstart") ?? new Date(), endsAt: asDate(deleted.endsAt, "Zeitraumende") ?? new Date(), isCurrent: Boolean(deleted.isCurrent), createdAt: asDate(deleted.createdAt, "Zeitraum erstellt am") ?? new Date() } }); break; } case "period.setCurrent": { const previousCurrentPeriodId = asNullableString(rollback.previousCurrentPeriodId); await tx.accountingPeriod.updateMany({ data: { isCurrent: false } }); if (previousCurrentPeriodId) { await tx.accountingPeriod.update({ where: { id: previousCurrentPeriodId }, data: { isCurrent: true } }); } break; } case "settings.update": { const previous = asRecord(rollback.previous, "App-Einstellungen"); await tx.appSettings.upsert({ where: { id: asString(previous.id, "Einstellungs-ID") }, update: { approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle") }, create: { id: asString(previous.id, "Einstellungs-ID"), approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle"), createdAt: asDate(previous.createdAt, "Einstellungen erstellt am") ?? new Date() } }); break; } case "user.create": { const created = asRecord(rollback.created, "Nutzer"); const userId = asString(created.id, "Nutzer-ID"); if (viewer.id === userId) { throw new Error("Dein eigenes aktives Konto kann nicht über den Änderungsverlauf entfernt werden."); } const user = await tx.user.findUnique({ where: { id: userId }, include: { _count: { select: { approvals: true, createdExpenses: true } } } }); if (!user) { throw new Error("Der angelegte Nutzer existiert nicht mehr."); } if (user._count.approvals > 0 || user._count.createdExpenses > 0) { throw new Error("Der Nutzer hat bereits Ausgaben oder Freigaben und kann nicht automatisch entfernt werden."); } if (user.role === "ADMIN") { const adminCount = await tx.user.count({ where: { role: "ADMIN" } }); if (adminCount <= 1) { throw new Error("Mindestens ein Vorstandskonto muss erhalten bleiben."); } } await tx.user.delete({ where: { id: userId } }); break; } case "user.delete": { const deleted = asRecord(rollback.deleted, "Nutzer"); await tx.user.create({ data: { id: asString(deleted.id, "Nutzer-ID"), name: asString(deleted.name, "Anzeigename"), username: asString(deleted.username, "Login-Name"), email: asNullableString(deleted.email), passwordHash: asString(deleted.passwordHash, "Passworthash"), role: asString(deleted.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER", approvalPreference: asNullableString(deleted.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, approvalPermissions: asApprovalPermissions(deleted.approvalPermissions), workingGroupId: asNullableString(deleted.workingGroupId), createdAt: asDate(deleted.createdAt, "Nutzer erstellt am") ?? new Date() } }); break; } case "user.update": { const previous = asRecord(rollback.previous, "Nutzer"); const role = asString(previous.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER"; await tx.user.update({ where: { id: asString(previous.id, "Nutzer-ID") }, data: { name: asString(previous.name, "Anzeigename"), username: asString(previous.username, "Login-Name"), email: asNullableString(previous.email), role, approvalPreference: asNullableString(previous.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, approvalPermissions: asApprovalPermissions(previous.approvalPermissions), workingGroupId: asNullableString(previous.workingGroupId) } }); break; } case "user.passwordReset": { await tx.user.update({ where: { id: asString(rollback.userId, "Nutzer-ID") }, data: { passwordHash: asString(rollback.previousPasswordHash, "Altes Passwort") } }); break; } case "expense.create": { const created = asRecord(rollback.created, "Ausgabe"); const expenseId = asString(created.id, "Ausgabe-ID"); const expense = await tx.expense.findUnique({ where: { id: expenseId }, include: { _count: { select: { approvals: true } } } }); if (!expense) { throw new Error("Die angelegte Ausgabe existiert nicht mehr."); } if (expense._count.approvals > 0 || expense.paidAt || expense.documentedAt) { throw new Error("Die Ausgabe wurde bereits weiterverarbeitet und kann nicht automatisch entfernt werden."); } await tx.expense.delete({ where: { id: expenseId } }); break; } case "expense.delete": { const deleted = asRecord(rollback.deleted, "Ausgabe"); await tx.expense.create({ data: { id: asString(deleted.id, "Ausgabe-ID"), title: asString(deleted.title, "Titel"), description: asNullableString(deleted.description), amount: asNumber(deleted.amount, "Betrag"), creatorId: asString(deleted.creatorId, "Ersteller-ID"), agId: asString(deleted.agId, "AG-ID"), budgetId: asString(deleted.budgetId, "Budget-ID"), periodId: asString(deleted.periodId, "Zeitraum-ID"), approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED", recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY", recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"), proofUrl: asNullableString(deleted.proofUrl), createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(), paidAt: asDate(deleted.paidAt, "Bezahlt am"), documentedAt: asDate(deleted.documentedAt, "Dokumentiert am") } }); break; } case "expense.approve": { const approval = asRecord(rollback.approval, "Freigabe"); const expenseId = asString(approval.expenseId, "Ausgabe-ID"); await tx.approval.delete({ where: { id: asString(approval.id, "Freigabe-ID") } }); const remainingApprovals = await tx.approval.findMany({ where: { expenseId } }); const approvalTypes = remainingApprovals.map((entry) => entry.approvalType); const approvalStatus = APPROVAL_FLOW.every((approvalType) => approvalTypes.includes(approvalType)) ? "APPROVED" : "PENDING"; await tx.expense.update({ where: { id: expenseId }, data: { approvalStatus } }); break; } case "expense.markPaid": { await tx.expense.update({ where: { id: asString(rollback.expenseId, "Ausgabe-ID") }, data: { paidAt: asDate(rollback.previousPaidAt, "Vorheriger Bezahlt-Zeitpunkt") } }); break; } case "expense.document": { await tx.expense.update({ where: { id: asString(rollback.expenseId, "Ausgabe-ID") }, data: { proofUrl: asNullableString(rollback.previousProofUrl), documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt") } }); break; } default: throw new Error("Dieser Änderungstyp kann aktuell noch nicht automatisch zurückgesetzt werden."); } }); await createAuditLog(prisma, { actorId: viewer.id, action: "audit.restore", entityType: auditLog.entityType, entityId: auditLog.entityId, entityLabel: auditLog.entityLabel, summary: `Änderung "${auditLog.summary}" wurde zurückgesetzt.`, metadata: { restoredAuditLogId: auditLog.id, restoredAction: auditLog.action } }); return NextResponse.json({ ok: true }); } catch (error) { const message = error instanceof Error ? error.message : "Der Zustand konnte nicht zurückgesetzt werden."; return NextResponse.json({ error: message }, { status: 400 }); } }