Initial commit
This commit is contained in:
516
src/app/api/audit-logs/[id]/restore/route.ts
Normal file
516
src/app/api/audit-logs/[id]/restore/route.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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"),
|
||||
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"),
|
||||
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 "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,
|
||||
workingGroupId: asNullableString(deleted.workingGroupId),
|
||||
createdAt: asDate(deleted.createdAt, "Nutzer erstellt am") ?? new Date()
|
||||
}
|
||||
});
|
||||
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",
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user