Initial commit

This commit is contained in:
Jan
2026-04-08 16:30:44 +02:00
commit f9b17e9964
65 changed files with 7574 additions and 0 deletions

View 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
View 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
}
});
}

View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
}
});
}