Files
RFP_Finanzen/src/app/api/audit-logs/[id]/restore/route.ts
Jan Hanewinkel f947908f0e
All checks were successful
CI / Build (push) Successful in 1m10s
CI / Deploy (push) Successful in 1m11s
Fix period editing and harden app with Next.js security upgrade
2026-04-20 00:02:46 +02:00

589 lines
19 KiB
TypeScript

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: Promise<{
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;
}
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 { id } = await params;
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
}
});
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.update": {
const previous = asRecord(rollback.previous, "Zeitraum");
await tx.accountingPeriod.update({
where: {
id: asString(previous.id, "Zeitraum-ID")
},
data: {
name: asString(previous.name, "Zeitraumname"),
startsAt: asDate(previous.startsAt, "Zeitraumstart") ?? new Date(),
endsAt: asDate(previous.endsAt, "Zeitraumende") ?? 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 });
}
}