UI Push Deep Links und Drive Diagnose verbessern
All checks were successful
CI / Build and Deploy (push) Successful in 2m30s
All checks were successful
CI / Build and Deploy (push) Successful in 2m30s
This commit is contained in:
@@ -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.
|
||||
|
||||
19
public/sw.js
19
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;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
26
src/app/api/settings/drive-diagnostics/route.ts
Normal file
26
src/app/api/settings/drive-diagnostics/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
@@ -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"
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.4}>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DashboardMessage | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
||||
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
||||
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
||||
const [backupFile, setBackupFile] = useState<File | null>(null);
|
||||
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
||||
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
||||
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
||||
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
|
||||
const [backupFile, setBackupFile] = useState<File | null>(null);
|
||||
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
||||
@@ -336,6 +351,7 @@ export function DashboardShell({
|
||||
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
|
||||
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
||||
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
|
||||
const [driveDiagnosticResult, setDriveDiagnosticResult] = useState<DriveDiagnosticResult | null>(null);
|
||||
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
|
||||
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({
|
||||
})
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Freigaberollen verfuegbar
|
||||
Keine Freigaberollen verfügbar
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -2375,8 +2438,26 @@ export function DashboardShell({
|
||||
</Card>
|
||||
) : null;
|
||||
|
||||
const selectedMobileGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0] ?? null;
|
||||
const overviewGroups = isCompactLayout && selectedMobileGroup ? [selectedMobileGroup] : visibleGroups;
|
||||
|
||||
const overviewContent = (
|
||||
<Stack spacing={2.5}>
|
||||
{isCompactLayout && visibleGroups.length > 1 ? (
|
||||
<TextField
|
||||
select
|
||||
label="AG auswählen"
|
||||
value={selectedMobileGroup?.id ?? ""}
|
||||
onChange={(event) => setSelectedMobileGroupId(event.target.value)}
|
||||
fullWidth
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
<MenuItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
{isCompactLayout ? (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -2391,9 +2472,9 @@ export function DashboardShell({
|
||||
touchAction: "pan-x pan-y"
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" gap={2} sx={{ width: "max-content", minWidth: "100%", alignItems: "stretch" }}>
|
||||
{visibleGroups.map((group) => (
|
||||
<Box key={group.id} sx={{ width: "min(88vw, 456px)", flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||
<Stack direction="row" gap={2} sx={{ width: "100%", minWidth: "100%", alignItems: "stretch" }}>
|
||||
{overviewGroups.map((group) => (
|
||||
<Box key={group.id} sx={{ width: "100%", flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||
<BudgetColumn
|
||||
group={group}
|
||||
viewer={viewer}
|
||||
@@ -2438,7 +2519,7 @@ export function DashboardShell({
|
||||
pr: 0.5
|
||||
}}
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
{overviewGroups.map((group) => (
|
||||
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||
<BudgetColumn
|
||||
group={group}
|
||||
@@ -2498,7 +2579,28 @@ export function DashboardShell({
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 3, md: 4 } }}>
|
||||
<CardContent sx={{ p: { xs: 3, md: 4 }, position: "relative" }}>
|
||||
{viewer.role === "ORGA" ? (
|
||||
<IconButton
|
||||
aria-label="Zuständigkeiten und Benachrichtigungen"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: { xs: 18, md: 24 },
|
||||
right: { xs: 18, md: 24 },
|
||||
width: 44,
|
||||
height: 44,
|
||||
border: `1px solid ${alpha("#FFFFFF", 0.28)}`,
|
||||
color: "white",
|
||||
bgcolor: alpha("#FFFFFF", 0.08),
|
||||
"&:hover": {
|
||||
bgcolor: alpha("#FFFFFF", 0.16)
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsOrgaSettingsOpen(true)}
|
||||
>
|
||||
<SettingsRoundedIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<Stack spacing={3}>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
@@ -2506,7 +2608,7 @@ export function DashboardShell({
|
||||
alignItems={{ xs: "flex-start", md: "center" }}
|
||||
gap={2}
|
||||
>
|
||||
<Box sx={{ maxWidth: 760 }}>
|
||||
<Box sx={{ maxWidth: 760, pr: viewer.role === "ORGA" ? { xs: 6, md: 0 } : 0 }}>
|
||||
<Typography variant="overline" sx={{ color: alpha("#FFFFFF", 0.72), letterSpacing: "0.18em" }}>
|
||||
Rave for Peace
|
||||
</Typography>
|
||||
@@ -2534,15 +2636,6 @@ export function DashboardShell({
|
||||
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push"}
|
||||
</Button>
|
||||
) : null}
|
||||
{viewer.role === "ORGA" ? (
|
||||
<IconButton
|
||||
aria-label="Zuständigkeiten und Benachrichtigungen"
|
||||
sx={{ border: `1px solid ${alpha("#FFFFFF", 0.28)}`, color: "white" }}
|
||||
onClick={() => setIsOrgaSettingsOpen(true)}
|
||||
>
|
||||
<SettingsRoundedIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<Chip
|
||||
label={`${viewer.username} - ${roleLabel(viewer.role)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.14), color: "white", fontWeight: 700, maxWidth: "100%" }}
|
||||
@@ -2644,6 +2737,40 @@ export function DashboardShell({
|
||||
<MenuItem value="ALL_GROUP_USERS">Alle Nutzer der AG</MenuItem>
|
||||
<MenuItem value="GROUP_MEMBERS_ONLY">Nur AG-Mitglieder</MenuItem>
|
||||
</TextField>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Google Drive
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Prüft Service Account, Zielordner und Upload-Rechte mit einer temporären Testdatei.
|
||||
</Typography>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
disabled={busy}
|
||||
onClick={handleRunDriveDiagnostics}
|
||||
sx={{ mt: 1.2 }}
|
||||
>
|
||||
Drive-Verbindung testen
|
||||
</Button>
|
||||
{driveDiagnosticResult ? (
|
||||
<Alert
|
||||
severity={driveDiagnosticResult.ok ? "success" : "error"}
|
||||
sx={{ mt: 1.2, whiteSpace: "pre-line" }}
|
||||
>
|
||||
{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."}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -2656,7 +2783,7 @@ export function DashboardShell({
|
||||
|
||||
<Container maxWidth={false} sx={{ maxWidth: 1640 }}>
|
||||
<Stack spacing={3} px={2}>
|
||||
{message ? <Alert severity={message.type}>{message.text}</Alert> : null}
|
||||
{message ? <Alert severity={message.type} sx={{ whiteSpace: "pre-line" }}>{message.text}</Alert> : null}
|
||||
{periodOverviewCard}
|
||||
|
||||
{isCompactLayout ? (
|
||||
|
||||
@@ -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}`
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user