Initial commit
This commit is contained in:
22
src/lib/accounting-periods.ts
Normal file
22
src/lib/accounting-periods.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function getCurrentAccountingPeriod() {
|
||||
const current = await prisma.accountingPeriod.findFirst({
|
||||
where: {
|
||||
isCurrent: true
|
||||
},
|
||||
orderBy: {
|
||||
startsAt: "desc"
|
||||
}
|
||||
});
|
||||
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return prisma.accountingPeriod.findFirst({
|
||||
orderBy: {
|
||||
startsAt: "desc"
|
||||
}
|
||||
});
|
||||
}
|
||||
62
src/lib/audit-log.ts
Normal file
62
src/lib/audit-log.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
type AuditClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export type AuditRollbackPayload = {
|
||||
kind: string;
|
||||
[key: string]: Prisma.JsonValue | null | undefined;
|
||||
};
|
||||
|
||||
type CreateAuditLogInput = {
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId?: string | null;
|
||||
entityLabel?: string | null;
|
||||
summary: string;
|
||||
metadata?: Prisma.InputJsonValue;
|
||||
};
|
||||
|
||||
export function withRollbackMetadata(
|
||||
details: Record<string, Prisma.JsonValue | null | undefined> = {},
|
||||
rollback?: AuditRollbackPayload | null
|
||||
) {
|
||||
return {
|
||||
...details,
|
||||
rollback: rollback ?? null
|
||||
} satisfies Prisma.InputJsonObject;
|
||||
}
|
||||
|
||||
export function getRollbackMetadata(metadata: Prisma.JsonValue | null | undefined) {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rollback = (metadata as Record<string, unknown>).rollback;
|
||||
|
||||
if (!rollback || typeof rollback !== "object" || Array.isArray(rollback)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = (rollback as Record<string, unknown>).kind;
|
||||
|
||||
if (typeof kind !== "string" || kind.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rollback as Record<string, Prisma.JsonValue | null | undefined> & { kind: string };
|
||||
}
|
||||
|
||||
export async function createAuditLog(client: AuditClient, input: CreateAuditLogInput) {
|
||||
await client.auditLog.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId ?? null,
|
||||
entityLabel: input.entityLabel ?? null,
|
||||
summary: input.summary,
|
||||
metadata: input.metadata
|
||||
}
|
||||
});
|
||||
}
|
||||
98
src/lib/audit-snapshots.ts
Normal file
98
src/lib/audit-snapshots.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Approval, AccountingPeriod, Budget, Expense, User, WorkingGroup } from "@prisma/client";
|
||||
|
||||
export function snapshotWorkingGroup(workingGroup: Pick<WorkingGroup, "id" | "name" | "createdAt">) {
|
||||
return {
|
||||
id: workingGroup.id,
|
||||
name: workingGroup.name,
|
||||
createdAt: workingGroup.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotPeriod(period: Pick<AccountingPeriod, "id" | "name" | "startsAt" | "endsAt" | "isCurrent" | "createdAt">) {
|
||||
return {
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
startsAt: period.startsAt.toISOString(),
|
||||
endsAt: period.endsAt.toISOString(),
|
||||
isCurrent: period.isCurrent,
|
||||
createdAt: period.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotBudget(budget: Pick<Budget, "id" | "name" | "totalBudget" | "colorCode" | "workingGroupId" | "periodId" | "createdAt">) {
|
||||
return {
|
||||
id: budget.id,
|
||||
name: budget.name,
|
||||
totalBudget: Number(budget.totalBudget),
|
||||
colorCode: budget.colorCode,
|
||||
workingGroupId: budget.workingGroupId,
|
||||
periodId: budget.periodId,
|
||||
createdAt: budget.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotExpense(
|
||||
expense: Pick<
|
||||
Expense,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "amount"
|
||||
| "creatorId"
|
||||
| "agId"
|
||||
| "budgetId"
|
||||
| "periodId"
|
||||
| "approvalStatus"
|
||||
| "recurrence"
|
||||
| "proofUrl"
|
||||
| "createdAt"
|
||||
| "paidAt"
|
||||
| "documentedAt"
|
||||
>
|
||||
) {
|
||||
return {
|
||||
id: expense.id,
|
||||
title: expense.title,
|
||||
description: expense.description,
|
||||
amount: Number(expense.amount),
|
||||
creatorId: expense.creatorId,
|
||||
agId: expense.agId,
|
||||
budgetId: expense.budgetId,
|
||||
periodId: expense.periodId,
|
||||
approvalStatus: expense.approvalStatus,
|
||||
recurrence: expense.recurrence,
|
||||
proofUrl: expense.proofUrl,
|
||||
createdAt: expense.createdAt.toISOString(),
|
||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||
documentedAt: expense.documentedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotApproval(approval: Pick<Approval, "id" | "expenseId" | "userId" | "approvalType" | "timestamp">) {
|
||||
return {
|
||||
id: approval.id,
|
||||
expenseId: approval.expenseId,
|
||||
userId: approval.userId,
|
||||
approvalType: approval.approvalType,
|
||||
timestamp: approval.timestamp.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotUser(
|
||||
user: Pick<
|
||||
User,
|
||||
"id" | "name" | "username" | "email" | "passwordHash" | "role" | "approvalPreference" | "workingGroupId" | "createdAt"
|
||||
>
|
||||
) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
passwordHash: user.passwordHash,
|
||||
role: user.role,
|
||||
approvalPreference: user.approvalPreference,
|
||||
workingGroupId: user.workingGroupId,
|
||||
createdAt: user.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
87
src/lib/auth.ts
Normal file
87
src/lib/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
identifier: z.string().trim().min(2),
|
||||
password: z.string().min(6)
|
||||
});
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
session: {
|
||||
strategy: "jwt"
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login"
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Login und Passwort",
|
||||
credentials: {
|
||||
identifier: { label: "Login-Name", type: "text" },
|
||||
password: { label: "Passwort", type: "password" }
|
||||
},
|
||||
async authorize(rawCredentials) {
|
||||
const parsedCredentials = credentialsSchema.safeParse(rawCredentials);
|
||||
|
||||
if (!parsedCredentials.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedIdentifier = parsedCredentials.data.identifier.toLowerCase();
|
||||
const matchedUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: normalizedIdentifier
|
||||
}
|
||||
});
|
||||
|
||||
if (!matchedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(parsedCredentials.data.password, matchedUser.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: matchedUser.id,
|
||||
name: matchedUser.username,
|
||||
username: matchedUser.username,
|
||||
email: matchedUser.email,
|
||||
role: matchedUser.role,
|
||||
workingGroupId: matchedUser.workingGroupId,
|
||||
approvalPreference: matchedUser.approvalPreference
|
||||
};
|
||||
}
|
||||
})
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.username = user.username;
|
||||
token.role = user.role;
|
||||
token.workingGroupId = user.workingGroupId;
|
||||
token.approvalPreference = user.approvalPreference;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id ?? "";
|
||||
session.user.username = token.username ?? "";
|
||||
session.user.role = token.role ?? "MEMBER";
|
||||
session.user.workingGroupId = token.workingGroupId ?? null;
|
||||
session.user.approvalPreference = token.approvalPreference ?? null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
};
|
||||
61
src/lib/backup-csv.ts
Normal file
61
src/lib/backup-csv.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export function toCsvCell(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
const normalized = String(value).replace(/"/g, "\"\"");
|
||||
return `"${normalized}"`;
|
||||
}
|
||||
|
||||
export function parseCsv(content: string) {
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentCell = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const char = content[index];
|
||||
const nextChar = content[index + 1];
|
||||
|
||||
if (char === "\"") {
|
||||
if (inQuotes && nextChar === "\"") {
|
||||
currentCell += "\"";
|
||||
index += 1;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "," && !inQuotes) {
|
||||
currentRow.push(currentCell);
|
||||
currentCell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((char === "\n" || char === "\r") && !inQuotes) {
|
||||
if (char === "\r" && nextChar === "\n") {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
currentRow.push(currentCell);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentCell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
currentCell += char;
|
||||
}
|
||||
|
||||
if (currentCell.length > 0 || currentRow.length > 0) {
|
||||
currentRow.push(currentCell);
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
if (rows[0]?.[0]?.charCodeAt(0) === 0xfeff) {
|
||||
rows[0][0] = rows[0][0].slice(1);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
99
src/lib/dashboard-types.ts
Normal file
99
src/lib/dashboard-types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { AppRole, ApprovalStatusValue, ApprovalTypeValue, ExpenseRecurrenceValue } from "@/lib/domain";
|
||||
|
||||
export type DashboardAccountingPeriod = {
|
||||
id: string;
|
||||
name: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export type DashboardViewer = {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
role: AppRole;
|
||||
workingGroupId: string | null;
|
||||
approvalPreference: ApprovalTypeValue | null;
|
||||
};
|
||||
|
||||
export type DashboardApproval = {
|
||||
id: string;
|
||||
approvalType: ApprovalTypeValue;
|
||||
timestamp: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DashboardExpense = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: number;
|
||||
budgetId: string;
|
||||
periodId: string;
|
||||
approvalStatus: ApprovalStatusValue;
|
||||
recurrence: ExpenseRecurrenceValue;
|
||||
paidAt: string | null;
|
||||
documentedAt: string | null;
|
||||
proofUrl: string | null;
|
||||
createdAt: string;
|
||||
creator: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
approvals: DashboardApproval[];
|
||||
};
|
||||
|
||||
export type DashboardBudget = {
|
||||
id: string;
|
||||
name: string;
|
||||
totalBudget: number;
|
||||
colorCode: string;
|
||||
periodId: string;
|
||||
expenses: DashboardExpense[];
|
||||
};
|
||||
|
||||
export type DashboardWorkingGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
totalBudget: number;
|
||||
members: {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
role: AppRole;
|
||||
}[];
|
||||
budgets: DashboardBudget[];
|
||||
};
|
||||
|
||||
export type DashboardManagedUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
role: AppRole;
|
||||
workingGroupId: string | null;
|
||||
workingGroupName: string | null;
|
||||
approvalPreference: ApprovalTypeValue | null;
|
||||
createdExpensesCount: number;
|
||||
approvalsCount: number;
|
||||
};
|
||||
|
||||
export type DashboardAuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
entityLabel: string | null;
|
||||
summary: string;
|
||||
canRestore: boolean;
|
||||
createdAt: string;
|
||||
actor: {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
role: AppRole;
|
||||
} | null;
|
||||
};
|
||||
122
src/lib/domain.ts
Normal file
122
src/lib/domain.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
export const AUTO_APPROVAL_THRESHOLD = 50;
|
||||
|
||||
export const APPROVAL_FLOW = ["CHAIR_A", "CHAIR_B", "FINANCE"] as const;
|
||||
export const COLOR_PRESETS = [
|
||||
"#FFB94A",
|
||||
"#68A35D",
|
||||
"#5677F6",
|
||||
"#FF8C69",
|
||||
"#D567C8",
|
||||
"#17A2B8",
|
||||
"#A47A5B",
|
||||
"#8E6CE7",
|
||||
"#E05A73",
|
||||
"#3FAF88"
|
||||
] as const;
|
||||
|
||||
export type AppRole = "ADMIN" | "FINANCE" | "MEMBER";
|
||||
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
|
||||
export type ApprovalStatusValue = "PENDING" | "APPROVED";
|
||||
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
|
||||
|
||||
export function requiresManualApproval(amount: number) {
|
||||
return amount >= AUTO_APPROVAL_THRESHOLD;
|
||||
}
|
||||
|
||||
export function roleLabel(role: AppRole) {
|
||||
switch (role) {
|
||||
case "ADMIN":
|
||||
return "Vorstand";
|
||||
case "FINANCE":
|
||||
return "Finanz-AG";
|
||||
case "MEMBER":
|
||||
return "AG-Mitglied";
|
||||
}
|
||||
}
|
||||
|
||||
export function approvalLabel(approvalType: ApprovalTypeValue) {
|
||||
switch (approvalType) {
|
||||
case "CHAIR_A":
|
||||
return "Vorstand A";
|
||||
case "CHAIR_B":
|
||||
return "Vorstand B";
|
||||
case "FINANCE":
|
||||
return "Finanz-AG";
|
||||
}
|
||||
}
|
||||
|
||||
export function recurrenceLabel(recurrence: ExpenseRecurrenceValue) {
|
||||
switch (recurrence) {
|
||||
case "MONTHLY":
|
||||
return "Monatliches Abo";
|
||||
case "NONE":
|
||||
return "Einmalig";
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAdministrativeAccess(role: AppRole) {
|
||||
return role === "ADMIN" || role === "FINANCE";
|
||||
}
|
||||
|
||||
export function canManageBudgets(role: AppRole) {
|
||||
return hasAdministrativeAccess(role);
|
||||
}
|
||||
|
||||
export function canManageUsers(role: AppRole) {
|
||||
return hasAdministrativeAccess(role);
|
||||
}
|
||||
|
||||
export function canMarkPaid(role: AppRole) {
|
||||
return hasAdministrativeAccess(role);
|
||||
}
|
||||
|
||||
export function canDocumentExpense(role: AppRole) {
|
||||
return hasAdministrativeAccess(role);
|
||||
}
|
||||
|
||||
export function canCreateExpenseForGroup(role: AppRole, viewerGroupId: string | null, targetGroupId: string) {
|
||||
if (hasAdministrativeAccess(role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return viewerGroupId === targetGroupId;
|
||||
}
|
||||
|
||||
export function canDeleteExpense(
|
||||
role: AppRole,
|
||||
viewerId: string,
|
||||
creatorId: string,
|
||||
approvalStatus: ApprovalStatusValue,
|
||||
paidAt: string | null,
|
||||
documentedAt: string | null
|
||||
) {
|
||||
if (role === "ADMIN" || role === "FINANCE") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return viewerId === creatorId && approvalStatus === "PENDING" && !paidAt && !documentedAt;
|
||||
}
|
||||
|
||||
export function getAvailableApprovalTypes(
|
||||
role: AppRole,
|
||||
approvalPreference: ApprovalTypeValue | null | undefined,
|
||||
existingApprovals: ApprovalTypeValue[]
|
||||
): ApprovalTypeValue[] {
|
||||
const missingApprovals = APPROVAL_FLOW.filter(
|
||||
(approvalType) => !existingApprovals.includes(approvalType)
|
||||
) as ApprovalTypeValue[];
|
||||
|
||||
if (role === "ADMIN") {
|
||||
if (approvalPreference && missingApprovals.includes(approvalPreference)) {
|
||||
return [approvalPreference, ...missingApprovals.filter((approvalType) => approvalType !== approvalPreference)];
|
||||
}
|
||||
|
||||
return missingApprovals;
|
||||
}
|
||||
|
||||
if (role === "FINANCE") {
|
||||
return missingApprovals.includes("FINANCE") ? ["FINANCE"] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
17
src/lib/prisma.ts
Normal file
17
src/lib/prisma.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"]
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
19
src/lib/session.ts
Normal file
19
src/lib/session.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function getCurrentViewer() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
workingGroup: true
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user