This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 : undefined)),
|
||||
amount: z.coerce.number().positive(),
|
||||
donatedAt: z.string().trim().transform((value) => parseDateInput(value) ?? "invalid"),
|
||||
periodId: z.string().trim().min(1),
|
||||
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 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 allgemein, AG Orga oder AG Finanzen dürfen Spenden erfassen." }, { status: 403 });
|
||||
}
|
||||
|
||||
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 period = await prisma.accountingPeriod.findUnique({
|
||||
where: { id: parsed.data.periodId }
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const expense = parsed.data.expenseId
|
||||
? await prisma.expense.findUnique({
|
||||
where: { id: parsed.data.expenseId }
|
||||
})
|
||||
: null;
|
||||
|
||||
if (parsed.data.expenseId && (!expense || expense.periodId !== period.id)) {
|
||||
return NextResponse.json({ error: "Die ausgewählte Ausgabe passt nicht zum Zeitraum." }, { status: 404 });
|
||||
}
|
||||
|
||||
const donation = {
|
||||
id: randomUUID(),
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
amount: parsed.data.amount,
|
||||
donatedAt: parsed.data.donatedAt,
|
||||
periodId: period.id,
|
||||
expenseId: expense?.id ?? null,
|
||||
creatorId: viewer.id,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO donations (id, title, description, amount, donated_at, period_id, expense_id, creator_id, created_at, updated_at)
|
||||
VALUES (${donation.id}, ${donation.title}, ${donation.description}, ${donation.amount}, ${donation.donatedAt},
|
||||
${donation.periodId}, ${donation.expenseId}, ${donation.creatorId}, ${donation.createdAt}, ${donation.createdAt})
|
||||
`;
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "donation.create",
|
||||
entityType: "donation",
|
||||
entityId: donation.id,
|
||||
entityLabel: donation.title,
|
||||
summary: `Spende ${donation.title} wurde erfasst.`,
|
||||
metadata: {
|
||||
amount: donation.amount,
|
||||
periodId: donation.periodId,
|
||||
expenseId: donation.expenseId,
|
||||
rollback: {
|
||||
kind: "donation.create",
|
||||
created: snapshotDonation(donation)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ donation });
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const expenseSchema = z
|
||||
amount: z.coerce.number().positive(),
|
||||
agId: z.string().trim().min(1),
|
||||
budgetId: z.string().trim().min(1),
|
||||
cutoffPhase: z.enum(["PRE", "POST"]).default("PRE"),
|
||||
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
|
||||
recurrenceStartAt: z
|
||||
.union([z.string().trim(), z.literal(""), z.null(), z.undefined()])
|
||||
@@ -114,6 +115,7 @@ export async function POST(request: Request) {
|
||||
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
|
||||
}
|
||||
});
|
||||
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${expense.id}`;
|
||||
|
||||
if (needsManualApproval) {
|
||||
await notifyApprovalRequest(
|
||||
@@ -140,12 +142,13 @@ export async function POST(request: Request) {
|
||||
workingGroupId: parsed.data.agId,
|
||||
recurrence: parsed.data.recurrence,
|
||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||
cutoffPhase: parsed.data.cutoffPhase,
|
||||
approvalStatus: expense.approvalStatus,
|
||||
approvalThreshold,
|
||||
requiredApprovalTypes,
|
||||
rollback: {
|
||||
kind: "expense.create",
|
||||
created: snapshotExpense(expense)
|
||||
created: snapshotExpense({ ...expense, cutoffPhase: parsed.data.cutoffPhase })
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ const CSV_HEADERS = [
|
||||
"periodStartsAt",
|
||||
"periodEndsAt",
|
||||
"periodIsCurrent",
|
||||
"cutoffName",
|
||||
"cutoffDate",
|
||||
"budgetId",
|
||||
"budgetName",
|
||||
"userId",
|
||||
@@ -41,6 +43,9 @@ const CSV_HEADERS = [
|
||||
"approvalType",
|
||||
"recurrence",
|
||||
"recurrenceStartAt",
|
||||
"cutoffPhase",
|
||||
"donatedAt",
|
||||
"expenseId",
|
||||
"invoiceDate",
|
||||
"proofUrl",
|
||||
"storedFileName",
|
||||
@@ -194,6 +199,36 @@ export async function GET() {
|
||||
createdAt: appSettings.createdAt.toISOString()
|
||||
} as CsvRow);
|
||||
|
||||
const periodCutoffs = await prisma.$queryRaw<
|
||||
{ id: string; cutoff_name: string; cutoff_date: Date | null }[]
|
||||
>`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`;
|
||||
const expenseCutoffs = await prisma.$queryRaw<
|
||||
{ id: string; cutoff_phase: "PRE" | "POST" }[]
|
||||
>`SELECT id, cutoff_phase FROM expenses`;
|
||||
const donationRows = await prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: unknown;
|
||||
donated_at: Date;
|
||||
period_id: string;
|
||||
expense_id: string | null;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
creator_username: string;
|
||||
created_at: Date;
|
||||
}[]
|
||||
>`
|
||||
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.creator_id,
|
||||
u.name AS creator_name, u.username AS creator_username, d.created_at
|
||||
FROM donations d
|
||||
JOIN users u ON u.id = d.creator_id
|
||||
ORDER BY d.donated_at ASC
|
||||
`;
|
||||
const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period]));
|
||||
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase]));
|
||||
|
||||
for (const user of users) {
|
||||
rows.push({
|
||||
recordType: "user",
|
||||
@@ -249,6 +284,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
for (const period of accountingPeriods) {
|
||||
const cutoff = periodCutoffById.get(period.id);
|
||||
rows.push({
|
||||
recordType: "period",
|
||||
id: period.id,
|
||||
@@ -261,6 +297,8 @@ export async function GET() {
|
||||
periodStartsAt: period.startsAt.toISOString(),
|
||||
periodEndsAt: period.endsAt.toISOString(),
|
||||
periodIsCurrent: period.isCurrent ? "true" : "false",
|
||||
cutoffName: cutoff?.cutoff_name ?? "Open Air",
|
||||
cutoffDate: cutoff?.cutoff_date?.toISOString() ?? "",
|
||||
budgetId: "",
|
||||
budgetName: "",
|
||||
userId: "",
|
||||
@@ -442,6 +480,7 @@ export async function GET() {
|
||||
approvalType: "",
|
||||
recurrence: expense.recurrence,
|
||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
||||
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
|
||||
invoiceDate: "",
|
||||
proofUrl: "",
|
||||
storedFileName: "",
|
||||
@@ -584,6 +623,27 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
for (const donation of donationRows) {
|
||||
rows.push({
|
||||
recordType: "donation",
|
||||
id: donation.id,
|
||||
parentId: donation.expense_id ?? donation.period_id,
|
||||
parentType: donation.expense_id ? "expense" : "period",
|
||||
periodId: donation.period_id,
|
||||
userId: donation.creator_id,
|
||||
userName: donation.creator_name,
|
||||
username: donation.creator_username,
|
||||
title: donation.title,
|
||||
description: donation.description ?? "",
|
||||
amount: Number(donation.amount).toFixed(2),
|
||||
donatedAt: donation.donated_at.toISOString(),
|
||||
expenseId: donation.expense_id ?? "",
|
||||
createdAt: donation.created_at.toISOString(),
|
||||
creatorName: donation.creator_name,
|
||||
creatorUsername: donation.creator_username
|
||||
});
|
||||
}
|
||||
|
||||
for (const auditLog of auditLogs) {
|
||||
rows.push({
|
||||
recordType: "auditLog",
|
||||
|
||||
@@ -16,7 +16,11 @@ type Context = {
|
||||
const periodSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
startsAt: z.coerce.date(),
|
||||
endsAt: z.coerce.date()
|
||||
endsAt: z.coerce.date(),
|
||||
cutoffName: z.string().trim().min(2).max(80).optional(),
|
||||
cutoffDate: z
|
||||
.union([z.coerce.date(), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (value instanceof Date ? value : null))
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: Context) {
|
||||
@@ -76,6 +80,16 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
endsAt: parsed.data.endsAt
|
||||
}
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE accounting_periods
|
||||
SET cutoff_name = ${parsed.data.cutoffName ?? "Open Air"},
|
||||
cutoff_date = ${parsed.data.cutoffDate}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
const cutoffRows = await prisma.$queryRaw<
|
||||
{ cutoff_name: string; cutoff_date: Date | null }[]
|
||||
>`SELECT cutoff_name, cutoff_date FROM accounting_periods WHERE id = ${id}`;
|
||||
const cutoff = cutoffRows[0] ?? { cutoff_name: "Open Air", cutoff_date: null };
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
@@ -90,10 +104,16 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
metadata: {
|
||||
startsAt: updatedPeriod.startsAt.toISOString(),
|
||||
endsAt: updatedPeriod.endsAt.toISOString(),
|
||||
cutoffName: cutoff.cutoff_name,
|
||||
cutoffDate: cutoff.cutoff_date?.toISOString() ?? null,
|
||||
rollback: {
|
||||
kind: "period.update",
|
||||
previous: snapshotPeriod(period),
|
||||
next: snapshotPeriod(updatedPeriod)
|
||||
next: snapshotPeriod({
|
||||
...updatedPeriod,
|
||||
cutoffName: cutoff.cutoff_name,
|
||||
cutoffDate: cutoff.cutoff_date
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,10 +78,6 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
? parsed.data.workingGroupId
|
||||
: 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 }
|
||||
|
||||
@@ -72,10 +72,6 @@ export async function POST(request: Request) {
|
||||
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 }
|
||||
|
||||
+66
-1
@@ -7,6 +7,7 @@ import { getRollbackMetadata } from "@/lib/audit-log";
|
||||
import type {
|
||||
DashboardAccountingPeriod,
|
||||
DashboardAuditLog,
|
||||
DashboardDonation,
|
||||
DashboardManagedUser,
|
||||
DashboardSettings,
|
||||
DashboardViewer,
|
||||
@@ -141,6 +142,45 @@ export default async function DashboardPage() {
|
||||
})
|
||||
: [];
|
||||
|
||||
const periodCutoffs = await prisma.$queryRaw<
|
||||
{ id: string; cutoff_name: string; cutoff_date: Date | null }[]
|
||||
>`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`;
|
||||
const expenseCutoffs = await prisma.$queryRaw<
|
||||
{ id: string; cutoff_phase: "PRE" | "POST" }[]
|
||||
>`SELECT id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
|
||||
const donationRows = await prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: unknown;
|
||||
donated_at: Date;
|
||||
period_id: string;
|
||||
expense_id: string | null;
|
||||
created_at: Date;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
}[]
|
||||
>`
|
||||
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
|
||||
FROM donations d
|
||||
JOIN users u ON u.id = d.creator_id
|
||||
WHERE d.period_id = ${currentPeriod.id}
|
||||
ORDER BY d.donated_at DESC
|
||||
`;
|
||||
const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period]));
|
||||
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase]));
|
||||
const donationsByExpenseId = new Map<string, number>();
|
||||
for (const donation of donationRows) {
|
||||
if (donation.expense_id) {
|
||||
donationsByExpenseId.set(
|
||||
donation.expense_id,
|
||||
(donationsByExpenseId.get(donation.expense_id) ?? 0) + Number(donation.amount)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const serializedViewer: DashboardViewer = {
|
||||
id: viewer.id,
|
||||
name: viewer.username,
|
||||
@@ -197,6 +237,13 @@ export default async function DashboardPage() {
|
||||
approvalStatus: expense.approvalStatus,
|
||||
recurrence: expense.recurrence,
|
||||
recurrenceStartAt,
|
||||
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
|
||||
donationAmount: donationsByExpenseId.get(expense.id) ?? 0,
|
||||
netPeriodAmount: Math.max(
|
||||
0,
|
||||
getExpensePeriodAmount(amount, expense.recurrence, occurrences.length) -
|
||||
(donationsByExpenseId.get(expense.id) ?? 0)
|
||||
),
|
||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
||||
documents: expense.documents.map((document) => ({
|
||||
@@ -249,7 +296,24 @@ export default async function DashboardPage() {
|
||||
name: period.name,
|
||||
startsAt: period.startsAt.toISOString(),
|
||||
endsAt: period.endsAt.toISOString(),
|
||||
isCurrent: period.isCurrent
|
||||
isCurrent: period.isCurrent,
|
||||
cutoffName: periodCutoffById.get(period.id)?.cutoff_name ?? "Open Air",
|
||||
cutoffDate: periodCutoffById.get(period.id)?.cutoff_date?.toISOString() ?? null
|
||||
}));
|
||||
|
||||
const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({
|
||||
id: donation.id,
|
||||
title: donation.title,
|
||||
description: donation.description,
|
||||
amount: Number(donation.amount),
|
||||
donatedAt: donation.donated_at.toISOString(),
|
||||
periodId: donation.period_id,
|
||||
expenseId: donation.expense_id,
|
||||
createdAt: donation.created_at.toISOString(),
|
||||
creator: {
|
||||
id: donation.creator_id,
|
||||
name: donation.creator_name
|
||||
}
|
||||
}));
|
||||
|
||||
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
|
||||
@@ -282,6 +346,7 @@ export default async function DashboardPage() {
|
||||
accountingPeriods={serializedPeriods}
|
||||
currentPeriodId={currentPeriod.id}
|
||||
settings={serializedSettings}
|
||||
donations={serializedDonations}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user