import { FastifyInstance } from "fastify" import { eq, ilike, asc, desc, and, count, inArray, or, sql, } from "drizzle-orm" import { authProfiles, costcentres, customers, entitybankaccounts } 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"] // ------------------------------------------------------------- // 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, updated: Record) { 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) { 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) { 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 normalizeMemberPayload(payload: Record) { const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {} const normalized = { ...payload, type: "Mitglied", isCompany: false, infoData, } return normalized } function validateMemberPayload(payload: Record) { 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 } async function validateCostCentreParent( server: FastifyInstance, tenantId: number, costCentreId: string | null, parentCostcentreId: string | null ) { if (!parentCostcentreId) { return null } const hierarchyRows = await server.db .select({ id: costcentres.id, parentCostcentre: costcentres.parentCostcentre, }) .from(costcentres) .where(eq(costcentres.tenant, tenantId)) const hierarchyMap = new Map( hierarchyRows.map((row) => [row.id, row.parentCostcentre || null]) ) if (!hierarchyMap.has(parentCostcentreId)) { return "Die übergeordnete Kostenstelle wurde nicht gefunden." } if (costCentreId && parentCostcentreId === costCentreId) { return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben." } if (!costCentreId) { return null } let currentParentId: string | null = parentCostcentreId const visited = new Set() while (currentParentId) { if (currentParentId === costCentreId) { return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen." } if (visited.has(currentParentId)) { break } visited.add(currentParentId) currentParentId = hierarchyMap.get(currentParentId) || null } 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) { const iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null const bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null const bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null return { ...row, iban, bic, bankName, displayLabel: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(), } } function prepareEntityBankAccountPayload(payload: Record, 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 = { ...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, existing: Record | 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 [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) { // ------------------------------------------------------------- // 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) 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) 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 = {}; 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) 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(table.tenant, 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; const config = resourceConfig[resource]; const table = config.table; let createData: Record = { ...body, tenant: req.user.tenant_id, archived: false }; 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 (config.numberRangeHolder && !body[config.numberRangeHolder]) { const numberRangeResource = resource === "members" ? "customers" : resource const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource) 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) => { if (key.toLowerCase().includes("date") && key !== "deliveryDateType") createData[key] = normalizeDate(createData[key]) }) 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) } 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 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(table.tenant, tenantId)), portalCustomerId)) .limit(1) if (!oldRecord) { return reply.code(404).send({ error: "Resource not found" }) } let data: Record = { ...body, updated_at: new Date().toISOString(), updated_by: userId } //@ts-ignore delete data.updatedBy; delete data.updatedAt; if (portalCustomerId) { data = { ...sanitizePortalCustomerUpdate(data), updated_at: data.updated_at, updated_by: data.updated_by, } } 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, updated_at: data.updated_at, updated_by: data.updated_by, } } 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 }) } } 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(table.tenant, tenantId)) 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(table.tenant, 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) } return updated } catch (err) { console.error(err) } }) }