From 6dec4b8a1034e80251ef67c289d2f26f4be7279f Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 6 May 2026 00:11:33 +0200 Subject: [PATCH] UI Push Deep Links und Drive Diagnose verbessern --- README.md | 2 +- public/sw.js | 19 +- src/app/api/budgets/[id]/route.ts | 12 +- src/app/api/budgets/route.ts | 6 +- src/app/api/expenses/[id]/approve/route.ts | 2 +- src/app/api/expenses/[id]/documented/route.ts | 2 +- src/app/api/expenses/[id]/paid/route.ts | 2 +- src/app/api/expenses/[id]/proof/route.ts | 21 +- src/app/api/expenses/[id]/route.ts | 2 +- src/app/api/expenses/route.ts | 3 +- src/app/api/export/csv/route.ts | 2 +- src/app/api/import/csv/route.ts | 2 +- src/app/api/push-subscriptions/route.ts | 4 +- .../api/settings/drive-diagnostics/route.ts | 26 ++ src/app/api/settings/route.ts | 4 +- src/app/api/users/[id]/route.ts | 4 +- src/app/api/users/route.ts | 2 +- src/app/api/working-groups/[id]/route.ts | 4 +- src/app/api/working-groups/route.ts | 2 +- src/components/dashboard/budget-column.tsx | 8 +- src/components/dashboard/dashboard-shell.tsx | 177 ++++++++++-- src/lib/google-drive.ts | 261 +++++++++++++++++- src/lib/push-notifications.ts | 5 +- 23 files changed, 491 insertions(+), 81 deletions(-) create mode 100644 src/app/api/settings/drive-diagnostics/route.ts diff --git a/README.md b/README.md index ea0466b..80bbd6f 100644 --- a/README.md +++ b/README.md @@ -39,5 +39,5 @@ Der Seed legt die Grundeinstellungen, den aktiven Zeitraum, AGs, Budgets und Bas ## Hinweise - Für Web Push müssen `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` und `VAPID_SUBJECT` gesetzt sein. -- Für Beleg-Uploads müssen `GOOGLE_DRIVE_FOLDER_ID`, `GOOGLE_SERVICE_ACCOUNT_EMAIL` und `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` gesetzt sein. Der Drive-Ordner muss für die Service-Account-Mail freigegeben sein. +- Für Beleg-Uploads müssen `GOOGLE_DRIVE_FOLDER_ID`, `GOOGLE_SERVICE_ACCOUNT_EMAIL` und `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` gesetzt sein. Der Drive-Ordner muss für die Service-Account-Mail freigegeben sein. AG Orga kann die Verbindung in den Einstellungen mit „Drive-Verbindung testen“ prüfen; die App zeigt dabei konkrete Fehler zu Key-Format, Authentifizierung, Ordnerzugriff und Schreibrechten. - Für Produktion sollten `NEXTAUTH_SECRET`, Datenbank-Zugangsdaten und Reverse-Proxy/SSL sauber gesetzt werden. diff --git a/public/sw.js b/public/sw.js index 43565bf..29153b0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -62,18 +62,27 @@ self.addEventListener("push", (event) => { self.addEventListener("notificationclick", (event) => { event.notification.close(); - const targetUrl = new URL(event.notification.data?.url || "/", self.location.origin).href; + const targetUrl = new URL(event.notification.data?.url || "/", self.location.origin); event.waitUntil( - clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => { for (const client of clientList) { - if ("focus" in client) { - client.navigate(targetUrl); + const clientUrl = new URL(client.url); + + if (clientUrl.origin === targetUrl.origin && "focus" in client) { + if ("navigate" in client) { + await client.navigate(targetUrl.href).catch(() => null); + } + return client.focus(); } } - return clients.openWindow(targetUrl); + if (clients.openWindow) { + return clients.openWindow(targetUrl.href); + } + + return undefined; }) ); }); diff --git a/src/app/api/budgets/[id]/route.ts b/src/app/api/budgets/[id]/route.ts index 3abe2b4..f76656e 100644 --- a/src/app/api/budgets/[id]/route.ts +++ b/src/app/api/budgets/[id]/route.ts @@ -21,7 +21,7 @@ const updateBudgetSchema = z if (value.releasedAmount !== undefined && value.releasedAmount > value.totalBudget) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Der bereits an die AG uebergebene Betrag darf das Budget nicht uebersteigen.", + message: "Der bereits an die AG übergebene Betrag darf das Budget nicht übersteigen.", path: ["releasedAmount"] }); } @@ -42,7 +42,7 @@ export async function PATCH(request: Request, { params }: Context) { } if (!canManageBudgets(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Budgets aendern." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Budgets ändern." }, { status: 403 }); } const budget = await prisma.budget.findUnique({ @@ -65,7 +65,7 @@ export async function PATCH(request: Request, { params }: Context) { const parsed = updateBudgetSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "Budgetname, Betrag, Mitteluebergabe oder Farbe sind ungueltig." }, { status: 400 }); + return NextResponse.json({ error: "Budgetname, Betrag, Mittelübergabe oder Farbe sind ungültig." }, { status: 400 }); } try { @@ -140,7 +140,7 @@ export async function DELETE(_: Request, { params }: Context) { } if (!canManageBudgets(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Budgets loeschen." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Budgets löschen." }, { status: 403 }); } const budget = await prisma.budget.findUnique({ @@ -160,7 +160,7 @@ export async function DELETE(_: Request, { params }: Context) { if (budget._count.expenses > 0) { return NextResponse.json( - { error: "Dieses Budget enthaelt noch Ausgaben. Bitte loesche oder verschiebe erst die Posten." }, + { error: "Dieses Budget enthält noch Ausgaben. Bitte lösche oder verschiebe erst die Posten." }, { status: 400 } ); } @@ -175,7 +175,7 @@ export async function DELETE(_: Request, { params }: Context) { entityType: "budget", entityId: budget.id, entityLabel: budget.name, - summary: `Budget ${budget.name} wurde geloescht.`, + summary: `Budget ${budget.name} wurde gelöscht.`, metadata: { rollback: { kind: "budget.delete", diff --git a/src/app/api/budgets/route.ts b/src/app/api/budgets/route.ts index 43bcc76..ebdedf5 100644 --- a/src/app/api/budgets/route.ts +++ b/src/app/api/budgets/route.ts @@ -20,7 +20,7 @@ const budgetSchema = z if (value.releasedAmount !== undefined && value.releasedAmount > value.totalBudget) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Der bereits an die AG uebergebene Betrag darf das Budget nicht uebersteigen.", + message: "Der bereits an die AG übergebene Betrag darf das Budget nicht übersteigen.", path: ["releasedAmount"] }); } @@ -34,7 +34,7 @@ export async function POST(request: Request) { } if (!canManageBudgets(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Budgets verwalten." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Budgets verwalten." }, { status: 403 }); } const body = await request.json().catch(() => null); @@ -42,7 +42,7 @@ export async function POST(request: Request) { if (!parsed.success) { return NextResponse.json( - { error: "Bitte AG, Budgetname, Betrag, Mitteluebergabe und Farbe korrekt angeben." }, + { error: "Bitte AG, Budgetname, Betrag, Mittelübergabe und Farbe korrekt angeben." }, { status: 400 } ); } diff --git a/src/app/api/expenses/[id]/approve/route.ts b/src/app/api/expenses/[id]/approve/route.ts index 9a4d836..49356bf 100644 --- a/src/app/api/expenses/[id]/approve/route.ts +++ b/src/app/api/expenses/[id]/approve/route.ts @@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Context) { const parsed = approvalSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "Freigabetyp ungueltig." }, { status: 400 }); + return NextResponse.json({ error: "Freigabetyp ungültig." }, { status: 400 }); } const existingApprovals = expense.approvals.map((approval) => approval.approvalType); diff --git a/src/app/api/expenses/[id]/documented/route.ts b/src/app/api/expenses/[id]/documented/route.ts index 6ac45bb..66ecade 100644 --- a/src/app/api/expenses/[id]/documented/route.ts +++ b/src/app/api/expenses/[id]/documented/route.ts @@ -20,7 +20,7 @@ export async function POST(_: Request, { params }: Context) { } if (!canDocumentExpense(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen dokumentieren." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen dokumentieren." }, { status: 403 }); } const expense = await prisma.expense.findUnique({ diff --git a/src/app/api/expenses/[id]/paid/route.ts b/src/app/api/expenses/[id]/paid/route.ts index 1674e99..0b7d47c 100644 --- a/src/app/api/expenses/[id]/paid/route.ts +++ b/src/app/api/expenses/[id]/paid/route.ts @@ -20,7 +20,7 @@ export async function POST(_: Request, { params }: Context) { } if (!canMarkPaid(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Bezahlt setzen." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Bezahlt setzen." }, { status: 403 }); } const expense = await prisma.expense.findUnique({ diff --git a/src/app/api/expenses/[id]/proof/route.ts b/src/app/api/expenses/[id]/proof/route.ts index 19d3d92..4f95dfb 100644 --- a/src/app/api/expenses/[id]/proof/route.ts +++ b/src/app/api/expenses/[id]/proof/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { createAuditLog } from "@/lib/audit-log"; import { canDocumentExpense } from "@/lib/domain"; -import { uploadExpenseProofToDrive } from "@/lib/google-drive"; +import { serializeDriveError, uploadExpenseProofToDrive } from "@/lib/google-drive"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; @@ -48,11 +48,11 @@ export async function POST(request: Request, { params }: Context) { } if (expense.creatorId !== viewer.id && !canDocumentExpense(viewer.role)) { - return NextResponse.json({ error: "Du darfst fuer diese Ausgabe keinen Beleg hochladen." }, { status: 403 }); + return NextResponse.json({ error: "Du darfst für diese Ausgabe keinen Beleg hochladen." }, { status: 403 }); } if (expense.approvalStatus !== "APPROVED") { - return NextResponse.json({ error: "Belegabgabe ist erst nach Freigabe moeglich." }, { status: 400 }); + return NextResponse.json({ error: "Belegabgabe ist erst nach Freigabe möglich." }, { status: 400 }); } const formData = await request.formData().catch(() => null); @@ -60,11 +60,11 @@ export async function POST(request: Request, { params }: Context) { const invoiceDate = parseInvoiceDate(formData?.get("invoiceDate") ?? null); if (!invoiceDate) { - return NextResponse.json({ error: "Bitte ein gueltiges Rechnungsdatum angeben." }, { status: 400 }); + return NextResponse.json({ error: "Bitte ein gültiges Rechnungsdatum angeben." }, { status: 400 }); } if (!(file instanceof File)) { - return NextResponse.json({ error: "Bitte einen Beleg als Bild oder PDF auswaehlen." }, { status: 400 }); + return NextResponse.json({ error: "Bitte einen Beleg als Bild oder PDF auswählen." }, { status: 400 }); } if (!ACCEPTED_MIME_TYPES.has(file.type)) { @@ -72,7 +72,7 @@ export async function POST(request: Request, { params }: Context) { } if (file.size > MAX_FILE_SIZE) { - return NextResponse.json({ error: "Der Beleg darf maximal 12 MB gross sein." }, { status: 400 }); + return NextResponse.json({ error: "Der Beleg darf maximal 12 MB groß sein." }, { status: 400 }); } const uploadedFile = await uploadExpenseProofToDrive({ @@ -82,8 +82,15 @@ export async function POST(request: Request, { params }: Context) { fileName: file.name, mimeType: file.type, buffer: Buffer.from(await file.arrayBuffer()) + }).catch((error) => { + const serialized = serializeDriveError(error); + return NextResponse.json(serialized, { status: serialized.status ?? 500 }); }); + if (uploadedFile instanceof NextResponse) { + return uploadedFile; + } + const now = new Date(); const transactionResult = await prisma.$transaction(async (tx) => { const document = await tx.expenseDocument.create({ @@ -125,7 +132,7 @@ export async function POST(request: Request, { params }: Context) { entityType: "expense", entityId: transactionResult.updatedExpense.id, entityLabel: transactionResult.updatedExpense.title, - summary: `Rechnung fuer ${transactionResult.updatedExpense.title} wurde abgegeben.`, + summary: `Rechnung für ${transactionResult.updatedExpense.title} wurde abgegeben.`, metadata: { documentId: transactionResult.document.id, proofUrl: transactionResult.document.proofUrl, diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts index 75492c7..e51f7b1 100644 --- a/src/app/api/expenses/[id]/route.ts +++ b/src/app/api/expenses/[id]/route.ts @@ -55,7 +55,7 @@ export async function DELETE(_: Request, { params }: Context) { entityType: "expense", entityId: expense.id, entityLabel: expense.title, - summary: `Ausgabe ${expense.title} wurde geloescht.`, + summary: `Ausgabe ${expense.title} wurde gelöscht.`, metadata: { rollback: { kind: "expense.delete", diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index ee44602..c75098e 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -120,7 +120,8 @@ export async function POST(request: Request) { { id: expense.id, title: expense.title, - amount: Number(expense.amount) + amount: Number(expense.amount), + workingGroupId: expense.agId }, requiredApprovalTypes ); diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index a0c335a..a64dfed 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -74,7 +74,7 @@ export async function GET() { } if (!canManageUsers(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen CSV-Backups herunterladen." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen CSV-Backups herunterladen." }, { status: 403 }); } const [appSettings, users, accountingPeriods, workingGroups, auditLogs] = await Promise.all([ diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index 255ceb2..6a33c38 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -73,7 +73,7 @@ export async function POST(request: Request) { } if (!canManageUsers(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Backups einspielen." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Backups einspielen." }, { status: 403 }); } const formData = await request.formData().catch(() => null); diff --git a/src/app/api/push-subscriptions/route.ts b/src/app/api/push-subscriptions/route.ts index e94a719..92c2676 100644 --- a/src/app/api/push-subscriptions/route.ts +++ b/src/app/api/push-subscriptions/route.ts @@ -23,7 +23,7 @@ export async function POST(request: Request) { const parsed = subscriptionSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "Push-Subscription ist ungueltig." }, { status: 400 }); + return NextResponse.json({ error: "Push-Subscription ist ungültig." }, { status: 400 }); } await prisma.pushSubscription.upsert({ @@ -57,7 +57,7 @@ export async function DELETE(request: Request) { const parsed = z.object({ endpoint: z.string().url() }).safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "Push-Subscription ist ungueltig." }, { status: 400 }); + return NextResponse.json({ error: "Push-Subscription ist ungültig." }, { status: 400 }); } await prisma.pushSubscription.deleteMany({ diff --git a/src/app/api/settings/drive-diagnostics/route.ts b/src/app/api/settings/drive-diagnostics/route.ts new file mode 100644 index 0000000..17b62ef --- /dev/null +++ b/src/app/api/settings/drive-diagnostics/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +import { canManageSettings } from "@/lib/domain"; +import { runDriveDiagnostics, serializeDriveError } from "@/lib/google-drive"; +import { getCurrentViewer } from "@/lib/session"; + +export async function POST() { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageSettings(viewer.role)) { + return NextResponse.json({ error: "Nur AG Orga darf die Drive-Verbindung testen." }, { status: 403 }); + } + + try { + const result = await runDriveDiagnostics(); + return NextResponse.json(result); + } catch (error) { + const serialized = serializeDriveError(error); + + return NextResponse.json(serialized, { status: serialized.status ?? 500 }); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 47a64d9..f3849f7 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -22,14 +22,14 @@ export async function PATCH(request: Request) { } if (!canManageUsers(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Einstellungen aendern." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Einstellungen ändern." }, { status: 403 }); } const body = await request.json().catch(() => null); const parsed = settingsSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "Bitte gueltige Einstellungen eingeben." }, { status: 400 }); + return NextResponse.json({ error: "Bitte gültige Einstellungen eingeben." }, { status: 400 }); } const changesOrgaSettings = diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 7b24bc9..e4999c0 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -56,7 +56,7 @@ export async function PATCH(request: Request, { params }: Context) { } if (!canManageUsers(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Nutzer bearbeiten." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Nutzer bearbeiten." }, { status: 403 }); } const body = await request.json().catch(() => null); @@ -215,7 +215,7 @@ export async function DELETE(_: Request, { params }: Context) { entityType: "user", entityId: user.id, entityLabel: user.username, - summary: `Nutzer ${user.username} wurde geloescht.`, + summary: `Nutzer ${user.username} wurde gelöscht.`, metadata: { rollback: { kind: "user.delete", diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 748a86a..67c3bf8 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -52,7 +52,7 @@ export async function POST(request: Request) { } if (!canManageUsers(viewer.role)) { - return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Nutzer anlegen." }, { status: 403 }); + return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Nutzer anlegen." }, { status: 403 }); } const body = await request.json().catch(() => null); diff --git a/src/app/api/working-groups/[id]/route.ts b/src/app/api/working-groups/[id]/route.ts index fe21b8e..3d4050e 100644 --- a/src/app/api/working-groups/[id]/route.ts +++ b/src/app/api/working-groups/[id]/route.ts @@ -33,7 +33,7 @@ export async function PATCH(request: Request, { params }: Context) { const parsed = workingGroupSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "Bitte einen gueltigen AG-Namen angeben." }, { status: 400 }); + return NextResponse.json({ error: "Bitte einen gültigen AG-Namen angeben." }, { status: 400 }); } const workingGroup = await prisma.workingGroup.findUnique({ @@ -137,7 +137,7 @@ export async function DELETE(_: Request, { params }: Context) { entityType: "workingGroup", entityId: workingGroup.id, entityLabel: workingGroup.name, - summary: `AG ${workingGroup.name} wurde geloescht.`, + summary: `AG ${workingGroup.name} wurde gelöscht.`, metadata: { rollback: { kind: "workingGroup.delete", diff --git a/src/app/api/working-groups/route.ts b/src/app/api/working-groups/route.ts index 54ce252..a283a01 100644 --- a/src/app/api/working-groups/route.ts +++ b/src/app/api/working-groups/route.ts @@ -26,7 +26,7 @@ export async function POST(request: Request) { const parsed = workingGroupSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "Bitte einen gueltigen AG-Namen angeben." }, { status: 400 }); + return NextResponse.json({ error: "Bitte einen gültigen AG-Namen angeben." }, { status: 400 }); } const existingWorkingGroup = await prisma.workingGroup.findFirst({ diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 4cc877b..238f8b1 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -260,7 +260,7 @@ export function BudgetColumn({ flexShrink: 0, backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.94 : 0.98), backgroundImage: "none", - touchAction: "pan-y" + touchAction: "pan-x pan-y" }} > @@ -688,7 +688,7 @@ export function BudgetColumn({ expense.approvalStatus === "APPROVED" ? alpha(budget.colorCode, isDark ? 0.16 : 0.08) : alpha(budget.colorCode, isDark ? 0.1 : 0.04), - touchAction: "pan-y" + touchAction: "pan-x pan-y" }} > @@ -834,7 +834,7 @@ export function BudgetColumn({ onClick={() => { if ( !window.confirm( - `Freigabe wirklich setzen?\n\nAusgabe: ${expense.title}\nBetrag: ${formatCurrency(expense.amount)}\nRolle: ${approvalLabel(approvalType)}\n\nMit deiner Freigabe bestaetigst du, dass du die Ausgabe plausibel geprueft hast und die Verantwortung fuer diesen Freigabeschritt uebernimmst.` + `Freigabe wirklich setzen?\n\nAusgabe: ${expense.title}\nBetrag: ${formatCurrency(expense.amount)}\nRolle: ${approvalLabel(approvalType)}\n\nMit deiner Freigabe bestätigst du, dass du die Ausgabe plausibel geprüft hast und die Verantwortung für diesen Freigabeschritt übernimmst.` ) ) { return; @@ -857,7 +857,7 @@ export function BudgetColumn({ onClick={() => { if ( !window.confirm( - `Ausgabe "${expense.title}" ohne Rechnung als bezahlt markieren?\n\nNutze das nur fuer Nachtragungen.` + `Ausgabe "${expense.title}" ohne Rechnung als bezahlt markieren?\n\nNutze das nur für Nachtragungen.` ) ) { return; diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 870ef23..431346c 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -37,7 +37,7 @@ import { } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import { signOut } from "next-auth/react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import type { FormEvent } from "react"; import { startTransition, useEffect, useMemo, useState } from "react"; @@ -134,6 +134,15 @@ type DashboardMessage = { text: string; }; +type DriveDiagnosticResult = { + ok?: boolean; + error?: string; + code?: string; + details?: string[]; + folderId?: string; + serviceAccountEmail?: string; +}; + function sortApprovalPermissions(value: ApprovalPermissionValue[]) { return APPROVAL_FLOW.filter((approvalType) => value.includes(approvalType)); } @@ -243,7 +252,11 @@ async function parseResponse(response: Response) { const payload = await response.json().catch(() => null); if (!response.ok) { - throw new Error(payload?.error ?? "Die Anfrage konnte nicht verarbeitet werden."); + const detailText = Array.isArray(payload?.details) && payload.details.length > 0 + ? `\n\n${payload.details.map((detail: string) => `- ${detail}`).join("\n")}` + : ""; + const codeText = typeof payload?.code === "string" ? `\nCode: ${payload.code}` : ""; + throw new Error(`${payload?.error ?? "Die Anfrage konnte nicht verarbeitet werden."}${codeText}${detailText}`); } return payload; @@ -269,6 +282,7 @@ export function DashboardShell({ const isDark = theme.palette.mode === "dark"; const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg")); const router = useRouter(); + const searchParams = useSearchParams(); const visibleGroups = workingGroups; const editableExpenseGroups = @@ -325,10 +339,11 @@ export function DashboardShell({ }); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); - const [mobileSection, setMobileSection] = useState("overview"); - const [desktopSection, setDesktopSection] = useState("overview"); - const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); - const [backupFile, setBackupFile] = useState(null); + const [mobileSection, setMobileSection] = useState("overview"); + const [desktopSection, setDesktopSection] = useState("overview"); + const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); + const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? ""); + const [backupFile, setBackupFile] = useState(null); const [editingPasswordUserId, setEditingPasswordUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null); const [passwordDrafts, setPasswordDrafts] = useState>({}); @@ -336,6 +351,7 @@ export function DashboardShell({ const [userDrafts, setUserDrafts] = useState>({}); const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false); + const [driveDiagnosticResult, setDriveDiagnosticResult] = useState(null); const [orgaSettingsDraft, setOrgaSettingsDraft] = useState({ requiredApprovalTypes: settings.requiredApprovalTypes, budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget @@ -359,6 +375,30 @@ export function DashboardShell({ } }, [desktopSection, desktopSections]); + useEffect(() => { + const directGroupId = searchParams.get("group"); + const budgetId = searchParams.get("budget"); + const expenseId = searchParams.get("expense"); + const targetGroupId = + (directGroupId && visibleGroups.some((group) => group.id === directGroupId) ? directGroupId : null) ?? + visibleGroups.find((group) => budgetId && group.budgets.some((budget) => budget.id === budgetId))?.id ?? + visibleGroups.find((group) => + expenseId && group.budgets.some((budget) => budget.expenses.some((expense) => expense.id === expenseId)) + )?.id ?? + null; + + if (targetGroupId) { + setSelectedMobileGroupId(targetGroupId); + setMobileSection("overview"); + setDesktopSection("overview"); + return; + } + + if (!visibleGroups.some((group) => group.id === selectedMobileGroupId)) { + setSelectedMobileGroupId(visibleGroups[0]?.id ?? ""); + } + }, [searchParams, selectedMobileGroupId, visibleGroups]); + useEffect(() => { if (visibleGroups.length === 0) { setBudgetForm((current) => ({ @@ -1050,7 +1090,7 @@ export function DashboardShell({ if (!Number.isFinite(nextThreshold) || nextThreshold < 0) { setMessage({ type: "error", - text: "Bitte eine gueltige Freigabe-Schwelle eingeben." + text: "Bitte eine gültige Freigabe-Schwelle eingeben." }); return; } @@ -1091,10 +1131,33 @@ export function DashboardShell({ }, "Zust\u00e4ndigkeiten und Benachrichtigungen wurden gespeichert."); } + async function handleRunDriveDiagnostics() { + setBusy(true); + setDriveDiagnosticResult(null); + setMessage(null); + + try { + const result = (await parseResponse( + await fetch("/api/settings/drive-diagnostics", { + method: "POST" + }) + )) as DriveDiagnosticResult; + + setDriveDiagnosticResult(result); + setMessage({ type: "success", text: "Drive-Verbindung erfolgreich getestet." }); + } catch (error) { + const text = error instanceof Error ? error.message : "Drive-Verbindungstest fehlgeschlagen."; + setDriveDiagnosticResult({ ok: false, error: text }); + setMessage({ type: "error", text }); + } finally { + setBusy(false); + } + } + async function handleEnablePushNotifications() { if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) { setPushStatus("unsupported"); - setMessage({ type: "error", text: "Dieser Browser unterstuetzt Web Push nicht." }); + setMessage({ type: "error", text: "Dieser Browser unterstützt Web Push nicht." }); return; } @@ -1280,7 +1343,7 @@ export function DashboardShell({ }) ) : ( - Keine Freigaberollen verfuegbar + Keine Freigaberollen verfügbar )} @@ -2375,8 +2438,26 @@ export function DashboardShell({ ) : null; + const selectedMobileGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0] ?? null; + const overviewGroups = isCompactLayout && selectedMobileGroup ? [selectedMobileGroup] : visibleGroups; + const overviewContent = ( + {isCompactLayout && visibleGroups.length > 1 ? ( + setSelectedMobileGroupId(event.target.value)} + fullWidth + > + {visibleGroups.map((group) => ( + + {group.name} + + ))} + + ) : null} {isCompactLayout ? ( - - {visibleGroups.map((group) => ( - + + {overviewGroups.map((group) => ( + - {visibleGroups.map((group) => ( + {overviewGroups.map((group) => ( - + + {viewer.role === "ORGA" ? ( + setIsOrgaSettingsOpen(true)} + > + + + ) : null} - + Rave for Peace @@ -2534,15 +2636,6 @@ export function DashboardShell({ {pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push"} ) : null} - {viewer.role === "ORGA" ? ( - setIsOrgaSettingsOpen(true)} - > - - - ) : null} Alle Nutzer der AG Nur AG-Mitglieder + + + Google Drive + + + Prüft Service Account, Zielordner und Upload-Rechte mit einer temporären Testdatei. + + + {driveDiagnosticResult ? ( + + {driveDiagnosticResult.ok + ? [ + "Drive-Test erfolgreich.", + driveDiagnosticResult.serviceAccountEmail + ? `Service Account: ${driveDiagnosticResult.serviceAccountEmail}` + : null, + driveDiagnosticResult.folderId ? `Zielordner: ${driveDiagnosticResult.folderId}` : null, + ...(driveDiagnosticResult.details ?? []) + ].filter(Boolean).join("\n") + : driveDiagnosticResult.error ?? "Drive-Verbindungstest fehlgeschlagen."} + + ) : null} + @@ -2656,7 +2783,7 @@ export function DashboardShell({ - {message ? {message.text} : null} + {message ? {message.text} : null} {periodOverviewCard} {isCompactLayout ? ( diff --git a/src/lib/google-drive.ts b/src/lib/google-drive.ts index a1cf89f..3fd0beb 100644 --- a/src/lib/google-drive.ts +++ b/src/lib/google-drive.ts @@ -3,23 +3,193 @@ import { Readable } from "node:stream"; const DEFAULT_DRIVE_FOLDER_ID = "12zMANi_J0uvie16LUxSmfeqwGjKawEhJ"; -function getDriveClient() { - const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL; - const privateKey = process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?.replace(/\\n/g, "\n"); +export type DriveErrorCode = + | "DRIVE_CONFIG_MISSING" + | "DRIVE_KEY_INVALID" + | "DRIVE_AUTH_FAILED" + | "DRIVE_FORBIDDEN" + | "DRIVE_FOLDER_NOT_FOUND" + | "DRIVE_UPLOAD_FAILED" + | "DRIVE_PERMISSION_FAILED" + | "DRIVE_FILE_ID_MISSING" + | "DRIVE_DIAGNOSTIC_CLEANUP_FAILED"; - if (!clientEmail || !privateKey) { - throw new Error("Google-Drive-Service-Account ist nicht konfiguriert."); +export class DriveIntegrationError extends Error { + code: DriveErrorCode; + details: string[]; + status?: number; + + constructor(message: string, code: DriveErrorCode, details: string[] = [], status?: number) { + super(message); + this.name = "DriveIntegrationError"; + this.code = code; + this.details = details; + this.status = status; + } +} + +type DriveConfig = { + clientEmail: string; + privateKey: string; + folderId: string; +}; + +function getGoogleErrorStatus(error: unknown) { + if (typeof error !== "object" || !error) { + return undefined; } + if ("code" in error && typeof error.code === "number") { + return error.code; + } + + if ("status" in error && typeof error.status === "number") { + return error.status; + } + + return undefined; +} + +function getGoogleErrorReason(error: unknown) { + if (typeof error !== "object" || !error) { + return null; + } + + const response = "response" in error && typeof error.response === "object" && error.response ? error.response : null; + const data = response && "data" in response && typeof response.data === "object" && response.data ? response.data : null; + + if (data && "error_description" in data && typeof data.error_description === "string") { + return data.error_description; + } + + if (data && "error" in data && typeof data.error === "string") { + return data.error; + } + + if ("message" in error && typeof error.message === "string") { + return error.message; + } + + return null; +} + +function mapDriveError(error: unknown, fallbackCode: DriveErrorCode, fallbackMessage: string, details: string[] = []) { + if (error instanceof DriveIntegrationError) { + return error; + } + + const status = getGoogleErrorStatus(error); + const reason = getGoogleErrorReason(error); + const nextDetails = [...details]; + + if (status) { + nextDetails.push(`Google API Status: ${status}`); + } + + if (reason) { + nextDetails.push(`Google API Meldung: ${reason}`); + } + + if (status === 401 || reason?.includes("invalid_grant")) { + return new DriveIntegrationError( + "Google Drive konnte den Service Account nicht authentifizieren.", + "DRIVE_AUTH_FAILED", + [ + "Prüfe, ob GOOGLE_SERVICE_ACCOUNT_EMAIL zur heruntergeladenen JSON-Key-Datei passt.", + "Prüfe, ob GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY vollständig und mit \\n-Zeilenumbrüchen gesetzt ist.", + ...nextDetails + ], + status + ); + } + + if (status === 403) { + return new DriveIntegrationError( + "Google Drive lehnt den Zugriff ab.", + "DRIVE_FORBIDDEN", + [ + "Prüfe, ob die Google Drive API im Google-Cloud-Projekt aktiviert ist.", + "Prüfe, ob der Zielordner für die Service-Account-Mail freigegeben wurde.", + ...nextDetails + ], + status + ); + } + + if (status === 404) { + return new DriveIntegrationError( + "Google Drive findet den Zielordner nicht.", + "DRIVE_FOLDER_NOT_FOUND", + [ + "Prüfe GOOGLE_DRIVE_FOLDER_ID.", + "Prüfe, ob der Ordner für die Service-Account-Mail sichtbar ist.", + ...nextDetails + ], + status + ); + } + + return new DriveIntegrationError(fallbackMessage, fallbackCode, nextDetails, status); +} + +export function getDriveConfig(): DriveConfig { + const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL?.trim(); + const rawPrivateKey = process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY; + const privateKey = rawPrivateKey?.replace(/\\n/g, "\n"); + const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID?.trim() || DEFAULT_DRIVE_FOLDER_ID; + const missing = [ + !clientEmail ? "GOOGLE_SERVICE_ACCOUNT_EMAIL" : null, + !rawPrivateKey ? "GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY" : null, + !folderId ? "GOOGLE_DRIVE_FOLDER_ID" : null + ].filter(Boolean); + + if (missing.length > 0 || !clientEmail || !privateKey) { + throw new DriveIntegrationError("Google Drive ist nicht vollständig konfiguriert.", "DRIVE_CONFIG_MISSING", [ + `Fehlende Werte: ${missing.join(", ")}`, + "Der Private Key wird nicht ausgegeben, damit keine Secrets im Browser landen." + ]); + } + + if (!clientEmail.includes("@") || !clientEmail.includes(".iam.gserviceaccount.com")) { + throw new DriveIntegrationError("Die Service-Account-Mail sieht ungültig aus.", "DRIVE_CONFIG_MISSING", [ + "GOOGLE_SERVICE_ACCOUNT_EMAIL sollte ungefähr so aussehen: name@projekt.iam.gserviceaccount.com" + ]); + } + + if (!privateKey.includes("-----BEGIN PRIVATE KEY-----") || !privateKey.includes("-----END PRIVATE KEY-----")) { + throw new DriveIntegrationError("Der Google-Service-Account-Key hat kein gültiges Private-Key-Format.", "DRIVE_KEY_INVALID", [ + "GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY muss den kompletten Private Key enthalten.", + "In .env sollte der Key in Anführungszeichen stehen und Zeilenumbrüche als \\n enthalten.", + "Beispiel: \"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n\"" + ]); + } + + return { clientEmail, privateKey, folderId }; +} + +function getDriveClient(config = getDriveConfig()) { const auth = new google.auth.JWT({ - email: clientEmail, - key: privateKey, + email: config.clientEmail, + key: config.privateKey, scopes: ["https://www.googleapis.com/auth/drive.file"] }); return google.drive({ version: "v3", auth }); } +export function serializeDriveError(error: unknown) { + const driveError = error instanceof DriveIntegrationError + ? error + : mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Google Drive konnte die Anfrage nicht verarbeiten."); + + return { + error: driveError.message, + code: driveError.code, + details: driveError.details, + status: driveError.status + }; +} + export function sanitizeDriveFileName(title: string, fallback = "beleg") { const sanitized = title .normalize("NFKD") @@ -39,8 +209,8 @@ export async function uploadExpenseProofToDrive(input: { mimeType: string; buffer: Buffer; }) { - const drive = getDriveClient(); - const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID; + const config = getDriveConfig(); + const drive = getDriveClient(config); const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : ""; const baseName = sanitizeDriveFileName(input.title); const name = `${input.invoiceDate}-${baseName}-${String(input.sequence).padStart(2, "0")}${extension}`; @@ -48,17 +218,24 @@ export async function uploadExpenseProofToDrive(input: { const response = await drive.files.create({ requestBody: { name, - parents: [folderId] + parents: [config.folderId] }, media: { mimeType: input.mimeType, body: Readable.from(input.buffer) }, fields: "id, webViewLink" + }).catch((error: unknown) => { + throw mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Google Drive konnte den Rechnungsbeleg nicht hochladen.", [ + `Zielordner: ${config.folderId}`, + `Service Account: ${config.clientEmail}` + ]); }); if (!response.data.id) { - throw new Error("Google Drive hat keine Datei-ID zurueckgegeben."); + throw new DriveIntegrationError("Google Drive hat keine Datei-ID zurückgegeben.", "DRIVE_FILE_ID_MISSING", [ + "Der Upload wurde von Google angenommen, aber ohne Datei-ID beantwortet." + ]); } await drive.permissions.create({ @@ -67,6 +244,10 @@ export async function uploadExpenseProofToDrive(input: { type: "anyone", role: "reader" } + }).catch((error: unknown) => { + throw mapDriveError(error, "DRIVE_PERMISSION_FAILED", "Google Drive konnte den Freigabe-Link nicht erstellen.", [ + `Die Datei wurde vermutlich bereits erstellt. Drive-Datei-ID: ${response.data.id}` + ]); }); return { @@ -75,3 +256,61 @@ export async function uploadExpenseProofToDrive(input: { storedFileName: name }; } + +export async function runDriveDiagnostics() { + const config = getDriveConfig(); + const drive = getDriveClient(config); + const testName = `rfp-drive-api-test-${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; + let createdFileId: string | null = null; + + try { + const response = await drive.files.create({ + requestBody: { + name: testName, + parents: [config.folderId] + }, + media: { + mimeType: "text/plain", + body: Readable.from(Buffer.from("RFP Finanzen Drive API Test\n", "utf8")) + }, + fields: "id, webViewLink" + }); + + createdFileId = response.data.id ?? null; + + if (!createdFileId) { + throw new DriveIntegrationError("Google Drive hat für die Testdatei keine Datei-ID zurückgegeben.", "DRIVE_FILE_ID_MISSING"); + } + + await drive.files.delete({ fileId: createdFileId }); + + return { + ok: true, + folderId: config.folderId, + serviceAccountEmail: config.clientEmail, + details: [ + "Service-Account-Konfiguration ist vollständig.", + "Authentifizierung bei Google war erfolgreich.", + "Eine Testdatei konnte im Zielordner erstellt und wieder gelöscht werden." + ] + }; + } catch (error) { + if (createdFileId) { + await drive.files.delete({ fileId: createdFileId }).catch((cleanupError: unknown) => { + throw new DriveIntegrationError( + "Drive-Test ist fehlgeschlagen und die temporäre Testdatei konnte nicht gelöscht werden.", + "DRIVE_DIAGNOSTIC_CLEANUP_FAILED", + [ + `Temporäre Drive-Datei-ID: ${createdFileId}`, + ...serializeDriveError(cleanupError).details + ] + ); + }); + } + + throw mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Der Google-Drive-Verbindungstest ist fehlgeschlagen.", [ + `Zielordner: ${config.folderId}`, + `Service Account: ${config.clientEmail}` + ]); + } +} diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 5cfaed3..30faa0d 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -8,6 +8,7 @@ type PushTargetExpense = { id: string; title: string; amount: number; + workingGroupId: string; }; type PushTargetBudgetRelease = { @@ -71,7 +72,7 @@ export async function notifyApprovalRequest(expense: PushTargetExpense, approval const payload = JSON.stringify({ title: "Freigabe angefragt", body: `${expense.title} (${expense.amount.toFixed(2)} EUR) braucht ${approvalType ? approvalLabel(approvalType) : "deine Freigabe"}.`, - url: `/?expense=${encodeURIComponent(expense.id)}`, + url: `/?expense=${encodeURIComponent(expense.id)}&group=${encodeURIComponent(expense.workingGroupId)}`, tag: `approval-${expense.id}-${subscription.user.role}` }); @@ -120,7 +121,7 @@ export async function notifyBudgetRelease(budget: PushTargetBudgetRelease, targe const payload = JSON.stringify({ title: "Budget freigegeben", body: `${budget.workingGroupName}: ${budget.name} wurde mit ${budget.releasedAmount.toFixed(2)} EUR freigegeben.`, - url: `/?budget=${encodeURIComponent(budget.id)}`, + url: `/?budget=${encodeURIComponent(budget.id)}&group=${encodeURIComponent(budget.workingGroupId)}`, tag: `budget-release-${budget.id}` });