From 3feff2e38c66b4419b37878cee84f71dce3740d0 Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 22 Aug 2026 15:27:06 +0200 Subject: [PATCH] Eigene Ausgaben fuer Mitglieder bearbeitbar machen --- src/app/api/expenses/[id]/route.ts | 83 ++++++++++++++-------- src/components/dashboard/budget-column.tsx | 15 +++- src/lib/domain.ts | 2 +- 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts index 8545800..00855a2 100644 --- a/src/app/api/expenses/[id]/route.ts +++ b/src/app/api/expenses/[id]/route.ts @@ -33,10 +33,6 @@ export async function PATCH(request: Request, { params }: Context) { 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); @@ -52,6 +48,35 @@ export async function PATCH(request: Request, { params }: Context) { return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); } + const previousCutoffRows = await prisma.$queryRaw<{ cutoff_id: string | null; cutoff_phase: "PRE" | "POST" }[]>` + SELECT cutoff_id, cutoff_phase FROM expenses WHERE id = ${id} + `; + const previousCutoff = previousCutoffRows[0] ?? { + cutoff_id: null, + cutoff_phase: "PRE" as const + }; + const isAdminUpdate = hasAdministrativeAccess(viewer.role); + const isOwnEditableExpense = viewer.id === expense.creatorId && !expense.paidAt && !expense.documentedAt; + + if (!isAdminUpdate && !isOwnEditableExpense) { + return NextResponse.json( + { error: "Du darfst nur eigene, noch nicht bezahlte oder dokumentierte Ausgaben bearbeiten." }, + { status: 403 } + ); + } + + if (!isAdminUpdate && parsed.data.agId !== expense.agId) { + return NextResponse.json({ error: "Mitglieder dürfen eigene Ausgaben nicht in eine andere AG verschieben." }, { status: 403 }); + } + + if ( + !isAdminUpdate && + ((parsed.data.cutoffId ?? previousCutoff.cutoff_id) !== previousCutoff.cutoff_id || + parsed.data.cutoffPhase !== previousCutoff.cutoff_phase) + ) { + return NextResponse.json({ error: "Mitglieder dürfen die Stichtag-Zuordnung nicht ändern." }, { status: 403 }); + } + const budget = await prisma.budget.findUnique({ where: { id: parsed.data.budgetId } }); @@ -60,30 +85,33 @@ export async function PATCH(request: Request, { params }: Context) { return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 }); } - const cutoffRows = await prisma.$queryRaw<{ id: string }[]>` - SELECT id FROM period_cutoffs - WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId} - `; - const fallbackCutoffRows = parsed.data.cutoffId - ? [] - : await prisma.$queryRaw<{ id: string }[]>` - SELECT id FROM period_cutoffs - WHERE period_id = ${expense.periodId} - ORDER BY date ASC NULLS LAST, created_at ASC - LIMIT 1 - `; - const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null; + let cutoffId = previousCutoff.cutoff_id; + let cutoffPhase = previousCutoff.cutoff_phase; - if (parsed.data.cutoffId && !cutoffId) { - return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 }); + if (isAdminUpdate) { + const cutoffRows = await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM period_cutoffs + WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId} + `; + const fallbackCutoffRows = parsed.data.cutoffId + ? [] + : await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM period_cutoffs + WHERE period_id = ${expense.periodId} + ORDER BY date ASC NULLS LAST, created_at ASC + LIMIT 1 + `; + cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null; + cutoffPhase = parsed.data.cutoffPhase; + + if (parsed.data.cutoffId && !cutoffId) { + return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht 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" + cutoffPhase: previousCutoff.cutoff_phase }); const updatedExpense = await prisma.expense.update({ @@ -98,7 +126,7 @@ export async function PATCH(request: Request, { params }: Context) { }); await prisma.$executeRaw` UPDATE expenses - SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" + SET cutoff_id = ${cutoffId}, cutoff_phase = ${cutoffPhase}::"CutoffPhase" WHERE id = ${id} `; @@ -113,11 +141,11 @@ export async function PATCH(request: Request, { params }: Context) { amount: Number(updatedExpense.amount), budgetId: updatedExpense.budgetId, workingGroupId: updatedExpense.agId, - cutoffPhase: parsed.data.cutoffPhase, + cutoffPhase, rollback: { kind: "expense.update", previous: previousSnapshot, - next: snapshotExpense({ ...updatedExpense, cutoffPhase: parsed.data.cutoffPhase }) + next: snapshotExpense({ ...updatedExpense, cutoffPhase }) } } }); @@ -147,13 +175,12 @@ export async function DELETE(_: Request, { params }: Context) { const isAdminDelete = hasAdministrativeAccess(viewer.role); const isOwnPendingExpense = viewer.id === expense.creatorId && - expense.approvalStatus === "PENDING" && !expense.paidAt && !expense.documentedAt; if (!isAdminDelete && !isOwnPendingExpense) { return NextResponse.json( - { error: "Du darfst nur eigene ungeprüfte Ausgaben löschen." }, + { error: "Du darfst nur eigene, noch nicht bezahlte oder dokumentierte Ausgaben löschen." }, { status: 403 } ); } diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 02b0d2d..3826cf6 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -308,7 +308,6 @@ export function BudgetColumn({ group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap; const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372); const canEditBudgets = canManageBudgets(viewer.role); - const canEditExpenses = canManageBudgets(viewer.role); const canEditDonations = viewer.role === "ORGA" || viewer.role === "FINANCE"; useEffect(() => { @@ -394,6 +393,14 @@ export function BudgetColumn({ })); } + function canEditExpense(expense: DashboardExpense) { + if (canManageBudgets(viewer.role)) { + return true; + } + + return expense.creator.id === viewer.id && !expense.paidAt && !expense.documentedAt; + } + function getAssignedDonationDraft(expense: DashboardExpense, donation: DashboardExpenseDonation): AssignedDonationDraft { return assignedDonationDrafts[donation.id] ?? { title: donation.title, @@ -1094,7 +1101,7 @@ export function BudgetColumn({ {isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"} - {canEditExpenses ? ( + {canEditExpense(expense) ? (