This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotDonation } 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: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type DonationRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: unknown;
|
||||
donated_at: Date;
|
||||
period_id: string;
|
||||
expense_id: string | null;
|
||||
creator_id: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
function parseDateInput(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day] = match;
|
||||
const parsed = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0, 0));
|
||||
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function normalizeDonation(row: DonationRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
amount: row.amount,
|
||||
donatedAt: row.donated_at,
|
||||
periodId: row.period_id,
|
||||
expenseId: row.expense_id,
|
||||
creatorId: row.creator_id,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
async function getDonation(id: string) {
|
||||
const rows = await prisma.$queryRaw<DonationRow[]>`
|
||||
SELECT id, title, description, amount, donated_at, period_id, expense_id, creator_id, created_at
|
||||
FROM donations
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
const donationSchema = 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 : null)),
|
||||
amount: z.coerce.number().positive(),
|
||||
donatedAt: z.string().trim().transform((value) => parseDateInput(value) ?? "invalid"),
|
||||
expenseId: z
|
||||
.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null))
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.donatedAt === "invalid") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Bitte ein gültiges Spendendatum angeben.",
|
||||
path: ["donatedAt"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden bearbeiten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const donation = await getDonation(id);
|
||||
|
||||
if (!donation) {
|
||||
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = donationSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success || !(parsed.data.donatedAt instanceof Date)) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.success ? "Bitte Spendendaten korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Spendendaten korrekt ausfüllen." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const expense = parsed.data.expenseId
|
||||
? await prisma.expense.findUnique({
|
||||
where: { id: parsed.data.expenseId }
|
||||
})
|
||||
: null;
|
||||
|
||||
if (parsed.data.expenseId && (!expense || expense.periodId !== donation.period_id)) {
|
||||
return NextResponse.json({ error: "Die ausgewählte Ausgabe passt nicht zum Zeitraum der Spende." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.$executeRaw`
|
||||
UPDATE donations
|
||||
SET title = ${parsed.data.title},
|
||||
description = ${parsed.data.description},
|
||||
amount = ${parsed.data.amount},
|
||||
donated_at = ${parsed.data.donatedAt},
|
||||
expense_id = ${expense?.id ?? null},
|
||||
updated_at = ${new Date()}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
const updatedDonation = await getDonation(id);
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "donation.update",
|
||||
entityType: "donation",
|
||||
entityId: id,
|
||||
entityLabel: parsed.data.title,
|
||||
summary: `Spende ${parsed.data.title} wurde bearbeitet.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "donation.update",
|
||||
previous: snapshotDonation(normalizeDonation(donation)),
|
||||
next: updatedDonation ? snapshotDonation(normalizeDonation(updatedDonation)) : null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden löschen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const donation = await getDonation(id);
|
||||
|
||||
if (!donation) {
|
||||
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.$executeRaw`DELETE FROM donations WHERE id = ${id}`;
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "donation.delete",
|
||||
entityType: "donation",
|
||||
entityId: id,
|
||||
entityLabel: donation.title,
|
||||
summary: `Spende ${donation.title} wurde gelöscht.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "donation.delete",
|
||||
deleted: snapshotDonation(normalizeDonation(donation))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
@@ -12,6 +13,95 @@ type Context = {
|
||||
}>;
|
||||
};
|
||||
|
||||
const updateExpenseSchema = 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),
|
||||
cutoffPhase: z.enum(["PRE", "POST"])
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!hasAdministrativeAccess(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Ausgaben bearbeiten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = updateExpenseSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Bitte Ausgabendaten korrekt ausfüllen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const expense = await prisma.expense.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const budget = await prisma.budget.findUnique({
|
||||
where: { id: parsed.data.budgetId }
|
||||
});
|
||||
|
||||
if (!budget || budget.workingGroupId !== parsed.data.agId || budget.periodId !== expense.periodId) {
|
||||
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 });
|
||||
}
|
||||
|
||||
const previousCutoffRows = await prisma.$queryRaw<{ cutoff_phase: "PRE" | "POST" }[]>`
|
||||
SELECT cutoff_phase FROM expenses WHERE id = ${id}
|
||||
`;
|
||||
const previousSnapshot = snapshotExpense({
|
||||
...expense,
|
||||
cutoffPhase: previousCutoffRows[0]?.cutoff_phase ?? "PRE"
|
||||
});
|
||||
|
||||
const updatedExpense = await prisma.expense.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
amount: parsed.data.amount,
|
||||
agId: parsed.data.agId,
|
||||
budgetId: parsed.data.budgetId
|
||||
}
|
||||
});
|
||||
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${id}`;
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "expense.update",
|
||||
entityType: "expense",
|
||||
entityId: updatedExpense.id,
|
||||
entityLabel: updatedExpense.title,
|
||||
summary: `Ausgabe ${updatedExpense.title} wurde bearbeitet.`,
|
||||
metadata: {
|
||||
amount: Number(updatedExpense.amount),
|
||||
budgetId: updatedExpense.budgetId,
|
||||
workingGroupId: updatedExpense.agId,
|
||||
cutoffPhase: parsed.data.cutoffPhase,
|
||||
rollback: {
|
||||
kind: "expense.update",
|
||||
previous: previousSnapshot,
|
||||
next: snapshotExpense({ ...updatedExpense, cutoffPhase: parsed.data.cutoffPhase })
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ expense: updatedExpense });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
+10
-1
@@ -160,12 +160,18 @@ export default async function DashboardPage() {
|
||||
created_at: Date;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
working_group_id: string | null;
|
||||
working_group_name: string | null;
|
||||
expense_title: string | null;
|
||||
}[]
|
||||
>`
|
||||
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.created_at,
|
||||
u.id AS creator_id, u.username AS creator_name
|
||||
u.id AS creator_id, u.username AS creator_name,
|
||||
wg.id AS working_group_id, wg.name AS working_group_name, e.title AS expense_title
|
||||
FROM donations d
|
||||
JOIN users u ON u.id = d.creator_id
|
||||
LEFT JOIN expenses e ON e.id = d.expense_id
|
||||
LEFT JOIN working_groups wg ON wg.id = e.ag_id
|
||||
WHERE d.period_id = ${currentPeriod.id}
|
||||
ORDER BY d.donated_at DESC
|
||||
`;
|
||||
@@ -309,6 +315,9 @@ export default async function DashboardPage() {
|
||||
donatedAt: donation.donated_at.toISOString(),
|
||||
periodId: donation.period_id,
|
||||
expenseId: donation.expense_id,
|
||||
workingGroupId: donation.working_group_id,
|
||||
workingGroupName: donation.working_group_name,
|
||||
expenseTitle: donation.expense_title,
|
||||
createdAt: donation.created_at.toISOString(),
|
||||
creator: {
|
||||
id: donation.creator_id,
|
||||
|
||||
Reference in New Issue
Block a user