AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s

This commit is contained in:
jan
2026-05-05 21:57:20 +02:00
parent 99d4f6dd22
commit f87a82e02f
21 changed files with 885 additions and 323 deletions

View File

@@ -1,6 +1,6 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import { DEFAULT_APPROVAL_THRESHOLD } from "@/lib/domain";
import { APPROVAL_FLOW, DEFAULT_APPROVAL_THRESHOLD, normalizeRequiredApprovalTypes } from "@/lib/domain";
import prisma from "@/lib/prisma";
type SettingsClient = PrismaClient | Prisma.TransactionClient;
@@ -13,7 +13,9 @@ export async function getAppSettings(client: SettingsClient = prisma) {
update: {},
create: {
id: "global",
approvalThreshold: DEFAULT_APPROVAL_THRESHOLD
approvalThreshold: DEFAULT_APPROVAL_THRESHOLD,
requiredApprovalTypes: [...APPROVAL_FLOW],
budgetReleaseNotifyTarget: "ALL_GROUP_USERS"
}
});
}
@@ -22,3 +24,11 @@ export function toApprovalThresholdNumber(value: { toString(): string } | number
const parsed = Number(typeof value === "number" ? value : value.toString());
return Number.isFinite(parsed) ? parsed : DEFAULT_APPROVAL_THRESHOLD;
}
export function serializeAppSettings(settings: Awaited<ReturnType<typeof getAppSettings>>) {
return {
approvalThreshold: toApprovalThresholdNumber(settings.approvalThreshold),
requiredApprovalTypes: normalizeRequiredApprovalTypes(settings.requiredApprovalTypes),
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
};
}

View File

