AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user