Initial commit
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import NextAuth from "next-auth";
|
||||
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotBudget } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const updateBudgetSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
totalBudget: z.coerce.number().min(0),
|
||||
colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/)
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function PATCH(request: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets aendern." }, { status: 403 });
|
||||
}
|
||||
|
||||
const budget = await prisma.budget.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!budget) {
|
||||
return NextResponse.json({ error: "Budget nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = updateBudgetSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Budgetname, Betrag oder Farbe sind ungueltig." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const previousBudget = budget;
|
||||
const updatedBudget = await prisma.budget.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
totalBudget: parsed.data.totalBudget,
|
||||
colorCode: parsed.data.colorCode
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "budget.update",
|
||||
entityType: "budget",
|
||||
entityId: updatedBudget.id,
|
||||
entityLabel: updatedBudget.name,
|
||||
summary: `Budget ${updatedBudget.name} wurde aktualisiert.`,
|
||||
metadata: {
|
||||
totalBudget: parsed.data.totalBudget,
|
||||
colorCode: parsed.data.colorCode,
|
||||
rollback: {
|
||||
kind: "budget.update",
|
||||
previous: snapshotBudget(previousBudget),
|
||||
next: snapshotBudget(updatedBudget)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ budget: updatedBudget });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "In dieser AG gibt es bereits ein Budget mit diesem Namen." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets loeschen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const budget = await prisma.budget.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
expenses: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!budget) {
|
||||
return NextResponse.json({ error: "Budget nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (budget._count.expenses > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dieses Budget enthaelt noch Ausgaben. Bitte loesche oder verschiebe erst die Posten." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.budget.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "budget.delete",
|
||||
entityType: "budget",
|
||||
entityId: budget.id,
|
||||
entityLabel: budget.name,
|
||||
summary: `Budget ${budget.name} wurde geloescht.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "budget.delete",
|
||||
deleted: snapshotBudget(budget)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotBudget } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const budgetSchema = z.object({
|
||||
workingGroupId: z.string().trim().min(1),
|
||||
periodId: z.string().trim().min(1),
|
||||
name: z.string().trim().min(2).max(80),
|
||||
totalBudget: z.coerce.number().min(0),
|
||||
colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/)
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets verwalten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = budgetSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte AG, Budgetname, Betrag und Farbe korrekt angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const workingGroup = await prisma.workingGroup.findUnique({
|
||||
where: {
|
||||
id: parsed.data.workingGroupId
|
||||
}
|
||||
});
|
||||
|
||||
if (!workingGroup) {
|
||||
return NextResponse.json({ error: "Die ausgewaehlte AG wurde nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const accountingPeriod = await prisma.accountingPeriod.findUnique({
|
||||
where: {
|
||||
id: parsed.data.periodId
|
||||
}
|
||||
});
|
||||
|
||||
if (!accountingPeriod) {
|
||||
return NextResponse.json({ error: "Der ausgewaehlte Abrechnungszeitraum wurde nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingBudget = await prisma.budget.findUnique({
|
||||
where: {
|
||||
workingGroupId_periodId_name: {
|
||||
workingGroupId: workingGroup.id,
|
||||
periodId: accountingPeriod.id,
|
||||
name: parsed.data.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const budget = existingBudget
|
||||
? await prisma.budget.update({
|
||||
where: {
|
||||
id: existingBudget.id
|
||||
},
|
||||
data: {
|
||||
totalBudget: parsed.data.totalBudget,
|
||||
colorCode: parsed.data.colorCode
|
||||
}
|
||||
})
|
||||
: await prisma.budget.create({
|
||||
data: {
|
||||
workingGroupId: workingGroup.id,
|
||||
periodId: accountingPeriod.id,
|
||||
name: parsed.data.name,
|
||||
totalBudget: parsed.data.totalBudget,
|
||||
colorCode: parsed.data.colorCode
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: existingBudget ? "budget.update" : "budget.create",
|
||||
entityType: "budget",
|
||||
entityId: budget.id,
|
||||
entityLabel: budget.name,
|
||||
summary: existingBudget
|
||||
? `Budget ${budget.name} in ${workingGroup.name} gespeichert.`
|
||||
: `Budget ${budget.name} in ${workingGroup.name} angelegt.`,
|
||||
metadata: {
|
||||
workingGroupId: workingGroup.id,
|
||||
workingGroupName: workingGroup.name,
|
||||
periodId: accountingPeriod.id,
|
||||
periodName: accountingPeriod.name,
|
||||
totalBudget: parsed.data.totalBudget,
|
||||
rollback: existingBudget
|
||||
? {
|
||||
kind: "budget.update",
|
||||
previous: snapshotBudget(existingBudget),
|
||||
next: snapshotBudget(budget)
|
||||
}
|
||||
: {
|
||||
kind: "budget.create",
|
||||
created: snapshotBudget(budget)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ budget });
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotApproval } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { APPROVAL_FLOW, getAvailableApprovalTypes, requiresManualApproval } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const approvalSchema = z.object({
|
||||
approvalType: z.enum(APPROVAL_FLOW)
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(request: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const expense = await prisma.expense.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
approvals: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!requiresManualApproval(Number(expense.amount))) {
|
||||
return NextResponse.json({ error: "Diese Ausgabe ist bereits automatisch freigegeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = approvalSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Freigabetyp ungueltig." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingApprovals = expense.approvals.map((approval) => approval.approvalType);
|
||||
const availableApprovals = getAvailableApprovalTypes(
|
||||
viewer.role,
|
||||
viewer.approvalPreference,
|
||||
existingApprovals
|
||||
);
|
||||
|
||||
if (!availableApprovals.includes(parsed.data.approvalType)) {
|
||||
return NextResponse.json({ error: "Du darfst diese Freigabe nicht setzen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const transactionResult = await prisma.$transaction(async (tx) => {
|
||||
const existingApproval = await tx.approval.findUnique({
|
||||
where: {
|
||||
expenseId_approvalType: {
|
||||
expenseId: expense.id,
|
||||
approvalType: parsed.data.approvalType
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const createdApproval =
|
||||
existingApproval ??
|
||||
(await tx.approval.create({
|
||||
data: {
|
||||
expenseId: expense.id,
|
||||
userId: viewer.id,
|
||||
approvalType: parsed.data.approvalType
|
||||
}
|
||||
}));
|
||||
|
||||
const approvals = await tx.approval.findMany({
|
||||
where: {
|
||||
expenseId: expense.id
|
||||
}
|
||||
});
|
||||
|
||||
const approvalTypes = approvals.map((approval) => approval.approvalType);
|
||||
const approvalStatus = APPROVAL_FLOW.every((approvalType) => approvalTypes.includes(approvalType))
|
||||
? "APPROVED"
|
||||
: "PENDING";
|
||||
|
||||
await tx.expense.update({
|
||||
where: { id: expense.id },
|
||||
data: {
|
||||
approvalStatus
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
approval: createdApproval,
|
||||
previousStatus: expense.approvalStatus,
|
||||
nextStatus: approvalStatus
|
||||
};
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "expense.approve",
|
||||
entityType: "expense",
|
||||
entityId: expense.id,
|
||||
entityLabel: expense.title,
|
||||
summary: `${parsed.data.approvalType} fuer ${expense.title} wurde gesetzt.`,
|
||||
metadata: {
|
||||
approvalType: parsed.data.approvalType,
|
||||
rollback: {
|
||||
kind: "expense.approve",
|
||||
approval: snapshotApproval(transactionResult.approval),
|
||||
previousStatus: transactionResult.previousStatus,
|
||||
nextStatus: transactionResult.nextStatus
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canDocumentExpense } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const documentedSchema = z.object({
|
||||
proofUrl: z
|
||||
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined))
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(request: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canDocumentExpense(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen dokumentieren." }, { status: 403 });
|
||||
}
|
||||
|
||||
const expense = await prisma.expense.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (expense.approvalStatus !== "APPROVED") {
|
||||
return NextResponse.json({ error: "Dokumentation ist erst nach Freigabe moeglich." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!expense.paidAt) {
|
||||
return NextResponse.json({ error: "Bitte zuerst Bezahlt setzen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = documentedSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Beleg-URL ist ungueltig." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatedExpense = await prisma.expense.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
proofUrl: parsed.data.proofUrl ?? expense.proofUrl,
|
||||
documentedAt: expense.documentedAt ?? new Date()
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "expense.document",
|
||||
entityType: "expense",
|
||||
entityId: updatedExpense.id,
|
||||
entityLabel: updatedExpense.title,
|
||||
summary: `Ausgabe ${updatedExpense.title} wurde dokumentiert.`,
|
||||
metadata: {
|
||||
proofUrl: parsed.data.proofUrl ?? updatedExpense.proofUrl,
|
||||
rollback: {
|
||||
kind: "expense.document",
|
||||
expenseId: updatedExpense.id,
|
||||
previousProofUrl: expense.proofUrl,
|
||||
previousDocumentedAt: expense.documentedAt?.toISOString() ?? null,
|
||||
nextProofUrl: updatedExpense.proofUrl,
|
||||
nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ expense: updatedExpense });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canMarkPaid } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(_: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canMarkPaid(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Bezahlt setzen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const expense = await prisma.expense.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (expense.approvalStatus !== "APPROVED") {
|
||||
return NextResponse.json({ error: "Bezahlt ist erst nach Freigabe moeglich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatedExpense = await prisma.expense.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
paidAt: expense.paidAt ?? new Date()
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "expense.markPaid",
|
||||
entityType: "expense",
|
||||
entityId: updatedExpense.id,
|
||||
entityLabel: updatedExpense.title,
|
||||
summary: `Ausgabe ${updatedExpense.title} wurde als bezahlt markiert.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "expense.markPaid",
|
||||
expenseId: updatedExpense.id,
|
||||
previousPaidAt: expense.paidAt?.toISOString() ?? null,
|
||||
nextPaidAt: updatedExpense.paidAt?.toISOString() ?? null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ expense: updatedExpense });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const expense = await prisma.expense.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const isAdminDelete = viewer.role === "ADMIN" || viewer.role === "FINANCE";
|
||||
const isOwnPendingExpense =
|
||||
viewer.id === expense.creatorId &&
|
||||
expense.approvalStatus === "PENDING" &&
|
||||
!expense.paidAt &&
|
||||
!expense.documentedAt;
|
||||
|
||||
if (!isAdminDelete && !isOwnPendingExpense) {
|
||||
return NextResponse.json(
|
||||
{ error: "Du darfst nur eigene ungepruefte Ausgaben loeschen." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.expense.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "expense.delete",
|
||||
entityType: "expense",
|
||||
entityId: expense.id,
|
||||
entityLabel: expense.title,
|
||||
summary: `Ausgabe ${expense.title} wurde geloescht.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "expense.delete",
|
||||
deleted: snapshotExpense(expense)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const expenseSchema = z.object({
|
||||
title: z.string().trim().min(2).max(120),
|
||||
description: z
|
||||
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)),
|
||||
amount: z.coerce.number().positive(),
|
||||
agId: z.string().trim().min(1),
|
||||
budgetId: z.string().trim().min(1),
|
||||
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
|
||||
proofUrl: z
|
||||
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = expenseSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte Titel, Betrag und AG korrekt ausfuellen." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!canCreateExpenseForGroup(viewer.role, viewer.workingGroupId, parsed.data.agId)) {
|
||||
return NextResponse.json({ error: "Du kannst nur in deiner eigenen AG Ausgaben erfassen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const budget = await prisma.budget.findUnique({
|
||||
where: { id: parsed.data.budgetId }
|
||||
});
|
||||
|
||||
if (!budget || budget.workingGroupId !== parsed.data.agId) {
|
||||
return NextResponse.json({ error: "Das ausgewaehlte Budget passt nicht zur AG." }, { status: 404 });
|
||||
}
|
||||
|
||||
const expense = await prisma.expense.create({
|
||||
data: {
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
amount: parsed.data.amount,
|
||||
agId: parsed.data.agId,
|
||||
budgetId: parsed.data.budgetId,
|
||||
periodId: budget.periodId,
|
||||
creatorId: viewer.id,
|
||||
proofUrl: parsed.data.proofUrl,
|
||||
recurrence: parsed.data.recurrence,
|
||||
approvalStatus: requiresManualApproval(parsed.data.amount) ? "PENDING" : "APPROVED"
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "expense.create",
|
||||
entityType: "expense",
|
||||
entityId: expense.id,
|
||||
entityLabel: expense.title,
|
||||
summary: `Ausgabe ${expense.title} wurde angelegt.`,
|
||||
metadata: {
|
||||
amount: parsed.data.amount,
|
||||
budgetId: parsed.data.budgetId,
|
||||
workingGroupId: parsed.data.agId,
|
||||
recurrence: parsed.data.recurrence,
|
||||
approvalStatus: expense.approvalStatus,
|
||||
rollback: {
|
||||
kind: "expense.create",
|
||||
created: snapshotExpense(expense)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ expense });
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { toCsvCell } from "@/lib/backup-csv";
|
||||
import { canManageUsers } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const CSV_HEADERS = [
|
||||
"recordType",
|
||||
"id",
|
||||
"parentId",
|
||||
"parentType",
|
||||
"workingGroupId",
|
||||
"workingGroupName",
|
||||
"periodId",
|
||||
"periodName",
|
||||
"periodStartsAt",
|
||||
"periodEndsAt",
|
||||
"periodIsCurrent",
|
||||
"budgetId",
|
||||
"budgetName",
|
||||
"userId",
|
||||
"userName",
|
||||
"username",
|
||||
"passwordHash",
|
||||
"email",
|
||||
"role",
|
||||
"approvalPreference",
|
||||
"title",
|
||||
"description",
|
||||
"amount",
|
||||
"totalBudget",
|
||||
"colorCode",
|
||||
"approvalStatus",
|
||||
"approvalType",
|
||||
"recurrence",
|
||||
"proofUrl",
|
||||
"createdAt",
|
||||
"paidAt",
|
||||
"documentedAt",
|
||||
"memberUsernames",
|
||||
"creatorName",
|
||||
"creatorUsername",
|
||||
"approverName",
|
||||
"approverUsername",
|
||||
"auditActorId",
|
||||
"auditAction",
|
||||
"auditEntityType",
|
||||
"auditEntityId",
|
||||
"auditEntityLabel",
|
||||
"auditSummary",
|
||||
"auditMetadata"
|
||||
] as const;
|
||||
|
||||
type CsvRow = Record<(typeof CSV_HEADERS)[number], string | number | null | undefined>;
|
||||
|
||||
export async function GET() {
|
||||
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 duerfen CSV-Backups herunterladen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const [users, accountingPeriods, workingGroups, auditLogs] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
include: {
|
||||
workingGroup: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ role: "asc" },
|
||||
{ username: "asc" }
|
||||
]
|
||||
}),
|
||||
prisma.accountingPeriod.findMany({
|
||||
orderBy: {
|
||||
startsAt: "asc"
|
||||
}
|
||||
}),
|
||||
prisma.workingGroup.findMany({
|
||||
orderBy: {
|
||||
name: "asc"
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
approvalPreference: true
|
||||
},
|
||||
orderBy: {
|
||||
username: "asc"
|
||||
}
|
||||
},
|
||||
budgets: {
|
||||
orderBy: {
|
||||
name: "asc"
|
||||
},
|
||||
include: {
|
||||
period: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
isCurrent: true
|
||||
}
|
||||
},
|
||||
expenses: {
|
||||
orderBy: {
|
||||
createdAt: "asc"
|
||||
},
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true
|
||||
}
|
||||
},
|
||||
approvals: {
|
||||
orderBy: {
|
||||
timestamp: "asc"
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
orderBy: {
|
||||
createdAt: "asc"
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const rows: CsvRow[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
rows.push({
|
||||
recordType: "user",
|
||||
id: user.id,
|
||||
parentId: user.workingGroupId,
|
||||
parentType: user.workingGroupId ? "workingGroup" : "",
|
||||
workingGroupId: user.workingGroupId,
|
||||
workingGroupName: user.workingGroup?.name ?? "",
|
||||
periodId: "",
|
||||
periodName: "",
|
||||
periodStartsAt: "",
|
||||
periodEndsAt: "",
|
||||
periodIsCurrent: "",
|
||||
budgetId: "",
|
||||
budgetName: "",
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
username: user.username,
|
||||
passwordHash: user.passwordHash,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
approvalPreference: user.approvalPreference ?? "",
|
||||
title: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
totalBudget: "",
|
||||
colorCode: "",
|
||||
approvalStatus: "",
|
||||
approvalType: "",
|
||||
recurrence: "",
|
||||
proofUrl: "",
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
paidAt: "",
|
||||
documentedAt: "",
|
||||
memberUsernames: "",
|
||||
creatorName: "",
|
||||
creatorUsername: "",
|
||||
approverName: "",
|
||||
approverUsername: "",
|
||||
auditActorId: "",
|
||||
auditAction: "",
|
||||
auditEntityType: "",
|
||||
auditEntityId: "",
|
||||
auditEntityLabel: "",
|
||||
auditSummary: "",
|
||||
auditMetadata: ""
|
||||
});
|
||||
}
|
||||
|
||||
for (const period of accountingPeriods) {
|
||||
rows.push({
|
||||
recordType: "period",
|
||||
id: period.id,
|
||||
parentId: "",
|
||||
parentType: "",
|
||||
workingGroupId: "",
|
||||
workingGroupName: "",
|
||||
periodId: period.id,
|
||||
periodName: period.name,
|
||||
periodStartsAt: period.startsAt.toISOString(),
|
||||
periodEndsAt: period.endsAt.toISOString(),
|
||||
periodIsCurrent: period.isCurrent ? "true" : "false",
|
||||
budgetId: "",
|
||||
budgetName: "",
|
||||
userId: "",
|
||||
userName: "",
|
||||
username: "",
|
||||
passwordHash: "",
|
||||
email: "",
|
||||
role: "",
|
||||
approvalPreference: "",
|
||||
title: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
totalBudget: "",
|
||||
colorCode: "",
|
||||
approvalStatus: "",
|
||||
approvalType: "",
|
||||
recurrence: "",
|
||||
proofUrl: "",
|
||||
createdAt: period.createdAt.toISOString(),
|
||||
paidAt: "",
|
||||
documentedAt: "",
|
||||
memberUsernames: "",
|
||||
creatorName: "",
|
||||
creatorUsername: "",
|
||||
approverName: "",
|
||||
approverUsername: "",
|
||||
auditActorId: "",
|
||||
auditAction: "",
|
||||
auditEntityType: "",
|
||||
auditEntityId: "",
|
||||
auditEntityLabel: "",
|
||||
auditSummary: "",
|
||||
auditMetadata: ""
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of workingGroups) {
|
||||
rows.push({
|
||||
recordType: "workingGroup",
|
||||
id: group.id,
|
||||
parentId: "",
|
||||
parentType: "",
|
||||
workingGroupId: group.id,
|
||||
workingGroupName: group.name,
|
||||
periodId: "",
|
||||
periodName: "",
|
||||
periodStartsAt: "",
|
||||
periodEndsAt: "",
|
||||
periodIsCurrent: "",
|
||||
budgetId: "",
|
||||
budgetName: "",
|
||||
userId: "",
|
||||
userName: "",
|
||||
username: "",
|
||||
passwordHash: "",
|
||||
email: "",
|
||||
role: "",
|
||||
approvalPreference: "",
|
||||
title: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
totalBudget: group.budgets.reduce((sum, budget) => sum + Number(budget.totalBudget), 0).toFixed(2),
|
||||
colorCode: "",
|
||||
approvalStatus: "",
|
||||
approvalType: "",
|
||||
recurrence: "",
|
||||
proofUrl: "",
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
paidAt: "",
|
||||
documentedAt: "",
|
||||
memberUsernames: group.members.map((member) => member.username).join(" | "),
|
||||
creatorName: "",
|
||||
creatorUsername: "",
|
||||
approverName: "",
|
||||
approverUsername: "",
|
||||
auditActorId: "",
|
||||
auditAction: "",
|
||||
auditEntityType: "",
|
||||
auditEntityId: "",
|
||||
auditEntityLabel: "",
|
||||
auditSummary: "",
|
||||
auditMetadata: ""
|
||||
});
|
||||
|
||||
for (const budget of group.budgets) {
|
||||
rows.push({
|
||||
recordType: "budget",
|
||||
id: budget.id,
|
||||
parentId: group.id,
|
||||
parentType: "workingGroup",
|
||||
workingGroupId: group.id,
|
||||
workingGroupName: group.name,
|
||||
periodId: budget.period.id,
|
||||
periodName: budget.period.name,
|
||||
periodStartsAt: budget.period.startsAt.toISOString(),
|
||||
periodEndsAt: budget.period.endsAt.toISOString(),
|
||||
periodIsCurrent: budget.period.isCurrent ? "true" : "false",
|
||||
budgetId: budget.id,
|
||||
budgetName: budget.name,
|
||||
userId: "",
|
||||
userName: "",
|
||||
username: "",
|
||||
passwordHash: "",
|
||||
email: "",
|
||||
role: "",
|
||||
approvalPreference: "",
|
||||
title: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
totalBudget: Number(budget.totalBudget).toFixed(2),
|
||||
colorCode: budget.colorCode,
|
||||
approvalStatus: "",
|
||||
approvalType: "",
|
||||
recurrence: "",
|
||||
proofUrl: "",
|
||||
createdAt: budget.createdAt.toISOString(),
|
||||
paidAt: "",
|
||||
documentedAt: "",
|
||||
memberUsernames: "",
|
||||
creatorName: "",
|
||||
creatorUsername: "",
|
||||
approverName: "",
|
||||
approverUsername: "",
|
||||
auditActorId: "",
|
||||
auditAction: "",
|
||||
auditEntityType: "",
|
||||
auditEntityId: "",
|
||||
auditEntityLabel: "",
|
||||
auditSummary: "",
|
||||
auditMetadata: ""
|
||||
});
|
||||
|
||||
for (const expense of budget.expenses) {
|
||||
rows.push({
|
||||
recordType: "expense",
|
||||
id: expense.id,
|
||||
parentId: budget.id,
|
||||
parentType: "budget",
|
||||
workingGroupId: group.id,
|
||||
workingGroupName: group.name,
|
||||
periodId: budget.period.id,
|
||||
periodName: budget.period.name,
|
||||
periodStartsAt: budget.period.startsAt.toISOString(),
|
||||
periodEndsAt: budget.period.endsAt.toISOString(),
|
||||
periodIsCurrent: budget.period.isCurrent ? "true" : "false",
|
||||
budgetId: budget.id,
|
||||
budgetName: budget.name,
|
||||
userId: expense.creator.id,
|
||||
userName: expense.creator.name,
|
||||
username: expense.creator.username,
|
||||
passwordHash: "",
|
||||
email: "",
|
||||
role: "",
|
||||
approvalPreference: "",
|
||||
title: expense.title,
|
||||
description: expense.description ?? "",
|
||||
amount: Number(expense.amount).toFixed(2),
|
||||
totalBudget: "",
|
||||
colorCode: "",
|
||||
approvalStatus: expense.approvalStatus,
|
||||
approvalType: "",
|
||||
recurrence: expense.recurrence,
|
||||
proofUrl: expense.proofUrl ?? "",
|
||||
createdAt: expense.createdAt.toISOString(),
|
||||
paidAt: expense.paidAt?.toISOString() ?? "",
|
||||
documentedAt: expense.documentedAt?.toISOString() ?? "",
|
||||
memberUsernames: "",
|
||||
creatorName: expense.creator.name,
|
||||
creatorUsername: expense.creator.username,
|
||||
approverName: "",
|
||||
approverUsername: "",
|
||||
auditActorId: "",
|
||||
auditAction: "",
|
||||
auditEntityType: "",
|
||||
auditEntityId: "",
|
||||
auditEntityLabel: "",
|
||||
auditSummary: "",
|
||||
auditMetadata: ""
|
||||
});
|
||||
|
||||
for (const approval of expense.approvals) {
|
||||
rows.push({
|
||||
recordType: "approval",
|
||||
id: approval.id,
|
||||
parentId: expense.id,
|
||||
parentType: "expense",
|
||||
workingGroupId: group.id,
|
||||
workingGroupName: group.name,
|
||||
periodId: budget.period.id,
|
||||
periodName: budget.period.name,
|
||||
periodStartsAt: budget.period.startsAt.toISOString(),
|
||||
periodEndsAt: budget.period.endsAt.toISOString(),
|
||||
periodIsCurrent: budget.period.isCurrent ? "true" : "false",
|
||||
budgetId: budget.id,
|
||||
budgetName: budget.name,
|
||||
userId: approval.user.id,
|
||||
userName: approval.user.name,
|
||||
username: approval.user.username,
|
||||
passwordHash: "",
|
||||
email: "",
|
||||
role: "",
|
||||
approvalPreference: "",
|
||||
title: expense.title,
|
||||
description: "",
|
||||
amount: Number(expense.amount).toFixed(2),
|
||||
totalBudget: "",
|
||||
colorCode: "",
|
||||
approvalStatus: expense.approvalStatus,
|
||||
approvalType: approval.approvalType,
|
||||
recurrence: expense.recurrence,
|
||||
proofUrl: "",
|
||||
createdAt: approval.timestamp.toISOString(),
|
||||
paidAt: "",
|
||||
documentedAt: "",
|
||||
memberUsernames: "",
|
||||
creatorName: expense.creator.name,
|
||||
creatorUsername: expense.creator.username,
|
||||
approverName: approval.user.name,
|
||||
approverUsername: approval.user.username,
|
||||
auditActorId: "",
|
||||
auditAction: "",
|
||||
auditEntityType: "",
|
||||
auditEntityId: "",
|
||||
auditEntityLabel: "",
|
||||
auditSummary: "",
|
||||
auditMetadata: ""
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const auditLog of auditLogs) {
|
||||
rows.push({
|
||||
recordType: "auditLog",
|
||||
id: auditLog.id,
|
||||
parentId: "",
|
||||
parentType: "",
|
||||
workingGroupId: "",
|
||||
workingGroupName: "",
|
||||
periodId: "",
|
||||
periodName: "",
|
||||
periodStartsAt: "",
|
||||
periodEndsAt: "",
|
||||
periodIsCurrent: "",
|
||||
budgetId: "",
|
||||
budgetName: "",
|
||||
userId: "",
|
||||
userName: "",
|
||||
username: "",
|
||||
passwordHash: "",
|
||||
email: "",
|
||||
role: "",
|
||||
approvalPreference: "",
|
||||
title: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
totalBudget: "",
|
||||
colorCode: "",
|
||||
approvalStatus: "",
|
||||
approvalType: "",
|
||||
recurrence: "",
|
||||
proofUrl: "",
|
||||
createdAt: auditLog.createdAt.toISOString(),
|
||||
paidAt: "",
|
||||
documentedAt: "",
|
||||
memberUsernames: "",
|
||||
creatorName: "",
|
||||
creatorUsername: "",
|
||||
approverName: "",
|
||||
approverUsername: "",
|
||||
auditActorId: auditLog.actorId ?? "",
|
||||
auditAction: auditLog.action,
|
||||
auditEntityType: auditLog.entityType,
|
||||
auditEntityId: auditLog.entityId ?? "",
|
||||
auditEntityLabel: auditLog.entityLabel ?? "",
|
||||
auditSummary: auditLog.summary,
|
||||
auditMetadata: auditLog.metadata ? JSON.stringify(auditLog.metadata) : ""
|
||||
});
|
||||
}
|
||||
|
||||
const csvLines = [
|
||||
CSV_HEADERS.join(","),
|
||||
...rows.map((row) => CSV_HEADERS.map((header) => toCsvCell(row[header])).join(","))
|
||||
];
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const csv = `\uFEFF${csvLines.join("\n")}`;
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="rfp-finanzuebersicht-backup-${timestamp}.csv"`,
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { parseCsv } from "@/lib/backup-csv";
|
||||
import { canManageUsers } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
function toNullable(value: string | undefined) {
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function toDate(value: string | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function toNumber(value: string | undefined) {
|
||||
if (!value || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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 duerfen Backups einspielen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData().catch(() => null);
|
||||
const uploadedFile = formData?.get("file");
|
||||
|
||||
if (!(uploadedFile instanceof File)) {
|
||||
return NextResponse.json({ error: "Bitte eine CSV-Datei auswaehlen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const content = await uploadedFile.text();
|
||||
const rows = parseCsv(content);
|
||||
|
||||
if (rows.length < 2) {
|
||||
return NextResponse.json({ error: "Die CSV-Datei enthaelt keine Daten." }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
const rawEntries = rows
|
||||
.slice(1)
|
||||
.filter((row) => row.some((cell) => cell.trim().length > 0))
|
||||
.map((row) => {
|
||||
const entry = Object.fromEntries(headers.map((header, index) => [header, row[index] ?? ""]));
|
||||
return entry as Record<string, string>;
|
||||
});
|
||||
|
||||
const userRows = rawEntries.filter((entry) => entry.recordType === "user");
|
||||
|
||||
if (userRows.some((entry) => !entry.passwordHash)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dieses Backup stammt aus einem alten Format ohne Passwort-Hashes und kann nicht vollstaendig eingespielt werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const periodRows = rawEntries.filter((entry) => entry.recordType === "period");
|
||||
const groupRows = rawEntries.filter((entry) => entry.recordType === "workingGroup");
|
||||
const budgetRows = rawEntries.filter((entry) => entry.recordType === "budget");
|
||||
const expenseRows = rawEntries.filter((entry) => entry.recordType === "expense");
|
||||
const approvalRows = rawEntries.filter((entry) => entry.recordType === "approval");
|
||||
const auditRows = rawEntries.filter((entry) => entry.recordType === "auditLog");
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.approval.deleteMany();
|
||||
await tx.expense.deleteMany();
|
||||
await tx.budget.deleteMany();
|
||||
await tx.auditLog.deleteMany();
|
||||
await tx.user.deleteMany();
|
||||
await tx.workingGroup.deleteMany();
|
||||
await tx.accountingPeriod.deleteMany();
|
||||
|
||||
for (const row of periodRows) {
|
||||
const startsAt = toDate(row.periodStartsAt);
|
||||
const endsAt = toDate(row.periodEndsAt);
|
||||
|
||||
if (!startsAt || !endsAt) {
|
||||
throw new Error(`Zeitraum ${row.periodName || row.id} enthaelt kein gueltiges Datum.`);
|
||||
}
|
||||
|
||||
await tx.accountingPeriod.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
name: row.periodName,
|
||||
startsAt,
|
||||
endsAt,
|
||||
isCurrent: row.periodIsCurrent === "true",
|
||||
createdAt: toDate(row.createdAt) ?? new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of groupRows) {
|
||||
await tx.workingGroup.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
name: row.workingGroupName,
|
||||
createdAt: toDate(row.createdAt) ?? new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of userRows) {
|
||||
await tx.user.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
name: row.userName,
|
||||
username: row.username,
|
||||
email: toNullable(row.email),
|
||||
passwordHash: row.passwordHash,
|
||||
role: row.role as "ADMIN" | "FINANCE" | "MEMBER",
|
||||
approvalPreference: toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null,
|
||||
workingGroupId: toNullable(row.workingGroupId),
|
||||
createdAt: toDate(row.createdAt) ?? new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of budgetRows) {
|
||||
const totalBudget = toNumber(row.totalBudget);
|
||||
|
||||
if (totalBudget === null) {
|
||||
throw new Error(`Budget ${row.budgetName || row.id} enthaelt keinen gueltigen Betrag.`);
|
||||
}
|
||||
|
||||
await tx.budget.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
name: row.budgetName,
|
||||
totalBudget,
|
||||
colorCode: row.colorCode,
|
||||
workingGroupId: row.workingGroupId,
|
||||
periodId: row.periodId,
|
||||
createdAt: toDate(row.createdAt) ?? new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of expenseRows) {
|
||||
const amount = toNumber(row.amount);
|
||||
|
||||
if (amount === null) {
|
||||
throw new Error(`Ausgabe ${row.title || row.id} enthaelt keinen gueltigen Betrag.`);
|
||||
}
|
||||
|
||||
await tx.expense.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: toNullable(row.description),
|
||||
amount,
|
||||
creatorId: row.userId,
|
||||
agId: row.workingGroupId,
|
||||
budgetId: row.budgetId,
|
||||
periodId: row.periodId,
|
||||
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
|
||||
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
|
||||
proofUrl: toNullable(row.proofUrl),
|
||||
createdAt: toDate(row.createdAt) ?? new Date(),
|
||||
paidAt: toDate(row.paidAt),
|
||||
documentedAt: toDate(row.documentedAt)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of approvalRows) {
|
||||
const timestamp = toDate(row.createdAt);
|
||||
|
||||
if (!timestamp) {
|
||||
throw new Error(`Freigabe ${row.id} enthaelt keinen gueltigen Zeitstempel.`);
|
||||
}
|
||||
|
||||
await tx.approval.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
expenseId: row.parentId,
|
||||
userId: row.userId,
|
||||
approvalType: row.approvalType as "CHAIR_A" | "CHAIR_B" | "FINANCE",
|
||||
timestamp
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of auditRows) {
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
actorId: toNullable(row.auditActorId),
|
||||
action: row.auditAction,
|
||||
entityType: row.auditEntityType,
|
||||
entityId: toNullable(row.auditEntityId),
|
||||
entityLabel: toNullable(row.auditEntityLabel),
|
||||
summary: row.auditSummary,
|
||||
metadata: row.auditMetadata ? JSON.parse(row.auditMetadata) : null,
|
||||
createdAt: toDate(row.createdAt) ?? new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: userRows.some((row) => row.id === viewer.id) ? viewer.id : null,
|
||||
action: "backup.import",
|
||||
entityType: "system",
|
||||
entityLabel: uploadedFile.name,
|
||||
summary: `CSV-Backup ${uploadedFile.name} wurde eingespielt.`,
|
||||
metadata: {
|
||||
fileName: uploadedFile.name,
|
||||
rowCount: rawEntries.length
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
importedRows: rawEntries.length
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Das Backup konnte nicht eingespielt werden.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { snapshotPeriod } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Zeitraeume loeschen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const period = await prisma.accountingPeriod.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
budgets: true,
|
||||
expenses: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (period.isCurrent) {
|
||||
return NextResponse.json({ error: "Der aktuell aktive Zeitraum kann nicht geloescht werden." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (period._count.budgets > 0 || period._count.expenses > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dieser Zeitraum enthaelt noch Budgets oder Ausgaben und kann deshalb nicht geloescht werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.accountingPeriod.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "period.delete",
|
||||
entityType: "period",
|
||||
entityId: period.id,
|
||||
entityLabel: period.name,
|
||||
summary: `Zeitraum ${period.name} wurde geloescht.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "period.delete",
|
||||
deleted: snapshotPeriod(period)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const currentPeriodSchema = z.object({
|
||||
periodId: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen den aktuellen Zeitraum wechseln." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = currentPeriodSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte einen gueltigen Zeitraum auswaehlen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const period = await prisma.accountingPeriod.findUnique({
|
||||
where: {
|
||||
id: parsed.data.periodId
|
||||
}
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const previousCurrentPeriod = await prisma.accountingPeriod.findFirst({
|
||||
where: {
|
||||
isCurrent: true
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.accountingPeriod.updateMany({
|
||||
data: {
|
||||
isCurrent: false
|
||||
}
|
||||
}),
|
||||
prisma.accountingPeriod.update({
|
||||
where: {
|
||||
id: period.id
|
||||
},
|
||||
data: {
|
||||
isCurrent: true
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "period.setCurrent",
|
||||
entityType: "period",
|
||||
entityId: period.id,
|
||||
entityLabel: period.name,
|
||||
summary: `Aktiver Zeitraum wurde auf ${period.name} gesetzt.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "period.setCurrent",
|
||||
previousCurrentPeriodId: previousCurrentPeriod?.id ?? null,
|
||||
nextCurrentPeriodId: period.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotPeriod } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const periodSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
startsAt: z.coerce.date(),
|
||||
endsAt: z.coerce.date(),
|
||||
copyBudgetsFromPeriodId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Zeitraeume verwalten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = periodSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte Zeitraumname sowie Start- und Enddatum korrekt angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (parsed.data.endsAt < parsed.data.startsAt) {
|
||||
return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const copyBudgetsFromPeriodId =
|
||||
typeof parsed.data.copyBudgetsFromPeriodId === "string" && parsed.data.copyBudgetsFromPeriodId.length > 0
|
||||
? parsed.data.copyBudgetsFromPeriodId
|
||||
: null;
|
||||
|
||||
const period = await prisma.$transaction(async (tx) => {
|
||||
const createdPeriod = await tx.accountingPeriod.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
startsAt: parsed.data.startsAt,
|
||||
endsAt: parsed.data.endsAt,
|
||||
isCurrent: false
|
||||
}
|
||||
});
|
||||
|
||||
if (copyBudgetsFromPeriodId) {
|
||||
const sourceBudgets = await tx.budget.findMany({
|
||||
where: {
|
||||
periodId: copyBudgetsFromPeriodId
|
||||
}
|
||||
});
|
||||
|
||||
if (sourceBudgets.length > 0) {
|
||||
await tx.budget.createMany({
|
||||
data: sourceBudgets.map((budget) => ({
|
||||
name: budget.name,
|
||||
totalBudget: budget.totalBudget,
|
||||
colorCode: budget.colorCode,
|
||||
workingGroupId: budget.workingGroupId,
|
||||
periodId: createdPeriod.id
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createdPeriod;
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "period.create",
|
||||
entityType: "period",
|
||||
entityId: period.id,
|
||||
entityLabel: period.name,
|
||||
summary: `Zeitraum ${period.name} wurde angelegt.`,
|
||||
metadata: {
|
||||
startsAt: period.startsAt.toISOString(),
|
||||
endsAt: period.endsAt.toISOString(),
|
||||
copiedFromPeriodId: copyBudgetsFromPeriodId,
|
||||
rollback: {
|
||||
kind: "period.create",
|
||||
created: snapshotPeriod(period)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ period });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageUsers } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const passwordSchema = z.object({
|
||||
password: z.string().min(8).max(128)
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(request: 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 duerfen Passwoerter neu setzen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = passwordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte ein Passwort mit mindestens 8 Zeichen angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { id: true, username: true, passwordHash: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
passwordHash
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "user.passwordReset",
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
entityLabel: user.username,
|
||||
summary: "Ein Nutzerpasswort wurde neu gesetzt.",
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "user.passwordReset",
|
||||
userId: user.id,
|
||||
previousPasswordHash: user.passwordHash,
|
||||
nextPasswordHash: passwordHash
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { snapshotUser } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageUsers } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function DELETE(_: 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 duerfen Nutzer loeschen." }, { status: 403 });
|
||||
}
|
||||
|
||||
if (viewer.id === params.id) {
|
||||
return NextResponse.json({ error: "Du kannst dein eigenes Konto hier nicht loeschen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
approvals: true,
|
||||
createdExpenses: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user._count.approvals > 0 || user._count.createdExpenses > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nutzer mit Freigaben oder Ausgaben koennen nicht geloescht werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (user.role === "ADMIN") {
|
||||
const adminCount = await prisma.user.count({
|
||||
where: { role: "ADMIN" }
|
||||
});
|
||||
|
||||
if (adminCount <= 1) {
|
||||
return NextResponse.json({ error: "Mindestens ein Admin muss erhalten bleiben." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "user.delete",
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
entityLabel: user.username,
|
||||
summary: `Nutzer ${user.username} wurde geloescht.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "user.delete",
|
||||
deleted: snapshotUser(user)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotUser } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageUsers } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]);
|
||||
const approvalPreferenceSchema = z.enum(["CHAIR_A", "CHAIR_B", "FINANCE"]);
|
||||
|
||||
const createUserSchema = z.object({
|
||||
username: z.string().trim().min(2).max(40),
|
||||
password: z.string().min(8).max(128),
|
||||
role: userRoleSchema,
|
||||
workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]),
|
||||
approvalPreference: z.union([approvalPreferenceSchema, z.literal(""), z.null(), z.undefined()])
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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 duerfen Nutzer anlegen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = createUserSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte Login-Name, Passwort und Rolle korrekt angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const username = parsed.data.username.toLowerCase();
|
||||
const workingGroupId = typeof parsed.data.workingGroupId === "string" && parsed.data.workingGroupId.length > 0
|
||||
? parsed.data.workingGroupId
|
||||
: null;
|
||||
const requestedApprovalPreference =
|
||||
parsed.data.approvalPreference === "CHAIR_A" ||
|
||||
parsed.data.approvalPreference === "CHAIR_B" ||
|
||||
parsed.data.approvalPreference === "FINANCE"
|
||||
? parsed.data.approvalPreference
|
||||
: null;
|
||||
|
||||
if (parsed.data.role === "MEMBER" && !workingGroupId) {
|
||||
return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (workingGroupId) {
|
||||
const workingGroup = await prisma.workingGroup.findUnique({
|
||||
where: { id: workingGroupId }
|
||||
});
|
||||
|
||||
if (!workingGroup) {
|
||||
return NextResponse.json({ error: "Die ausgewaehlte AG wurde nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const existingUserByUsername = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUserByUsername) {
|
||||
return NextResponse.json({ error: "Dieser Login-Name ist bereits vergeben." }, { status: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
const approvalPreference =
|
||||
parsed.data.role === "FINANCE"
|
||||
? "FINANCE"
|
||||
: parsed.data.role === "ADMIN"
|
||||
? requestedApprovalPreference
|
||||
: null;
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: username,
|
||||
username,
|
||||
email: null,
|
||||
passwordHash,
|
||||
role: parsed.data.role,
|
||||
workingGroupId: parsed.data.role === "MEMBER" ? workingGroupId : null,
|
||||
approvalPreference
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "user.create",
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
entityLabel: user.username,
|
||||
summary: `Nutzer ${user.username} wurde angelegt.`,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
workingGroupId: user.workingGroupId,
|
||||
rollback: {
|
||||
kind: "user.create",
|
||||
created: snapshotUser(user)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotWorkingGroup } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
type Context = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
const workingGroupSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80)
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen AGs bearbeiten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = workingGroupSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte einen gueltigen AG-Namen angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const workingGroup = await prisma.workingGroup.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!workingGroup) {
|
||||
return NextResponse.json({ error: "AG nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingWorkingGroup = await prisma.workingGroup.findFirst({
|
||||
where: {
|
||||
id: {
|
||||
not: params.id
|
||||
},
|
||||
name: {
|
||||
equals: parsed.data.name,
|
||||
mode: "insensitive"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingWorkingGroup) {
|
||||
return NextResponse.json({ error: "Diese AG gibt es bereits." }, { status: 409 });
|
||||
}
|
||||
|
||||
const updatedWorkingGroup = await prisma.workingGroup.update({
|
||||
where: {
|
||||
id: params.id
|
||||
},
|
||||
data: {
|
||||
name: parsed.data.name
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "workingGroup.update",
|
||||
entityType: "workingGroup",
|
||||
entityId: updatedWorkingGroup.id,
|
||||
entityLabel: updatedWorkingGroup.name,
|
||||
summary: `AG ${workingGroup.name} wurde auf ${updatedWorkingGroup.name} umbenannt.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "workingGroup.update",
|
||||
previous: snapshotWorkingGroup(workingGroup),
|
||||
next: snapshotWorkingGroup(updatedWorkingGroup)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ workingGroup: updatedWorkingGroup });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen AGs loeschen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const workingGroup = await prisma.workingGroup.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
budgets: true,
|
||||
expenses: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!workingGroup) {
|
||||
return NextResponse.json({ error: "AG nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workingGroup._count.members > 0 || workingGroup._count.budgets > 0 || workingGroup._count.expenses > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Diese AG wird noch verwendet. Bitte zuerst Mitglieder, Budgets und eventuelle Ausgaben entfernen."
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.workingGroup.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "workingGroup.delete",
|
||||
entityType: "workingGroup",
|
||||
entityId: workingGroup.id,
|
||||
entityLabel: workingGroup.name,
|
||||
summary: `AG ${workingGroup.name} wurde geloescht.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "workingGroup.delete",
|
||||
deleted: snapshotWorkingGroup(workingGroup)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotWorkingGroup } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
const workingGroupSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80)
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen AGs verwalten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = workingGroupSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Bitte einen gueltigen AG-Namen angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingWorkingGroup = await prisma.workingGroup.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
equals: parsed.data.name,
|
||||
mode: "insensitive"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingWorkingGroup) {
|
||||
return NextResponse.json({ error: "Diese AG gibt es bereits." }, { status: 409 });
|
||||
}
|
||||
|
||||
const workingGroup = await prisma.workingGroup.create({
|
||||
data: {
|
||||
name: parsed.data.name
|
||||
}
|
||||
});
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "workingGroup.create",
|
||||
entityType: "workingGroup",
|
||||
entityId: workingGroup.id,
|
||||
entityLabel: workingGroup.name,
|
||||
summary: `AG ${workingGroup.name} wurde angelegt.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "workingGroup.create",
|
||||
created: snapshotWorkingGroup(workingGroup)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ workingGroup });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
background: #f5f1e8;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(242, 139, 75, 0.22), transparent 34%),
|
||||
radial-gradient(circle at right 20%, rgba(59, 90, 224, 0.2), transparent 28%),
|
||||
linear-gradient(180deg, #faf6ef 0%, #f3eee4 100%);
|
||||
color: #17203a;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background: #0b1020;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 179, 107, 0.16), transparent 30%),
|
||||
radial-gradient(circle at right 18%, rgba(140, 164, 255, 0.2), transparent 26%),
|
||||
linear-gradient(180deg, #0b1020 0%, #111a2b 100%);
|
||||
color: #e9eef9;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Space_Grotesk, Source_Sans_3 } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AppProviders } from "@/components/providers/app-providers";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const displayFont = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display"
|
||||
});
|
||||
|
||||
const bodyFont = Source_Sans_3({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-body"
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "RFP Finanz\u00fcbersicht",
|
||||
description: "Budgetkontrolle und Freigaben f\u00fcr Rave for Peace.",
|
||||
applicationName: "RFP Finanz\u00fcbersicht",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/icon-192.png", type: "image/png", sizes: "192x192" },
|
||||
{ url: "/icon-512.png", type: "image/png", sizes: "512x512" }
|
||||
],
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
shortcut: ["/favicon.ico"]
|
||||
}
|
||||
};
|
||||
|
||||
type RootLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body className={`${displayFont.variable} ${bodyFont.variable}`}>
|
||||
<AppProviders>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Box, Container, Stack, Typography } from "@mui/material";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { LoginForm } from "@/components/login-form";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LoginPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (session?.user?.id) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", px: 2 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Stack spacing={4}>
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h1" gutterBottom>
|
||||
{"RFP Finanz\u00fcbersicht"}
|
||||
</Typography>
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
{"Material-3-orientierter MVP f\u00fcr Budget\u00fcbersicht, Freigaben und Dokumentation im Verein."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LoginForm />
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "RFP Finanz\u00fcbersicht",
|
||||
short_name: "RFP Finanzen",
|
||||
description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#F5F1E8",
|
||||
theme_color: "#3B5AE0",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable"
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable"
|
||||
},
|
||||
{
|
||||
src: "/apple-touch-icon.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
|
||||
import { getCurrentAccountingPeriod } from "@/lib/accounting-periods";
|
||||
import { getRollbackMetadata } from "@/lib/audit-log";
|
||||
import type {
|
||||
DashboardAccountingPeriod,
|
||||
DashboardAuditLog,
|
||||
DashboardManagedUser,
|
||||
DashboardViewer,
|
||||
DashboardWorkingGroup
|
||||
} from "@/lib/dashboard-types";
|
||||
import { canManageUsers } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const currentPeriod = await getCurrentAccountingPeriod();
|
||||
|
||||
if (!currentPeriod) {
|
||||
throw new Error("Kein Abrechnungszeitraum gefunden.");
|
||||
}
|
||||
|
||||
const accountingPeriods = await prisma.accountingPeriod.findMany({
|
||||
orderBy: {
|
||||
startsAt: "desc"
|
||||
}
|
||||
});
|
||||
|
||||
const workingGroups = await prisma.workingGroup.findMany({
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
role: true
|
||||
}
|
||||
},
|
||||
budgets: {
|
||||
where: {
|
||||
periodId: currentPeriod.id
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc"
|
||||
},
|
||||
include: {
|
||||
expenses: {
|
||||
orderBy: {
|
||||
createdAt: "desc"
|
||||
},
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true
|
||||
}
|
||||
},
|
||||
approvals: {
|
||||
orderBy: {
|
||||
timestamp: "asc"
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc"
|
||||
}
|
||||
});
|
||||
|
||||
const managedUsers = canManageUsers(viewer.role)
|
||||
? await prisma.user.findMany({
|
||||
include: {
|
||||
workingGroup: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
approvals: true,
|
||||
createdExpenses: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
role: "asc"
|
||||
},
|
||||
{
|
||||
username: "asc"
|
||||
}
|
||||
]
|
||||
})
|
||||
: [];
|
||||
|
||||
const auditLogs = canManageUsers(viewer.role)
|
||||
? await prisma.auditLog.findMany({
|
||||
orderBy: {
|
||||
createdAt: "desc"
|
||||
},
|
||||
take: 120,
|
||||
include: {
|
||||
actor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
const serializedViewer: DashboardViewer = {
|
||||
id: viewer.id,
|
||||
name: viewer.username,
|
||||
username: viewer.username,
|
||||
role: viewer.role,
|
||||
workingGroupId: viewer.workingGroupId,
|
||||
approvalPreference: viewer.approvalPreference
|
||||
};
|
||||
|
||||
const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({
|
||||
id: workingGroup.id,
|
||||
name: workingGroup.name,
|
||||
totalBudget: workingGroup.budgets.reduce((sum, budget) => sum + Number(budget.totalBudget), 0),
|
||||
members: workingGroup.members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.username,
|
||||
username: member.username,
|
||||
role: member.role
|
||||
})),
|
||||
budgets: workingGroup.budgets.map((budget) => ({
|
||||
id: budget.id,
|
||||
name: budget.name,
|
||||
totalBudget: Number(budget.totalBudget),
|
||||
colorCode: budget.colorCode,
|
||||
periodId: budget.periodId,
|
||||
expenses: budget.expenses.map((expense) => ({
|
||||
id: expense.id,
|
||||
title: expense.title,
|
||||
description: expense.description,
|
||||
amount: Number(expense.amount),
|
||||
budgetId: expense.budgetId,
|
||||
periodId: expense.periodId,
|
||||
approvalStatus: expense.approvalStatus,
|
||||
recurrence: expense.recurrence,
|
||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
||||
proofUrl: expense.proofUrl,
|
||||
createdAt: expense.createdAt.toISOString(),
|
||||
creator: {
|
||||
id: expense.creator.id,
|
||||
name: expense.creator.username
|
||||
},
|
||||
approvals: expense.approvals.map((approval) => ({
|
||||
id: approval.id,
|
||||
approvalType: approval.approvalType,
|
||||
timestamp: approval.timestamp.toISOString(),
|
||||
user: {
|
||||
id: approval.user.id,
|
||||
name: approval.user.username
|
||||
}
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
const serializedUsers: DashboardManagedUser[] = managedUsers.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
workingGroupId: user.workingGroupId,
|
||||
workingGroupName: user.workingGroup?.name ?? null,
|
||||
approvalPreference: user.approvalPreference,
|
||||
createdExpensesCount: user._count.createdExpenses,
|
||||
approvalsCount: user._count.approvals
|
||||
}));
|
||||
|
||||
const serializedPeriods: DashboardAccountingPeriod[] = accountingPeriods.map((period) => ({
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
startsAt: period.startsAt.toISOString(),
|
||||
endsAt: period.endsAt.toISOString(),
|
||||
isCurrent: period.isCurrent
|
||||
}));
|
||||
|
||||
const serializedAuditLogs: DashboardAuditLog[] = auditLogs.map((entry) => ({
|
||||
id: entry.id,
|
||||
action: entry.action,
|
||||
entityType: entry.entityType,
|
||||
entityId: entry.entityId,
|
||||
entityLabel: entry.entityLabel,
|
||||
summary: entry.summary,
|
||||
canRestore: Boolean(getRollbackMetadata(entry.metadata)),
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
actor: entry.actor
|
||||
? {
|
||||
id: entry.actor.id,
|
||||
name: entry.actor.username,
|
||||
username: entry.actor.username,
|
||||
role: entry.actor.role
|
||||
}
|
||||
: null
|
||||
}));
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
viewer={serializedViewer}
|
||||
workingGroups={serializedGroups}
|
||||
managedUsers={serializedUsers}
|
||||
auditLogs={serializedAuditLogs}
|
||||
accountingPeriods={serializedPeriods}
|
||||
currentPeriodId={currentPeriod.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user