Rollen Freigaben Push und Beleg Upload ueberarbeiten
All checks were successful
CI / Build (push) Successful in 2m6s
CI / Deploy (push) Successful in 2m11s

This commit is contained in:
jan
2026-05-01 15:50:37 +02:00
parent f947908f0e
commit 549c8f16c6
34 changed files with 1354 additions and 172 deletions

View File

@@ -14,7 +14,7 @@ export const COLOR_PRESETS = [
"#3FAF88"
] as const;
export type AppRole = "ADMIN" | "FINANCE" | "MEMBER";
export type AppRole = "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
export type ApprovalStatusValue = "PENDING" | "APPROVED";
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
@@ -25,10 +25,12 @@ export function requiresManualApproval(amount: number, approvalThreshold = DEFAU
export function roleLabel(role: AppRole) {
switch (role) {
case "ADMIN":
return "Vorstand";
case "BOARD":
return "Vorstand allgemein";
case "ORGA":
return "AG Orga";
case "FINANCE":
return "Finanz-AG";
return "AG Finanzen";
case "MEMBER":
return "AG-Mitglied";
}
@@ -37,11 +39,11 @@ export function roleLabel(role: AppRole) {
export function approvalLabel(approvalType: ApprovalTypeValue) {
switch (approvalType) {
case "CHAIR_A":
return "Vorstand A";
return "AG Orga";
case "CHAIR_B":
return "Vorstand B";
return "Vorstand allgemein";
case "FINANCE":
return "Finanz-AG";
return "AG Finanzen";
}
}
@@ -55,7 +57,7 @@ export function recurrenceLabel(recurrence: ExpenseRecurrenceValue) {
}
export function hasAdministrativeAccess(role: AppRole) {
return role === "ADMIN" || role === "FINANCE";
return role === "BOARD" || role === "ORGA" || role === "FINANCE";
}
export function canManageBudgets(role: AppRole) {
@@ -67,11 +69,11 @@ export function canManageUsers(role: AppRole) {
}
export function canMarkPaid(role: AppRole) {
return hasAdministrativeAccess(role);
return role === "BOARD" || role === "FINANCE";
}
export function canDocumentExpense(role: AppRole) {
return hasAdministrativeAccess(role);
return role === "BOARD" || role === "FINANCE";
}
export function canCreateExpenseForGroup(role: AppRole, viewerGroupId: string | null, targetGroupId: string) {
@@ -90,7 +92,7 @@ export function canDeleteExpense(
paidAt: string | null,
documentedAt: string | null
) {
if (role === "ADMIN" || role === "FINANCE") {
if (hasAdministrativeAccess(role)) {
return true;
}
@@ -99,8 +101,10 @@ export function canDeleteExpense(
export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] {
switch (role) {
case "ADMIN":
return ["CHAIR_A", "CHAIR_B"];
case "BOARD":
return ["CHAIR_B"];
case "ORGA":
return ["CHAIR_A"];
case "FINANCE":
return ["FINANCE"];
case "MEMBER":
@@ -109,24 +113,11 @@ export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] {
}
export function normalizeApprovalPermissions(
_role: AppRole,
approvalPermissions: ApprovalTypeValue[] | null | undefined,
approvalPreference: ApprovalTypeValue | null | undefined = null
role: AppRole,
_approvalPermissions: ApprovalTypeValue[] | null | undefined,
_approvalPreference: ApprovalTypeValue | null | undefined = null
) {
const rawPermissions = approvalPermissions ?? (approvalPreference ? [approvalPreference] : []);
// Deduplizierung: behalte jeden Eintrag nur beim ersten Vorkommen
const seen = new Set<ApprovalTypeValue>();
const unique: ApprovalTypeValue[] = [];
for (const perm of rawPermissions) {
if (!seen.has(perm)) {
seen.add(perm);
unique.push(perm);
}
}
// Sortiere nach der Reihenfolge in APPROVAL_FLOW
return APPROVAL_FLOW.filter((type) => unique.includes(type)) as ApprovalTypeValue[];
return getAvailableApprovalRoles(role);
}
export function getLegacyApprovalPreference(approvalPermissions: ApprovalTypeValue[]) {

72
src/lib/google-drive.ts Normal file
View File

@@ -0,0 +1,72 @@
import { google } from "googleapis";
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");
if (!clientEmail || !privateKey) {
throw new Error("Google-Drive-Service-Account ist nicht konfiguriert.");
}
const auth = new google.auth.JWT({
email: clientEmail,
key: privateKey,
scopes: ["https://www.googleapis.com/auth/drive.file"]
});
return google.drive({ version: "v3", auth });
}
export function sanitizeDriveFileName(title: string, fallback = "beleg") {
const sanitized = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || fallback;
}
export async function uploadExpenseProofToDrive(input: {
title: string;
fileName: string;
mimeType: string;
buffer: Buffer;
}) {
const drive = getDriveClient();
const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID;
const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : "";
const baseName = sanitizeDriveFileName(input.title);
const uniqueSuffix = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
const name = `${baseName}-${uniqueSuffix}${extension}`;
const response = await drive.files.create({
requestBody: {
name,
parents: [folderId]
},
media: {
mimeType: input.mimeType,
body: Readable.from(input.buffer)
},
fields: "id, webViewLink"
});
if (!response.data.id) {
throw new Error("Google Drive hat keine Datei-ID zurueckgegeben.");
}
await drive.permissions.create({
fileId: response.data.id,
requestBody: {
type: "anyone",
role: "reader"
}
});
return response.data.webViewLink ?? `https://drive.google.com/file/d/${response.data.id}/view`;
}

View File

@@ -0,0 +1,94 @@
import webpush from "web-push";
import type { ApprovalType } from "@prisma/client";
import { approvalLabel } from "@/lib/domain";
import prisma from "@/lib/prisma";
type PushTargetExpense = {
id: string;
title: string;
amount: number;
};
function configureWebPush() {
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
return false;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
return true;
}
export function getApprovalRole(approvalType: ApprovalType) {
switch (approvalType) {
case "CHAIR_A":
return "ORGA";
case "CHAIR_B":
return "BOARD";
case "FINANCE":
return "FINANCE";
}
}
export async function notifyApprovalRequest(expense: PushTargetExpense, approvalTypes: ApprovalType[]) {
if (!configureWebPush() || approvalTypes.length === 0) {
return;
}
const roles = approvalTypes.map(getApprovalRole);
const subscriptions = await prisma.pushSubscription.findMany({
where: {
user: {
role: {
in: roles
}
}
},
include: {
user: {
select: {
role: true
}
}
}
});
await Promise.all(
subscriptions.map(async (subscription) => {
const approvalType = approvalTypes.find((type) => getApprovalRole(type) === subscription.user.role);
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)}`,
tag: `approval-${expense.id}-${subscription.user.role}`
});
try {
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth
}
},
payload
);
} catch (error) {
const statusCode = typeof error === "object" && error && "statusCode" in error ? error.statusCode : null;
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.delete({
where: {
endpoint: subscription.endpoint
}
});
}
}
})
);
}