diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts index 97def75..2ecb3e6 100644 --- a/backend/src/mcp/registry.ts +++ b/backend/src/mcp/registry.ts @@ -1,11 +1,13 @@ import { accountingTools } from "./tools/accounting" import { masterdataTools } from "./tools/masterdata" import { organisationTools } from "./tools/organisation" +import { wikiTools } from "./tools/wiki" export const mcpTools = [ ...accountingTools, ...masterdataTools, ...organisationTools, + ...wikiTools, ] export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) diff --git a/backend/src/mcp/tools/wiki.ts b/backend/src/mcp/tools/wiki.ts new file mode 100644 index 0000000..1614a15 --- /dev/null +++ b/backend/src/mcp/tools/wiki.ts @@ -0,0 +1,322 @@ +import { and, asc, eq, ilike, isNull } from "drizzle-orm" +import { wikiPages } from "../../../db/schema" +import { McpContext, McpTool } from "../types" + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +type WikiScope = { + entityType: string | null + entityId: number | null + entityUuid: string | null +} + +const stringArg = (args: Record, key: string) => { + const value = args[key] + return typeof value === "string" && value.trim() ? value.trim() : null +} + +const uuidArg = (args: Record, key: string) => { + const value = stringArg(args, key) + if (!value || !UUID_PATTERN.test(value)) return null + return value +} + +const pageIdArg = (args: Record, key = "id") => { + const value = uuidArg(args, key) + if (!value) throw new Error(`${key} muss eine gültige UUID sein`) + return value +} + +const limitFromArgs = (args: Record) => { + const value = Number(args.limit ?? 50) + if (!Number.isFinite(value)) return 50 + return Math.min(Math.max(Math.trunc(value), 1), 100) +} + +const offsetFromArgs = (args: Record) => { + const value = Number(args.offset ?? 0) + if (!Number.isFinite(value)) return 0 + return Math.max(Math.trunc(value), 0) +} + +export function prepareWikiScope(args: Record): WikiScope { + const entityType = stringArg(args, "entityType") + const hasEntityId = args.entityId !== undefined && args.entityId !== null + const hasEntityUuid = args.entityUuid !== undefined && args.entityUuid !== null + + if (!entityType && (hasEntityId || hasEntityUuid)) { + throw new Error("entityType ist für ein Entitäts-Wiki erforderlich") + } + if (entityType && !hasEntityId && !hasEntityUuid) { + throw new Error("Für ein Entitäts-Wiki ist entityId oder entityUuid erforderlich") + } + if (hasEntityId && hasEntityUuid) { + throw new Error("Bitte entweder entityId oder entityUuid angeben, nicht beides") + } + if (!entityType) { + return { entityType: null, entityId: null, entityUuid: null } + } + + if (hasEntityId) { + const entityId = Number(args.entityId) + if (!Number.isSafeInteger(entityId) || entityId <= 0) { + throw new Error("entityId muss eine positive ganze Zahl sein") + } + return { entityType, entityId, entityUuid: null } + } + + const entityUuid = uuidArg(args, "entityUuid") + if (!entityUuid) throw new Error("entityUuid muss eine gültige UUID sein") + return { entityType, entityId: null, entityUuid } +} + +const scopeConditions = (scope: WikiScope) => [ + scope.entityType === null ? isNull(wikiPages.entityType) : eq(wikiPages.entityType, scope.entityType), + scope.entityId === null ? isNull(wikiPages.entityId) : eq(wikiPages.entityId, scope.entityId), + scope.entityUuid === null ? isNull(wikiPages.entityUuid) : eq(wikiPages.entityUuid, scope.entityUuid), +] + +const scopeFromPage = (page: { + entityType: string | null + entityId: number | null + entityUuid: string | null +}): WikiScope => ({ + entityType: page.entityType, + entityId: page.entityId, + entityUuid: page.entityUuid, +}) + +const sameScope = (left: WikiScope, right: WikiScope) => + left.entityType === right.entityType + && left.entityId === right.entityId + && left.entityUuid === right.entityUuid + +async function loadPage(context: McpContext, id: string) { + const [page] = await context.server.db + .select() + .from(wikiPages) + .where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId))) + .limit(1) + + if (!page) throw new Error("Wiki-Seite nicht gefunden") + return page +} + +async function assertValidParent( + context: McpContext, + parentId: string | null, + scope: WikiScope, + pageId?: string, +) { + if (!parentId) return + if (parentId === pageId) throw new Error("Eine Wiki-Seite kann nicht ihr eigener übergeordneter Eintrag sein") + + const parent = await loadPage(context, parentId) + if (!parent.isFolder) throw new Error("Der übergeordnete Wiki-Eintrag muss ein Ordner sein") + if (!sameScope(scopeFromPage(parent), scope)) { + throw new Error("Übergeordneter Eintrag und Wiki-Seite müssen zum selben Wiki gehören") + } +} + +const entityScopeProperties = { + entityType: { type: "string", description: "Technischer Entitätstyp, z. B. customers oder projects. Ohne Angabe wird das allgemeine Wiki verwendet." }, + entityId: { type: "number", description: "Numerische ID der Entität; nur zusammen mit entityType und alternativ zu entityUuid." }, + entityUuid: { type: "string", format: "uuid", description: "UUID der Entität; nur zusammen mit entityType und alternativ zu entityId." }, +} + +const pageProperties = { + title: { type: "string", description: "Titel der Wiki-Seite oder des Ordners." }, + content: { description: "Inhalt als JSON, üblicherweise ein Tiptap/ProseMirror-Dokument." }, + parentId: { type: ["string", "null"], format: "uuid", description: "Übergeordneter Ordner; null verschiebt den Eintrag auf die oberste Ebene." }, + sortOrder: { type: "integer", description: "Sortierreihenfolge innerhalb derselben Ebene." }, + isFolder: { type: "boolean", description: "Kennzeichnet den Eintrag als Ordner." }, +} + +export const wikiTools: McpTool[] = [ + { + name: "wiki.pages.list", + title: "Wiki-Seiten auflisten", + description: "Listet Seiten und Ordner des allgemeinen Wikis oder des Wikis einer einzelnen Entität. Der Inhalt wird nicht mitgeladen; dafür wiki.pages.get verwenden.", + requiredPermissions: ["wiki.read"], + inputSchema: { + type: "object", + properties: { + ...entityScopeProperties, + query: { type: "string", description: "Optionaler Suchtext im Titel." }, + parentId: { type: ["string", "null"], format: "uuid", description: "Optional auf direkte Kinder dieses Ordners einschränken; null steht für die oberste Ebene." }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, + offset: { type: "integer", minimum: 0, default: 0 }, + }, + }, + async handler(context, args) { + const scope = prepareWikiScope(args) + const conditions = [eq(wikiPages.tenantId, context.tenantId), ...scopeConditions(scope)] + const query = stringArg(args, "query") + if (query) conditions.push(ilike(wikiPages.title, `%${query}%`)) + if (Object.prototype.hasOwnProperty.call(args, "parentId")) { + if (args.parentId === null) conditions.push(isNull(wikiPages.parentId)) + else conditions.push(eq(wikiPages.parentId, pageIdArg(args, "parentId"))) + } + + const rows = await context.server.db + .select({ + id: wikiPages.id, + parentId: wikiPages.parentId, + title: wikiPages.title, + isFolder: wikiPages.isFolder, + sortOrder: wikiPages.sortOrder, + entityType: wikiPages.entityType, + entityId: wikiPages.entityId, + entityUuid: wikiPages.entityUuid, + createdAt: wikiPages.createdAt, + updatedAt: wikiPages.updatedAt, + }) + .from(wikiPages) + .where(and(...conditions)) + .orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title)) + .limit(limitFromArgs(args)) + .offset(offsetFromArgs(args)) + + return { rows } + }, + }, + { + name: "wiki.pages.get", + title: "Wiki-Seite laden", + description: "Lädt eine Wiki-Seite einschließlich ihres Inhalts anhand ihrer UUID.", + requiredPermissions: ["wiki.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "string", format: "uuid" } }, + }, + async handler(context, args) { + return { page: await loadPage(context, pageIdArg(args)) } + }, + }, + { + name: "wiki.pages.create", + title: "Wiki-Seite erstellen", + description: "Erstellt eine Seite oder einen Ordner im allgemeinen Wiki oder im Wiki einer einzelnen Entität.", + requiredPermissions: ["wiki.write"], + inputSchema: { + type: "object", + required: ["title"], + properties: { + ...pageProperties, + ...entityScopeProperties, + }, + }, + async handler(context, args) { + const title = stringArg(args, "title") + if (!title) throw new Error("title ist erforderlich") + const scope = prepareWikiScope(args) + const parentId = args.parentId === undefined || args.parentId === null + ? null + : pageIdArg(args, "parentId") + await assertValidParent(context, parentId, scope) + + const sortOrder = args.sortOrder === undefined ? 0 : Number(args.sortOrder) + if (!Number.isInteger(sortOrder)) throw new Error("sortOrder muss eine ganze Zahl sein") + + const [page] = await context.server.db + .insert(wikiPages) + .values({ + tenantId: context.tenantId, + title, + content: args.content ?? null, + parentId, + sortOrder, + isFolder: args.isFolder === true, + ...scope, + createdBy: context.userId, + updatedBy: context.userId, + }) + .returning() + + return { page } + }, + }, + { + name: "wiki.pages.update", + title: "Wiki-Seite bearbeiten", + description: "Bearbeitet Titel, Inhalt, Position oder Ordnereigenschaften einer Wiki-Seite. Funktioniert für das allgemeine Wiki und für Entitäts-Wikis.", + requiredPermissions: ["wiki.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", format: "uuid" }, + ...pageProperties, + }, + }, + async handler(context, args) { + const id = pageIdArg(args) + const existing = await loadPage(context, id) + const update: Record = { + updatedAt: new Date(), + updatedBy: context.userId, + } + let changed = false + + if (args.title !== undefined) { + const title = stringArg(args, "title") + if (!title) throw new Error("title darf nicht leer sein") + update.title = title + changed = true + } + if (Object.prototype.hasOwnProperty.call(args, "content")) { + update.content = args.content ?? null + changed = true + } + if (Object.prototype.hasOwnProperty.call(args, "parentId")) { + const parentId = args.parentId === null ? null : pageIdArg(args, "parentId") + await assertValidParent(context, parentId, scopeFromPage(existing), id) + update.parentId = parentId + changed = true + } + if (args.sortOrder !== undefined) { + const sortOrder = Number(args.sortOrder) + if (!Number.isInteger(sortOrder)) throw new Error("sortOrder muss eine ganze Zahl sein") + update.sortOrder = sortOrder + changed = true + } + if (args.isFolder !== undefined) { + if (typeof args.isFolder !== "boolean") throw new Error("isFolder muss ein boolescher Wert sein") + update.isFolder = args.isFolder + changed = true + } + if (!changed) throw new Error("Mindestens ein zu änderndes Feld ist erforderlich") + + const [page] = await context.server.db + .update(wikiPages) + .set(update) + .where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId))) + .returning() + + if (!page) throw new Error("Wiki-Seite nicht gefunden") + return { page } + }, + }, + { + name: "wiki.pages.delete", + title: "Wiki-Seite löschen", + description: "Löscht eine Wiki-Seite oder einen Wiki-Ordner samt untergeordneten Einträgen.", + requiredPermissions: ["wiki.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "string", format: "uuid" } }, + }, + async handler(context, args) { + const id = pageIdArg(args) + const deleted = await context.server.db + .delete(wikiPages) + .where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId))) + .returning({ id: wikiPages.id }) + + if (!deleted[0]) throw new Error("Wiki-Seite nicht gefunden") + return { deletedId: deleted[0].id } + }, + }, +] diff --git a/backend/src/modules/banking-institution.ts b/backend/src/modules/banking-institution.ts new file mode 100644 index 0000000..1c9de3e --- /dev/null +++ b/backend/src/modules/banking-institution.ts @@ -0,0 +1,22 @@ +export type BankingInstitution = { + bic?: unknown + [key: string]: unknown +} + +export const findBankInstitutionByBic = ( + institutions: unknown, + bic: string, +): BankingInstitution | null => { + if (!Array.isArray(institutions)) return null + + const normalizedBic = bic.trim().toUpperCase() + if (!normalizedBic) return null + + const institution = institutions.find((candidate): candidate is BankingInstitution => { + if (!candidate || typeof candidate !== "object") return false + const candidateBic = (candidate as BankingInstitution).bic + return typeof candidateBic === "string" && candidateBic.trim().toUpperCase() === normalizedBic + }) + + return institution || null +} diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index 13c94b7..a6bd9dc 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -50,6 +50,8 @@ const adminPermissions = [ "organisation.events.read", "organisation.tasks.read", "organisation.tasks.write", + "wiki.read", + "wiki.write", ] const defaultTaxTypes = [ diff --git a/backend/src/modules/cron/bankstatementsync.service.ts b/backend/src/modules/cron/bankstatementsync.service.ts index 71aa17d..98e08ca 100644 --- a/backend/src/modules/cron/bankstatementsync.service.ts +++ b/backend/src/modules/cron/bankstatementsync.service.ts @@ -52,6 +52,21 @@ const normalizeDate = (val: any) => { return isNaN(d.getTime()) ? null : d } +export const getBankAccountOwnerName = (account: any) => { + const ownerName = typeof account?.owner_name === "string" ? account.owner_name.trim() : "" + return ownerName || null +} + +export const isExpiredBankingError = (error: any) => { + const values = [ + error?.response?.data?.summary, + error?.response?.data?.detail, + error?.response?.data?.message, + error?.message, + ] + return values.some((value) => typeof value === "string" && value.toLowerCase().includes("expired")) +} + export function bankStatementService(server: FastifyInstance) { let accessToken: string | null = null @@ -78,7 +93,7 @@ export function bankStatementService(server: FastifyInstance) { // ----------------------------------------------- // ✔ Salden laden // ----------------------------------------------- - const getBalanceData = async (accountId: string): Promise => { + const getBalanceData = async (accountId: string, tenantId: number): Promise => { try { if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId) const {data} = await axios.get( @@ -95,21 +110,37 @@ export function bankStatementService(server: FastifyInstance) { } catch (err: any) { server.log.error(err.response?.data ?? err) - const expired = - err.response?.data?.summary?.includes("expired") || - err.response?.data?.detail?.includes("expired") - - if (expired) { + if (isExpiredBankingError(err)) { await server.db .update(bankaccounts) .set({expired: true}) - .where(eq(bankaccounts.accountId, accountId)) + .where(and( + eq(bankaccounts.accountId, accountId), + eq(bankaccounts.tenant, tenantId), + )) } throw err } } + // ----------------------------------------------- + // ✔ Kontoinhaber laden + // ----------------------------------------------- + const getAccountData = async (accountId: string): Promise => { + if (useCentralBanking) return await centralServicesClient.getBankingAccount(accountId) + const {data} = await axios.get( + `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + } + ) + return data + } + // ----------------------------------------------- // ✔ Transaktionen laden // ----------------------------------------------- @@ -162,10 +193,26 @@ export function bankStatementService(server: FastifyInstance) { for (const account of accounts) { try { + // --------------------------- + // 0. KONTOINHABER SYNC + // --------------------------- + try { + const accountData = await getAccountData(account.accountId) + const ownerName = getBankAccountOwnerName(accountData) + if (ownerName && ownerName !== account.ownerName) { + await server.db + .update(bankaccounts) + .set({ownerName}) + .where(eq(bankaccounts.id, account.id)) + } + } catch (error: any) { + server.log.warn({err: error, accountId: account.accountId}, "Kontoinhaber konnte nicht synchronisiert werden") + } + // --------------------------- // 1. BALANCE SYNC // --------------------------- - const balData = await getBalanceData(account.accountId) + const balData = await getBalanceData(account.accountId, tenantId) if (balData) { const closing = balData.balances.find( diff --git a/backend/src/modules/push-server.client.ts b/backend/src/modules/push-server.client.ts index 1a0fc61..37ee7ea 100644 --- a/backend/src/modules/push-server.client.ts +++ b/backend/src/modules/push-server.client.ts @@ -86,7 +86,12 @@ async function requestPushServer(method: "GET" | "POST" | "DELETE", path: str if (!response.ok) { const message = data?.message || data?.error || `Push-Server Anfrage fehlgeschlagen (${response.status})` - throw new Error(message) + const error = Object.assign(new Error(message), { + code: data?.code || data?.error, + status: response.status, + response: { data }, + }) + throw error } return data as T diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 09351af..45177fc 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,7 +1,11 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import multipart from "@fastify/multipart"; -import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import { randomUUID } from "node:crypto"; +import { createReadStream, createWriteStream, openAsBlob } from "node:fs"; +import { stat, unlink } from "node:fs/promises"; +import { pipeline } from "node:stream/promises"; import { authTenantUsers, @@ -463,8 +467,9 @@ export default async function adminRoutes(server: FastifyInstance) { const startTenantImportJob = async ( jobId: string, currentUser: { id: string; email: string }, - source: { archiveBuffer: Buffer } | { exportData: TenantFullExport } + source: { archiveStoragePath: string } | { exportData: TenantFullExport } ) => { + let temporaryArchivePath: string | null = null; try { await server.db .update(tenantExportJobs) @@ -487,9 +492,21 @@ export default async function adminRoutes(server: FastifyInstance) { .where(eq(tenantExportJobs.id, jobId)); }; - const result = "archiveBuffer" in source - ? await importTenantFullExportArchive(server, source.archiveBuffer, { onProgress }) - : await importTenantFullExport(server, source.exportData, { onProgress }); + let result: Awaited>; + if ("archiveStoragePath" in source) { + const object = await s3.send(new GetObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: source.archiveStoragePath, + })); + if (!object.Body) throw new Error("Importarchiv konnte nicht aus dem Speicher gelesen werden"); + + temporaryArchivePath = `${process.env.TMPDIR || "/tmp"}/fedeo-tenant-import-${randomUUID()}.zip`; + await pipeline(object.Body as NodeJS.ReadableStream, createWriteStream(temporaryArchivePath, { flags: "wx" })); + const archive = await openAsBlob(temporaryArchivePath); + result = await importTenantFullExportArchive(server, archive, { onProgress }); + } else { + result = await importTenantFullExport(server, source.exportData, { onProgress }); + } const access = await completeImportedTenantAccess(currentUser, result); const importResult = { success: true, ...access, ...result }; @@ -517,6 +534,20 @@ export default async function adminRoutes(server: FastifyInstance) { updatedAt: new Date(), }) .where(eq(tenantExportJobs.id, jobId)); + } finally { + if (temporaryArchivePath) { + await unlink(temporaryArchivePath).catch((cleanupError) => { + console.error("ERROR cleanup temporary tenant import archive:", cleanupError); + }); + } + if ("archiveStoragePath" in source) { + await s3.send(new DeleteObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: source.archiveStoragePath, + })).catch((cleanupError) => { + console.error("ERROR cleanup tenant import archive:", cleanupError); + }); + } } }; @@ -1425,12 +1456,14 @@ export default async function adminRoutes(server: FastifyInstance) { // POST /admin/tenant-imports // ------------------------------------------------------------- server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => { + let archiveStoragePath: string | null = null; + let temporaryArchivePath: string | null = null; try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const isMultipart = req.headers["content-type"]?.includes("multipart/form-data"); - let source!: { archiveBuffer: Buffer } | { exportData: TenantFullExport }; + let source!: { archiveStoragePath: string } | { exportData: TenantFullExport }; let filename = "tenant-export.json"; let fileSize: number | null = null; let targetTenantId: number | null = null; @@ -1439,9 +1472,7 @@ export default async function adminRoutes(server: FastifyInstance) { const data: any = await req.file(); if (!data?.file) return reply.code(400).send({ error: "export file required" }); - const archiveBuffer = await data.toBuffer(); filename = data.filename || "tenant-export.fedeo-export.zip"; - fileSize = archiveBuffer.length; targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; if (targetTenantId) { return reply.code(409).send({ @@ -1449,7 +1480,21 @@ export default async function adminRoutes(server: FastifyInstance) { }); } - source = { archiveBuffer }; + archiveStoragePath = `tenant-imports/${randomUUID()}-${filename}`; + temporaryArchivePath = `${process.env.TMPDIR || "/tmp"}/fedeo-tenant-import-${randomUUID()}.zip`; + await pipeline(data.file, createWriteStream(temporaryArchivePath, { flags: "wx" })); + const temporaryArchive = await stat(temporaryArchivePath); + await s3.send(new PutObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: archiveStoragePath, + Body: createReadStream(temporaryArchivePath), + ContentLength: temporaryArchive.size, + ContentType: data.mimetype || "application/zip", + })); + fileSize = temporaryArchive.size; + await unlink(temporaryArchivePath); + temporaryArchivePath = null; + source = { archiveStoragePath }; } else { const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; const exportData = "format" in body ? body : body.exportData; @@ -1493,6 +1538,19 @@ export default async function adminRoutes(server: FastifyInstance) { }); } catch (err: any) { console.error("ERROR /admin/tenant-imports:", err); + if (temporaryArchivePath) { + await unlink(temporaryArchivePath).catch((cleanupError) => { + console.error("ERROR cleanup temporary tenant import archive:", cleanupError); + }); + } + if (archiveStoragePath) { + await s3.send(new DeleteObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: archiveStoragePath, + })).catch((cleanupError) => { + console.error("ERROR cleanup tenant import archive:", cleanupError); + }); + } const message = err?.message || "Internal Server Error"; const statusCode = message.includes("Tenant mit dieser ID existiert bereits") ? 409 : 500; return reply.code(statusCode).send({ error: message }); diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index ce25ef0..d80da56 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -8,6 +8,7 @@ import { decrypt, encrypt } from "../utils/crypt" import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { centralServicesClient } from "../modules/push-server.client" +import { findBankInstitutionByBic } from "../modules/banking-institution" import { bankrequisitions, @@ -997,7 +998,10 @@ export default async function bankingRoutes(server: FastifyInstance) { if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) - const redirect = new URL("/settings/banking", secrets.API_BASE_URL).toString() + // API_BASE_URL kann einen Reverse-Proxy-Pfad wie `/backend` enthalten. + // Der OAuth-Rücksprung muss aber auf die Frontend-Route zeigen und darf + // diesen Backend-Pfad nicht erneut enthalten. + const redirect = new URL("/settings/banking", new URL(secrets.API_BASE_URL).origin).toString() let data: any if (useCentralBanking) { data = await centralServicesClient.createBankingRequisition({ institutionId: institutionid, redirect, userLanguage: "de" }) @@ -1044,7 +1048,7 @@ export default async function bankingRoutes(server: FastifyInstance) { )) } - const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase()) + const bank = findBankInstitutionByBic(data, bic) if (!bank) return reply.code(404).send("Bank not found") diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index cf01f73..9cefe5c 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -1129,9 +1129,17 @@ export default async function resourceRoutes(server: FastifyInstance) { 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; + let data: Record = { ...body } + 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 @@ -1144,9 +1152,11 @@ export default async function resourceRoutes(server: FastifyInstance) { if (portalCustomerId) { data = { ...sanitizePortalCustomerUpdate(data), - updated_at: data.updated_at, - updated_by: data.updated_by, } + 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") { @@ -1162,9 +1172,11 @@ export default async function resourceRoutes(server: FastifyInstance) { 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 (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") { diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index f8b0dc5..347b6c7 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -982,10 +982,10 @@ export const importTenantFullExport = async ( export const importTenantFullExportArchive = async ( server: FastifyInstance, - archiveBuffer: Buffer, + archive: Buffer | Blob, options: ImportOptions = {} ): Promise => { - const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer]))) + const reader = new ZipReader(new BlobReader(archive instanceof Blob ? archive : new Blob([archive]))) try { const entries = await reader.getEntries() diff --git a/backend/tests/bankStatementSyncOwnerName.test.ts b/backend/tests/bankStatementSyncOwnerName.test.ts new file mode 100644 index 0000000..05c9417 --- /dev/null +++ b/backend/tests/bankStatementSyncOwnerName.test.ts @@ -0,0 +1,26 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { + getBankAccountOwnerName, + isExpiredBankingError, +} from "../src/modules/cron/bankstatementsync.service" + +test("reads the GoCardless owner_name for a bank account", () => { + assert.equal( + getBankAccountOwnerName({ owner_name: "Neue Firma GmbH" }), + "Neue Firma GmbH" + ) +}) + +test("does not overwrite the stored owner with an empty provider value", () => { + assert.equal(getBankAccountOwnerName({ owner_name: "" }), null) + assert.equal(getBankAccountOwnerName({}), null) + assert.equal(getBankAccountOwnerName(null), null) +}) + +test("recognizes expired EUA errors from direct and central banking responses", () => { + assert.equal(isExpiredBankingError({ response: { data: { detail: "EUA was valid for 90 days and it expired" } } }), true) + assert.equal(isExpiredBankingError(new Error("EUA was valid for 90 days and it expired")), true) + assert.equal(isExpiredBankingError({ response: { data: { message: "temporary provider error" } } }), false) +}) diff --git a/backend/tests/bankingInstitution.test.ts b/backend/tests/bankingInstitution.test.ts new file mode 100644 index 0000000..8edd3ff --- /dev/null +++ b/backend/tests/bankingInstitution.test.ts @@ -0,0 +1,18 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { findBankInstitutionByBic } from "../src/modules/banking-institution" + +test("finds a banking institution by BIC case-insensitively", () => { + const institution = findBankInstitutionByBic([ + { id: "without-bic" }, + { id: "slz", bic: "slzode22xxx" }, + ], " SLZODE22XXX ") + + assert.equal(institution?.id, "slz") +}) + +test("ignores institutions without a BIC instead of throwing", () => { + assert.equal(findBankInstitutionByBic([{ id: "without-bic" }], "SLZODE22XXX"), null) + assert.equal(findBankInstitutionByBic(null, "SLZODE22XXX"), null) +}) \ No newline at end of file diff --git a/backend/tests/mcpWiki.test.ts b/backend/tests/mcpWiki.test.ts new file mode 100644 index 0000000..144f2cc --- /dev/null +++ b/backend/tests/mcpWiki.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mcpToolMap } from "../src/mcp/registry" +import { prepareWikiScope, wikiTools } from "../src/mcp/tools/wiki" + +const tool = (name: string) => wikiTools.find((candidate) => candidate.name === name) + +test("registers read and write tools for global and entity wiki pages", () => { + const expected = [ + ["wiki.pages.list", "wiki.read"], + ["wiki.pages.get", "wiki.read"], + ["wiki.pages.create", "wiki.write"], + ["wiki.pages.update", "wiki.write"], + ["wiki.pages.delete", "wiki.write"], + ] as const + + for (const [name, permission] of expected) { + assert.deepEqual(tool(name)?.requiredPermissions, [permission]) + assert.equal(mcpToolMap.get(name), tool(name)) + } +}) + +test("exposes entity scope on list and create tools", () => { + for (const name of ["wiki.pages.list", "wiki.pages.create"]) { + const properties = (tool(name)?.inputSchema as any)?.properties + assert.equal(properties.entityType.type, "string") + assert.equal(properties.entityId.type, "number") + assert.equal(properties.entityUuid.type, "string") + } +}) + +test("prepares global, numeric entity and UUID entity wiki scopes", () => { + assert.deepEqual(prepareWikiScope({}), { + entityType: null, + entityId: null, + entityUuid: null, + }) + assert.deepEqual(prepareWikiScope({ entityType: "customers", entityId: 42 }), { + entityType: "customers", + entityId: 42, + entityUuid: null, + }) + assert.deepEqual(prepareWikiScope({ + entityType: "ownaccounts", + entityUuid: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + }), { + entityType: "ownaccounts", + entityId: null, + entityUuid: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + }) +}) + +test("rejects incomplete or ambiguous entity wiki scopes", () => { + assert.throws(() => prepareWikiScope({ entityType: "customers" }), /entityId oder entityUuid/) + assert.throws(() => prepareWikiScope({ entityId: 42 }), /entityType/) + assert.throws( + () => prepareWikiScope({ + entityType: "customers", + entityId: 42, + entityUuid: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + }), + /entweder entityId oder entityUuid/, + ) + assert.throws( + () => prepareWikiScope({ entityType: "customers", entityUuid: "not-a-uuid" }), + /gültige UUID/, + ) +}) + +test("requires identifiers and editable fields on mutating tools", () => { + assert.deepEqual((tool("wiki.pages.create")?.inputSchema as any).required, ["title"]) + assert.deepEqual((tool("wiki.pages.update")?.inputSchema as any).required, ["id"]) + assert.deepEqual((tool("wiki.pages.delete")?.inputSchema as any).required, ["id"]) + + const updateProperties = (tool("wiki.pages.update")?.inputSchema as any).properties + for (const field of ["title", "content", "parentId", "sortOrder", "isFolder"]) { + assert.ok(updateProperties[field], `${field} fehlt im Update-Schema`) + } +}) diff --git a/frontend/pages/settings/banking/index.vue b/frontend/pages/settings/banking/index.vue index 41324be..4339329 100644 --- a/frontend/pages/settings/banking/index.vue +++ b/frontend/pages/settings/banking/index.vue @@ -74,16 +74,20 @@ const addAccount = async (account) => { } const updateAccount = async (account) => { + const existingAccount = bankaccounts.value.find(i => i.iban === account.iban) + if (!existingAccount) return - let bankaccountId = bankaccounts.value.find(i => i.iban === account.iban).id - - // Fehlerbehandlung analog zu addAccount verbessert try { - const res = await useEntities("bankaccounts").update(bankaccountId, {accountId: account.id, expired: false}, true) + await useEntities("bankaccounts").update(existingAccount.id, { + accountId: account.id, + ownerName: account.owner_name, + iban: account.iban, + bankId: account.institution_id, + expired: false, + }, true) // useEntities feuert bereits einen Success-Toast - // reqData.value = null // Das würde das Modal leeren, ggf. gewünscht? Im Original war es drin. - setupPage() + await setupPage() } catch (error) { console.log(error) toast.add({title: "Es gab einen Fehler beim Aktualisieren des Accounts", color:"error"}) diff --git a/frontend/pages/settings/texttemplates.vue b/frontend/pages/settings/texttemplates.vue index bab737c..5c7b4bd 100644 --- a/frontend/pages/settings/texttemplates.vue +++ b/frontend/pages/settings/texttemplates.vue @@ -238,64 +238,63 @@ const getDocLabel = (type) => { - +