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>
1282 lines
51 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
}
|