Rollen Freigaben Push und Beleg Upload ueberarbeiten
This commit is contained in:
@@ -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
72
src/lib/google-drive.ts
Normal 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`;
|
||||
}
|
||||
94
src/lib/push-notifications.ts
Normal file
94
src/lib/push-notifications.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user