589 lines
19 KiB
TypeScript
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 });
|
|
}
|
|
}
|