Bezahlt setzen zählt in der Budgetanzeige automatisch mit. Zusätzlich gibt es unter Neue Ausgabe eine eigene Insel für zusätzlich bereits übergebenes Geld, falls das nicht über einzelne Ausgaben läuft. In den Budgetkarten wird das als gestrichelte Querlinie plus eigenem Chip dargestellt
572 lines
18 KiB
TypeScript
572 lines
18 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: {
|
|
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 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 });
|
|
}
|
|
}
|