Files
FEDEO/backend/src/routes/resources/main.ts
florianfederspiel af4a283eb5 feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen
mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die
Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im
DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der
USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen
verteilen den Skonto auf die Restforderung nach Abzug der Abschläge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:31:47 +01:00

1282 lines
51 KiB
TypeScript

import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
import { projectChatSettingError } from "../../modules/communication-access"
import { FastifyInstance } from "fastify"
import {
eq,
ilike,
asc,
desc,
and,
count,
inArray,
isNull,
or,
sql,
} from "drizzle-orm"
import {
authProfiles,
authUsers,
createddocuments,
customers,
entitybankaccounts,
statementallocations,
} from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt";
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
const OUTGOING_SEPA_MANDATE_STATUSES = new Set(["Entwurf", "Aktiv", "Widerrufen", "Abgelaufen"])
const OUTGOING_SEPA_MANDATE_TYPES = new Set(["CORE", "B2B"])
const OUTGOING_SEPA_SEQUENCE_TYPES = new Set(["RCUR", "OOFF", "FRST", "FNAL"])
// -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
// -------------------------------------------------------------
function buildSearchCondition(columns: any[], search: string) {
if (!search || !columns.length) return null
const normalizeForSearch = (value: string) =>
value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ß/g, "ss")
const searchTermsRaw = search
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
const searchTermsNormalized = searchTermsRaw.map(normalizeForSearch)
const normalizeSqlExpr = (valueExpr: any) => sql`
lower(
replace(
replace(
replace(
replace(
replace(
replace(
replace(cast(${valueExpr} as text), 'Ä', 'A'),
'Ö', 'O'
),
'Ü', 'U'
),
'ä', 'a'
),
'ö', 'o'
),
'ü', 'u'
),
'ß', 'ss'
)
)
`
const validColumns = columns.filter(Boolean)
if (validColumns.length === 0) return null
// Alle Suchspalten zu einem String zusammenführen, damit Vor-/Nachname zuverlässig
// gemeinsam durchsuchbar sind (auch wenn in getrennten Feldern gespeichert).
const combinedRawExpr = sql`concat_ws(' ', ${sql.join(validColumns.map((col) => sql`coalesce(cast(${col} as text), '')`), sql`, `)})`
const combinedNormalizedExpr = normalizeSqlExpr(combinedRawExpr)
const perTermConditions = searchTermsRaw.map((rawTerm, idx) => {
const normalizedTerm = searchTermsNormalized[idx]
const rawLike = `%${rawTerm}%`
const normalizedLike = `%${normalizedTerm}%`
const rawCondition = ilike(combinedRawExpr, rawLike)
const normalizedCondition = sql`${combinedNormalizedExpr} like ${normalizedLike}`
return or(rawCondition, normalizedCondition)
})
if (perTermConditions.length === 0) return null
return and(...perTermConditions)
}
function formatDiffValue(value: any): string {
if (value === null || value === undefined) return "-"
if (typeof value === "boolean") return value ? "Ja" : "Nein"
if (typeof value === "object") {
try {
return JSON.stringify(value)
} catch {
return "[Objekt]"
}
}
return String(value)
}
const TECHNICAL_HISTORY_KEYS = new Set([
"id",
"tenant",
"tenant_id",
"createdAt",
"created_at",
"createdBy",
"created_by",
"updatedAt",
"updated_at",
"updatedBy",
"updated_by",
"archived",
])
function getUserVisibleChanges(oldRecord: Record<string, any>, updated: Record<string, any>) {
return diffObjects(oldRecord, updated).filter((c) => !TECHNICAL_HISTORY_KEYS.has(c.key))
}
function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: any, newValue: any) {
const resourceLabel = getHistoryEntityLabel(resource)
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
}
function applyResourceWhereFilters(resource: string, table: any, whereCond: any, list = false) {
if (list && table === authProfiles) {
return and(whereCond, eq(authProfiles.active, true), isNull(authProfiles.archived_at))
}
if (resource === "members") {
return and(whereCond, eq(table.type, "Mitglied"))
}
return whereCond
}
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
if (!portalCustomerId) return whereCond
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
return null
}
if (resource === "customers") {
return and(whereCond, eq(table.id, portalCustomerId))
}
if (resource === "contracts") {
return and(whereCond, eq(table.customer, portalCustomerId))
}
if (resource === "createddocuments") {
return and(
whereCond,
eq(table.customer, portalCustomerId),
eq(table.availableInPortal, true),
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
)
}
return whereCond
}
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
return {
name: payload.name,
firstname: payload.firstname,
lastname: payload.lastname,
salutation: payload.salutation,
title: payload.title,
nameAddition: payload.nameAddition,
infoData: nextInfoData,
}
}
function getTenantColumn(resource: string, table: any) {
const config = resourceConfig[resource]
const tenantKey = config?.tenantKey || "tenant"
return table[tenantKey]
}
function getRelationConfig(relation: string) {
const candidateKeys = [
relation,
`${relation}s`,
]
if (relation.endsWith("y")) {
candidateKeys.push(`${relation.slice(0, -1)}ies`)
}
if (/(s|x|z|ch|sh)$/.test(relation)) {
candidateKeys.push(`${relation}es`)
}
for (const key of candidateKeys) {
if (resourceConfig[key]) return resourceConfig[key]
}
return null
}
function isDateLikeField(key: string) {
if (key === "deliveryDateType") return false
if (key.includes("_at") || key.endsWith("At")) return true
if (/Date$/.test(key)) return true
return /(^|_|-)date($|_|-)/i.test(key)
}
function isDateValue(value: any) {
if (value instanceof Date) return !Number.isNaN(value.getTime())
if (typeof value !== "string") return false
const normalized = value.trim()
if (/^\d+$/.test(normalized)) return false
return !Number.isNaN(new Date(normalized).getTime())
}
function normalizeCreatedDocumentPayload(payload: Record<string, any>) {
if (Object.prototype.hasOwnProperty.call(payload, "customSurchargePercentage")) {
const customSurchargePercentage = Number(payload.customSurchargePercentage)
payload.customSurchargePercentage = Number.isFinite(customSurchargePercentage)
? customSurchargePercentage
: 0
}
const numberRelationFields = [
"customer",
"contact",
"project",
"createddocument",
"letterhead",
"plant",
"contract",
"outgoingsepamandate",
]
for (const field of numberRelationFields) {
const value = payload[field]
if (value instanceof Date || (typeof value === "string" && isDateValue(value))) {
payload[field] = null
}
}
const serialexecution = payload.serialexecution
if (serialexecution === undefined || serialexecution === null || serialexecution === "") return payload
if (isDateValue(serialexecution)) {
payload.serialexecution = null
return payload
}
if (typeof serialexecution === "string") {
const normalized = serialexecution.trim()
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)
if (!isUuid && isDateValue(normalized)) {
payload.serialexecution = null
return payload
}
}
return payload
}
function normalizeMemberPayload(payload: Record<string, any>) {
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
const normalized = {
...payload,
type: "Mitglied",
isCompany: false,
infoData,
}
return normalized
}
function validateMemberPayload(payload: Record<string, any>) {
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.filter(Boolean) : []
const firstname = typeof payload.firstname === "string" ? payload.firstname.trim() : ""
const lastname = typeof payload.lastname === "string" ? payload.lastname.trim() : ""
if (!firstname || !lastname) {
return "Für Mitglieder sind Vorname und Nachname erforderlich."
}
if (!bankAccountIds.length) {
return "Für Mitglieder muss mindestens ein Bankkonto hinterlegt werden."
}
if (infoData.hasSEPA && !infoData.sepaSignedAt) {
return "Wenn ein SEPA-Mandat hinterlegt ist, muss ein Unterschriftsdatum gesetzt werden."
}
return null
}
function maskIban(iban: string) {
if (!iban) return ""
const cleaned = iban.replace(/\s+/g, "")
if (cleaned.length <= 8) return cleaned
return `${cleaned.slice(0, 4)} **** **** ${cleaned.slice(-4)}`
}
function decryptEntityBankAccount(row: Record<string, any>) {
let iban = null
let bic = null
let bankName = null
let decryptError = null
try {
iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
} catch (err: any) {
decryptError = err?.message || "Bankverbindung konnte nicht entschlüsselt werden."
}
return {
...row,
iban,
bic,
bankName,
decryptError,
displayLabel: decryptError
? `Bankverbindung nicht lesbar${row.description ? ` (${row.description})` : ""}`
: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
}
}
function prepareEntityBankAccountPayload(payload: Record<string, any>, requireAll: boolean) {
const iban = typeof payload.iban === "string" ? payload.iban.trim() : ""
const bic = typeof payload.bic === "string" ? payload.bic.trim() : ""
const bankName = typeof payload.bankName === "string" ? payload.bankName.trim() : ""
const hasAnyPlainField = Object.prototype.hasOwnProperty.call(payload, "iban")
|| Object.prototype.hasOwnProperty.call(payload, "bic")
|| Object.prototype.hasOwnProperty.call(payload, "bankName")
if (!hasAnyPlainField && !requireAll) {
return { data: payload }
}
if (!iban || !bic || !bankName) {
return { error: "IBAN, BIC und Bankinstitut sind Pflichtfelder." }
}
const result: Record<string, any> = {
...payload,
ibanEncrypted: encrypt(iban),
bicEncrypted: encrypt(bic),
bankNameEncrypted: encrypt(bankName),
}
delete result.iban
delete result.bic
delete result.bankName
return { data: result }
}
async function validateOutgoingSepaMandatePayload(
server: FastifyInstance,
tenantId: number,
payload: Record<string, any>,
existing: Record<string, any> | null = null
) {
const customerId = Number(payload.customer ?? existing?.customer)
const bankaccountId = Number(payload.bankaccount ?? existing?.bankaccount)
if (!customerId || !bankaccountId) {
return "Kunde und Bankverbindung sind Pflichtfelder."
}
const status = payload.status ?? existing?.status ?? "Entwurf"
if (!OUTGOING_SEPA_MANDATE_STATUSES.has(status)) {
return "Ungültiger Mandatsstatus."
}
const mandateType = payload.mandateType ?? existing?.mandateType ?? "CORE"
if (!OUTGOING_SEPA_MANDATE_TYPES.has(mandateType)) {
return "Ungültiger Mandatstyp."
}
const sequenceType = payload.sequenceType ?? existing?.sequenceType ?? "RCUR"
if (!OUTGOING_SEPA_SEQUENCE_TYPES.has(sequenceType)) {
return "Ungültige Mandatssequenz."
}
const [customer] = await server.db
.select()
.from(customers)
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
.limit(1)
if (!customer) {
return "Kunde nicht gefunden."
}
const [bankaccount] = await server.db
.select()
.from(entitybankaccounts)
.where(and(eq(entitybankaccounts.id, bankaccountId), eq(entitybankaccounts.tenant, tenantId)))
.limit(1)
if (!bankaccount) {
return "Bankverbindung nicht gefunden."
}
const assignedBankAccountIds = Array.isArray((customer.infoData as any)?.bankAccountIds)
? (customer.infoData as any).bankAccountIds.map((id: any) => Number(id))
: []
if (!assignedBankAccountIds.includes(bankaccountId)) {
return "Die Bankverbindung ist dem ausgewählten Kunden nicht zugeordnet."
}
return null
}
export default async function resourceRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// CREATED DOCUMENT LIST
// -------------------------------------------------------------
server.get("/resource/createddocuments/list", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const portalCustomerId = await getPortalCustomerId(server, req)
let whereCond: any = eq(createddocuments.tenant, tenantId)
whereCond = applyResourceWhereFilters("createddocuments", createddocuments, whereCond)
whereCond = applyPortalScope(
"createddocuments",
createddocuments,
whereCond,
portalCustomerId
)
const documentRows = await server.db
.select({
id: createddocuments.id,
createdAt: createddocuments.createdAt,
type: createddocuments.type,
documentNumber: createddocuments.documentNumber,
documentDate: createddocuments.documentDate,
state: createddocuments.state,
rows: createddocuments.rows,
paymentDays: createddocuments.paymentDays,
linkedDocumentId: createddocuments.createddocument,
usedAdvanceInvoices: createddocuments.usedAdvanceInvoices,
archived: createddocuments.archived,
customerId: customers.id,
customerName: customers.name,
})
.from(createddocuments)
.leftJoin(customers, eq(createddocuments.customer, customers.id))
.where(whereCond)
.orderBy(asc(createddocuments.documentNumber))
if (documentRows.length === 0) {
return []
}
const documentIds = documentRows.map((document) => document.id)
const allocationRows = await server.db
.select({
id: statementallocations.id,
createddocument: statementallocations.createddocument,
amount: statementallocations.amount,
cashDiscountAmount: statementallocations.cashDiscountAmount,
})
.from(statementallocations)
.where(and(
eq(statementallocations.tenant, tenantId),
inArray(statementallocations.createddocument, documentIds)
))
const allocationsByDocument = new Map<number, typeof allocationRows>()
for (const allocation of allocationRows) {
if (allocation.createddocument === null) continue
const allocations = allocationsByDocument.get(allocation.createddocument) || []
allocations.push(allocation)
allocationsByDocument.set(allocation.createddocument, allocations)
}
return documentRows.map((document) => ({
id: document.id,
createdAt: document.createdAt,
type: document.type,
documentNumber: document.documentNumber,
documentDate: document.documentDate,
state: document.state,
rows: document.rows || [],
paymentDays: document.paymentDays,
createddocument: document.linkedDocumentId
? { id: document.linkedDocumentId }
: null,
usedAdvanceInvoices: document.usedAdvanceInvoices || [],
archived: document.archived,
customer: document.customerId
? { id: document.customerId, name: document.customerName }
: null,
statementallocations: allocationsByDocument.get(document.id) || [],
}))
} catch (err) {
server.log.error({ err }, "Failed to load created document list")
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// LIST
// -------------------------------------------------------------
server.get("/resource/:resource", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId)
return reply.code(400).send({ error: "No tenant selected" })
const { search, sort, asc: ascQuery } = req.query as {
search?: string
sort?: string
asc?: string
}
const { resource } = req.params as { resource: string }
const config = resourceConfig[resource]
if (!config) {
return reply.code(404).send({ error: "Unknown resource" })
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table
const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = getRelationConfig(rel)
if (relConfig) {
const relTable = relConfig.table
// FIX: Nur joinen, wenn es keine Self-Reference ist (verhindert ERROR 42712)
if (relTable !== table) {
// @ts-ignore
q = q.leftJoin(relTable, eq(table[rel], relTable.id))
if (relConfig.searchColumns) {
relConfig.searchColumns.forEach(c => {
if (relTable[c]) searchCols.push(relTable[c])
})
}
}
}
})
}
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim())
if (searchCond) whereCond = and(whereCond, searchCond)
}
q = q.where(whereCond)
if (sort) {
const col = (table as any)[sort]
if (col) {
q = ascQuery === "true" ? q.orderBy(asc(col)) : q.orderBy(desc(col))
}
}
const queryData = await q
// Transformation: Falls Joins genutzt wurden, das Hauptobjekt extrahieren
const rows = queryData.map(r => r[resource] || r.table || r);
// RELATION LOADING
let data = [...rows]
if(config.mtoLoad) {
let ids: any = {}
let lists: any = {}
let maps: any = {}
config.mtoLoad.forEach(rel => {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
})
for await (const rel of config.mtoLoad) {
const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
}
data = rows.map(row => {
let toReturn = { ...row }
config.mtoLoad.forEach(rel => {
toReturn[rel] = row[rel] ? maps[rel][row[rel]] : null
})
return toReturn
});
}
if(config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table
const parentKey = config.relationKey || resource.substring(0, resource.length - 1)
const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)))
data = data.map(row => ({
...row,
[relation]: relationRows.filter(i => i[parentKey] === row.id)
}))
}
}
if (resource === "entitybankaccounts") {
return data.map((row) => decryptEntityBankAccount(row))
}
return data
} catch (err) {
console.error("ERROR /resource/:resource", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// PAGINATED LIST
// -------------------------------------------------------------
server.get("/resource/:resource/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id;
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const config = resourceConfig[resource];
if (!config) {
return reply.code(404).send({ error: "Unknown resource" });
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table;
const { queryConfig } = req;
const { pagination, sort, filters } = queryConfig;
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
const parsedFilters: Array<{ key: string; value: any }> = []
let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic();
let mainQuery = server.db.select().from(table).$dynamic();
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = getRelationConfig(rel)
if (relConfig) {
const relTable = relConfig.table;
// FIX: Self-Reference Check
if (relTable !== table) {
countQuery = countQuery.leftJoin(relTable, eq(table[rel], relTable.id));
// @ts-ignore
mainQuery = mainQuery.leftJoin(relTable, eq(table[rel], relTable.id));
if (relConfig.searchColumns) {
relConfig.searchColumns.forEach(c => {
if (relTable[c]) {
searchCols.push(relTable[c]);
debugSearchColumnNames.push(`${rel}.${c}`);
}
});
}
}
}
});
}
if (search) {
if (resource === "customers") {
const rawSearch = search.trim()
const terms = rawSearch.toLowerCase().split(/\s+/).filter(Boolean)
const normalizedTerms = terms
.map((t) => t.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ß/g, "ss"))
server.log.info({
tag: "customer-search-debug",
search: rawSearch,
terms,
normalizedTerms,
searchColumns: debugSearchColumnNames,
page: pagination?.page ?? 1,
limit: pagination?.limit ?? 100,
}, "Paginated customer search request")
}
const searchCond = buildSearchCondition(searchCols, search.trim());
if (searchCond) whereCond = and(whereCond, searchCond);
}
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (table as any)[key];
if (!col) continue;
parsedFilters.push({ key, value: val })
whereCond = Array.isArray(val) ? and(whereCond, inArray(col, val)) : and(whereCond, eq(col, val as any));
}
}
const totalRes = await countQuery.where(whereCond);
const total = Number(totalRes[0]?.value ?? 0);
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? 100;
mainQuery = mainQuery.where(whereCond).offset(offset).limit(limit);
if (sort?.length > 0) {
const s = sort[0];
const col = (table as any)[s.field];
if (col) {
mainQuery = s.direction === "asc" ? mainQuery.orderBy(asc(col)) : mainQuery.orderBy(desc(col));
}
}
const rawRows = await mainQuery;
// Transformation für Drizzle Joins
let rows = rawRows.map(r => r[resource] || r.table || r);
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName];
if (!col) continue;
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = getRelationConfig(rel)
if (!relConfig) return;
const relTable = relConfig.table;
if (relTable !== table) {
distinctQuery = distinctQuery.leftJoin(relTable, eq(table[rel], relTable.id));
}
});
}
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond, true)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim())
if (searchCond) distinctWhereCond = and(distinctWhereCond, searchCond)
}
for (const f of parsedFilters) {
if (f.key === colName) continue
const filterCol = (table as any)[f.key]
if (!filterCol) continue
distinctWhereCond = Array.isArray(f.value)
? and(distinctWhereCond, inArray(filterCol, f.value))
: and(distinctWhereCond, eq(filterCol, f.value as any))
}
const dRows = await distinctQuery.where(distinctWhereCond);
distinctValues[colName] = [...new Set(dRows.map(r => r.v).filter(v => v != null && v !== ""))].sort();
}
}
let data = [...rows];
if (config.mtoLoad) {
let ids: any = {};
let lists: any = {};
let maps: any = {};
config.mtoLoad.forEach(rel => {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
});
for await (const rel of config.mtoLoad) {
const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table;
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
}
data = rows.map(row => {
let toReturn = { ...row };
config.mtoLoad.forEach(rel => {
toReturn[rel] = row[rel] ? maps[rel][row[rel]] : null;
});
return toReturn;
});
}
if (config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table;
const parentKey = config.relationKey || resource.substring(0, resource.length - 1);
const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)));
data = data.map(row => ({
...row,
[relation]: relationRows.filter(i => i[parentKey] === row.id)
}));
}
}
if (resource === "entitybankaccounts") {
data = data.map((row) => decryptEntityBankAccount(row))
}
return {
data,
queryConfig: { ...queryConfig, total, totalPages: Math.ceil(total / limit), distinctValues }
};
} catch (err) {
console.error(`ERROR /resource/:resource/paginated:`, err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// DETAIL
// -------------------------------------------------------------
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
try {
const { id } = req.params as { id: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const projRows = await server.db
.select()
.from(table)
.where(whereCond)
.limit(1)
if (!projRows.length)
return reply.code(404).send({ error: "Resource not found" })
let data = { ...projRows[0] }
if (!no_relations) {
if (resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad) {
if (data[relation]) {
const relConf = getRelationConfig(relation)
if (!relConf) continue
const relTable = relConf.table
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
data[relation] = relData[0] || null
}
}
}
if (resourceConfig[resource].mtmLoad) {
for await (const relation of resourceConfig[resource].mtmLoad) {
const relTable = resourceConfig[relation].table
const parentKey = resourceConfig[resource].relationKey || resource.substring(0, resource.length - 1)
data[relation] = await server.db.select().from(relTable).where(eq(relTable[parentKey], id))
}
}
}
if (resource === "entitybankaccounts") {
return decryptEntityBankAccount(data)
}
return data
} catch (err) {
console.error("ERROR /resource/:resource/:id", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// Create
server.post("/resource/:resource", async (req, reply) => {
try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId) {
return reply.code(403).send({ error: "Forbidden" })
}
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any>;
const config = resourceConfig[resource];
const table = config.table;
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
if (table === authProfiles) {
delete createData.archived_at;
createData.tenant_id = req.user.tenant_id;
if (createData.user_id) {
const [owner] = await server.db.select({ id: authUsers.id }).from(authUsers)
.where(and(eq(authUsers.id, createData.user_id), isNull(authUsers.archived_at))).limit(1);
if (!owner) return reply.code(409).send({ error: "Aktiver Benutzer nicht gefunden." });
}
}
if (resource === "projects") {
const error = projectChatSettingError(req, createData)
if (error) return reply.code(error.statusCode).send({ error: error.error })
}
if (resource === "members") {
createData = normalizeMemberPayload(createData)
const validationError = validateMemberPayload(createData)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "entitybankaccounts") {
const prepared = prepareEntityBankAccountPayload(createData, true)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
createData = prepared.data!
}
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
req.user.tenant_id,
null,
createData.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "outgoingsepamandates") {
const validationError = await validateOutgoingSepaMandatePayload(server, req.user.tenant_id, createData)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "createddocuments") {
createData = normalizeCreatedDocumentPayload(createData)
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource
const numberRangeColumn = table[config.numberRangeHolder]
const tenantColumn = getTenantColumn(resource, table)
const result = await useNextNumberRangeNumber(
server,
req.user.tenant_id,
numberRangeResource,
async (candidate, tx) => {
const existing = await tx
.select({ id: table.id })
.from(table)
.where(and(
eq(tenantColumn, req.user!.tenant_id),
eq(numberRangeColumn, candidate)
))
.limit(1)
return existing.length === 0
}
)
createData[config.numberRangeHolder] = result.usedNumber
}
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
Object.keys(createData).forEach((key) => {
const value = createData[key]
const shouldNormalize =
isDateLikeField(key) &&
value !== null &&
value !== undefined &&
(typeof value === "string" || typeof value === "number" || value instanceof Date)
if (shouldNormalize) {
createData[key] = normalizeDate(value)
}
})
const [created] = await server.db.insert(table).values(createData).returning()
if (resource === "outgoingsepamandates" && created?.defaultMandate) {
await server.db
.update(table)
.set({ defaultMandate: false })
.where(and(
eq(table.tenant, req.user.tenant_id),
eq(table.customer, created.customer),
sql`${table.id} <> ${created.id}`
))
}
if (["products", "services", "hourrates"].includes(resource)) {
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
}
if (created) {
try {
const resourceLabel = getHistoryEntityLabel(resource)
await insertHistoryItem(server, {
tenant_id: req.user.tenant_id,
created_by: req.user?.user_id || null,
entity: resource,
entityId: created.id,
action: "created",
oldVal: null,
newVal: created,
text: `Neuer Eintrag in ${resourceLabel} erstellt`,
})
} catch (historyError) {
server.log.warn({ err: historyError, resource }, "Failed to write create history entry")
}
}
if (resource === "entitybankaccounts") {
return decryptEntityBankAccount(created as Record<string, any>)
}
return created;
} catch (error) {
console.error(error);
reply.status(500);
}
});
// Update
server.put("/resource/:resource/:id", async (req, reply) => {
try {
const { resource, id } = req.params as { resource: string; id: string }
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
const portalCustomerId = await getPortalCustomerId(server, req)
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
if (portalCustomerId && resource !== "customers") {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
const [oldRecord] = await server.db
.select()
.from(table)
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)), portalCustomerId))
.limit(1)
if (!oldRecord) {
return reply.code(404).send({ error: "Resource not found" })
}
if (resource === "projects") {
const error = projectChatSettingError(req, body, oldRecord.chatEnabled)
if (error) return reply.code(error.statusCode).send({ error: error.error })
}
if (table === authProfiles && oldRecord.archived_at) {
return reply.code(409).send({ error: "Archivierte Profile können nicht mehr bearbeitet werden." })
}
let data: Record<string, any> = { ...body }
if (table === authProfiles) {
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete data[key]
}
delete data.updatedAt
delete data.updated_at
delete data.updatedBy
delete data.updated_by
const updatedAt = new Date()
if (Object.prototype.hasOwnProperty.call(table, "updatedAt")) data.updatedAt = updatedAt
if (Object.prototype.hasOwnProperty.call(table, "updated_at")) data.updated_at = updatedAt
if (Object.prototype.hasOwnProperty.call(table, "updatedBy")) data.updatedBy = userId
if (Object.prototype.hasOwnProperty.call(table, "updated_by")) data.updated_by = userId
if (resource === "filetags") {
delete data.isSystemUsed
if (oldRecord.isSystemUsed && data.archived === true) {
return reply.code(400).send({ error: "System-Dateitypen können nicht archiviert werden" })
}
}
if (portalCustomerId) {
data = {
...sanitizePortalCustomerUpdate(data),
}
if (Object.prototype.hasOwnProperty.call(table, "updatedAt")) data.updatedAt = updatedAt
if (Object.prototype.hasOwnProperty.call(table, "updated_at")) data.updated_at = updatedAt
if (Object.prototype.hasOwnProperty.call(table, "updatedBy")) data.updatedBy = userId
if (Object.prototype.hasOwnProperty.call(table, "updated_by")) data.updated_by = userId
}
if (resource === "members") {
data = normalizeMemberPayload(data)
const validationError = validateMemberPayload(data)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "entitybankaccounts") {
const prepared = prepareEntityBankAccountPayload(data, false)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
data = {
...prepared.data,
}
if (Object.prototype.hasOwnProperty.call(table, "updatedAt")) data.updatedAt = updatedAt
if (Object.prototype.hasOwnProperty.call(table, "updated_at")) data.updated_at = updatedAt
if (Object.prototype.hasOwnProperty.call(table, "updatedBy")) data.updatedBy = userId
if (Object.prototype.hasOwnProperty.call(table, "updated_by")) data.updated_by = userId
}
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
tenantId,
oldRecord.id,
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
? data.parentCostcentre || null
: oldRecord.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "outgoingsepamandates") {
const validationError = await validateOutgoingSepaMandatePayload(server, tenantId, data, oldRecord)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "createddocuments") {
data = normalizeCreatedDocumentPayload(data)
}
Object.keys(data).forEach((key) => {
const value = data[key]
const shouldNormalize =
isDateLikeField(key) &&
value !== null &&
value !== undefined &&
(typeof value === "string" || typeof value === "number" || value instanceof Date)
if (shouldNormalize) {
data[key] = normalizeDate(value)
}
})
let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
if (table === authProfiles) updateWhereCond = and(updateWhereCond, isNull(authProfiles.archived_at))
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
if (resource === "outgoingsepamandates" && updated?.defaultMandate) {
await server.db
.update(table)
.set({ defaultMandate: false })
.where(and(
eq(getTenantColumn(resource, table), tenantId),
eq(table.customer, updated.customer),
sql`${table.id} <> ${updated.id}`
))
}
if (["products", "services", "hourrates"].includes(resource)) {
await recalculateServicePricesForTenant(server, tenantId, userId);
}
if (updated) {
try {
const resourceLabel = getHistoryEntityLabel(resource)
const changes = oldRecord ? getUserVisibleChanges(oldRecord, updated) : []
if (!changes.length) {
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: resource,
entityId: updated.id,
action: "updated",
oldVal: oldRecord || null,
newVal: updated,
text: `Eintrag in ${resourceLabel} geändert`,
})
} else {
for (const change of changes) {
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: resource,
entityId: updated.id,
action: "updated",
oldVal: change.oldValue,
newVal: change.newValue,
text: buildFieldUpdateHistoryText(resource, change.label, change.oldValue, change.newValue),
})
}
}
} catch (historyError) {
server.log.warn({ err: historyError, resource, id }, "Failed to write update history entry")
}
}
if (resource === "entitybankaccounts") {
return decryptEntityBankAccount(updated as Record<string, any>)
}
return updated
} catch (err) {
console.error(err)
}
})
}