@@ -19,10 +19,14 @@ export function snapshotPeriod(period: Pick<AccountingPeriod, "id" | "name" | "s
};
}
export function snapshotAppSettings(settings: Pick<AppSettings, "id" | "approvalThreshold" | "createdAt">) {
export function snapshotAppSettings(
settings: Pick<AppSettings, "id" | "approvalThreshold" | "requiredApprovalTypes" | "budgetReleaseNotifyTarget" | "createdAt">
) {
return {
id: settings.id,
approvalThreshold: Number(settings.approvalThreshold),
requiredApprovalTypes: settings.requiredApprovalTypes,
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget,
createdAt: settings.createdAt.toISOString()
};
}
@@ -56,8 +60,6 @@ export function snapshotExpense(
| "approvalStatus"
| "recurrence"
| "recurrenceStartAt"
| "invoiceDate"
| "proofUrl"
| "createdAt"
| "paidAt"
| "documentedAt"
@@ -75,8 +77,6 @@ export function snapshotExpense(
approvalStatus: expense.approvalStatus,
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
invoiceDate: expense.invoiceDate?.toISOString() ?? null,
proofUrl: expense.proofUrl,
createdAt: expense.createdAt.toISOString(),
paidAt: expense.paidAt?.toISOString() ?? null,
documentedAt: expense.documentedAt?.toISOString() ?? null

View File

@@ -1,4 +1,10 @@
import type { AppRole, ApprovalStatusValue, ApprovalTypeValue, ExpenseRecurrenceValue } from "@/lib/domain";
import type {
AppRole,
ApprovalStatusValue,
ApprovalTypeValue,
BudgetReleaseNotifyTargetValue,
ExpenseRecurrenceValue
} from "@/lib/domain";
export type DashboardAccountingPeriod = {
id: string;
@@ -34,6 +40,21 @@ export type DashboardExpenseOccurrence = {
amount: number;
};
export type DashboardExpenseDocument = {
id: string;
invoiceDate: string;
proofUrl: string;
storedFileName: string;
originalFileName: string;
mimeType: string;
size: number;
createdAt: string;
uploadedBy: {
id: string;
name: string;
};
};
export type DashboardExpense = {
id: string;
title: string;
@@ -49,8 +70,7 @@ export type DashboardExpense = {
recurrenceStartAt: string | null;
paidAt: string | null;
documentedAt: string | null;
invoiceDate: string | null;
proofUrl: string | null;
documents: DashboardExpenseDocument[];
createdAt: string;
creator: {
id: string;
@@ -59,6 +79,12 @@ export type DashboardExpense = {
approvals: DashboardApproval[];
};
export type DashboardSettings = {
approvalThreshold: number;
requiredApprovalTypes: ApprovalTypeValue[];
budgetReleaseNotifyTarget: BudgetReleaseNotifyTargetValue;
};
export type DashboardBudget = {
id: string;
name: string;

View File

@@ -18,6 +18,7 @@ export type AppRole = "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
export type ApprovalStatusValue = "PENDING" | "APPROVED";
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
export type BudgetReleaseNotifyTargetValue = "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY";
export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) {
return amount >= approvalThreshold;
@@ -68,6 +69,10 @@ export function canManageUsers(role: AppRole) {
return hasAdministrativeAccess(role);
}
export function canManageSettings(role: AppRole) {
return role === "ORGA";
}
export function canMarkPaid(role: AppRole) {
return canDocumentExpense(role);
}
@@ -126,9 +131,15 @@ export function getLegacyApprovalPreference(approvalPermissions: ApprovalTypeVal
export function getAvailableApprovalTypes(
approvalPermissions: ApprovalTypeValue[],
existingApprovals: ApprovalTypeValue[]
existingApprovals: ApprovalTypeValue[],
requiredApprovalTypes: ApprovalTypeValue[] = [...APPROVAL_FLOW]
): ApprovalTypeValue[] {
return APPROVAL_FLOW.filter(
return requiredApprovalTypes.filter(
(approvalType) => approvalPermissions.includes(approvalType) && !existingApprovals.includes(approvalType)
) as ApprovalTypeValue[];
}
export function normalizeRequiredApprovalTypes(value: ApprovalTypeValue[] | null | undefined) {
const normalized = APPROVAL_FLOW.filter((approvalType) => value?.includes(approvalType));
return normalized.length > 0 ? normalized : [...APPROVAL_FLOW];
}

View File

@@ -34,6 +34,7 @@ export function sanitizeDriveFileName(title: string, fallback = "beleg") {
export async function uploadExpenseProofToDrive(input: {
title: string;
invoiceDate: string;
sequence: number;
fileName: string;
mimeType: string;
buffer: Buffer;
@@ -42,7 +43,7 @@ export async function uploadExpenseProofToDrive(input: {
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 name = `${input.invoiceDate}-${baseName}${extension}`;
const name = `${input.invoiceDate}-${baseName}-${String(input.sequence).padStart(2, "0")}${extension}`;
const response = await drive.files.create({
requestBody: {
@@ -68,5 +69,9 @@ export async function uploadExpenseProofToDrive(input: {
}
});
return response.data.webViewLink ?? `https://drive.google.com/file/d/${response.data.id}/view`;
return {
driveFileId: response.data.id,
proofUrl: response.data.webViewLink ?? `https://drive.google.com/file/d/${response.data.id}/view`,
storedFileName: name
};
}

View File

@@ -1,5 +1,5 @@
import webpush from "web-push";
import type { ApprovalType } from "@prisma/client";
import type { ApprovalType, BudgetReleaseNotifyTarget } from "@prisma/client";
import { approvalLabel } from "@/lib/domain";
import prisma from "@/lib/prisma";
@@ -10,6 +10,14 @@ type PushTargetExpense = {
amount: number;
};
type PushTargetBudgetRelease = {
id: string;
name: string;
workingGroupId: string;
workingGroupName: string;
releasedAmount: number;
};
function configureWebPush() {
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
@@ -92,3 +100,52 @@ export async function notifyApprovalRequest(expense: PushTargetExpense, approval
})
);
}
export async function notifyBudgetRelease(budget: PushTargetBudgetRelease, target: BudgetReleaseNotifyTarget) {
if (!configureWebPush()) {
return;
}
const subscriptions = await prisma.pushSubscription.findMany({
where: {
user: {
workingGroupId: budget.workingGroupId,
...(target === "GROUP_MEMBERS_ONLY" ? { role: "MEMBER" } : {})
}
}
});
await Promise.all(
subscriptions.map(async (subscription) => {
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)}`,
tag: `budget-release-${budget.id}`
});
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
}
});
}
}
})
);
}