From 405cc95b41b044335fa6c7746bed9b2dbd2922fd Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 20:05:06 +0000 Subject: [PATCH 01/10] Tenant-Importe per Stream in den Object Storage laden --- backend/src/routes/admin.ts | 53 ++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 09351af..d6699eb 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,7 +1,8 @@ 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 { authTenantUsers, @@ -463,7 +464,7 @@ 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 } ) => { try { await server.db @@ -487,9 +488,19 @@ 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"); + + const archiveBuffer = Buffer.from(await object.Body.transformToByteArray()); + result = await importTenantFullExportArchive(server, archiveBuffer, { onProgress }); + } else { + result = await importTenantFullExport(server, source.exportData, { onProgress }); + } const access = await completeImportedTenantAccess(currentUser, result); const importResult = { success: true, ...access, ...result }; @@ -517,6 +528,15 @@ export default async function adminRoutes(server: FastifyInstance) { updatedAt: new Date(), }) .where(eq(tenantExportJobs.id, jobId)); + } finally { + 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 +1445,13 @@ 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; 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 +1460,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 +1468,15 @@ export default async function adminRoutes(server: FastifyInstance) { }); } - source = { archiveBuffer }; + archiveStoragePath = `tenant-imports/${randomUUID()}-${filename}`; + await s3.send(new PutObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: archiveStoragePath, + Body: data.file, + ContentType: data.mimetype || "application/zip", + })); + fileSize = data.file.bytesRead || null; + source = { archiveStoragePath }; } else { const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; const exportData = "format" in body ? body : body.exportData; @@ -1493,6 +1520,14 @@ export default async function adminRoutes(server: FastifyInstance) { }); } catch (err: any) { console.error("ERROR /admin/tenant-imports:", err); + 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 }); From b1785a35a9c760998fa5bf05ac4247739d3fbbed Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 20:14:18 +0000 Subject: [PATCH 02/10] =?UTF-8?q?S3-Import=20mit=20bekannter=20Dateigr?= =?UTF-8?q?=C3=B6=C3=9Fe=20hochladen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/admin.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index d6699eb..0246993 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -3,6 +3,9 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import multipart from "@fastify/multipart"; import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { randomUUID } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { stat, unlink } from "node:fs/promises"; +import { pipeline } from "node:stream/promises"; import { authTenantUsers, @@ -1446,6 +1449,7 @@ export default async function adminRoutes(server: FastifyInstance) { // ------------------------------------------------------------- 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; @@ -1469,13 +1473,19 @@ export default async function adminRoutes(server: FastifyInstance) { } 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: data.file, + Body: createReadStream(temporaryArchivePath), + ContentLength: temporaryArchive.size, ContentType: data.mimetype || "application/zip", })); - fileSize = data.file.bytesRead || null; + fileSize = temporaryArchive.size; + await unlink(temporaryArchivePath); + temporaryArchivePath = null; source = { archiveStoragePath }; } else { const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; @@ -1520,6 +1530,11 @@ 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, From f26743154ab2690945a6f7b1189d0a91742e322e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 20:20:02 +0000 Subject: [PATCH 03/10] Tenant-Importe ohne ZIP-Buffering verarbeiten --- backend/src/routes/admin.ts | 14 +++++++++++--- backend/src/utils/tenantFullExport.ts | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 0246993..45177fc 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -3,7 +3,7 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import multipart from "@fastify/multipart"; import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { randomUUID } from "node:crypto"; -import { createReadStream, createWriteStream } from "node:fs"; +import { createReadStream, createWriteStream, openAsBlob } from "node:fs"; import { stat, unlink } from "node:fs/promises"; import { pipeline } from "node:stream/promises"; @@ -469,6 +469,7 @@ export default async function adminRoutes(server: FastifyInstance) { currentUser: { id: string; email: string }, source: { archiveStoragePath: string } | { exportData: TenantFullExport } ) => { + let temporaryArchivePath: string | null = null; try { await server.db .update(tenantExportJobs) @@ -499,8 +500,10 @@ export default async function adminRoutes(server: FastifyInstance) { })); if (!object.Body) throw new Error("Importarchiv konnte nicht aus dem Speicher gelesen werden"); - const archiveBuffer = Buffer.from(await object.Body.transformToByteArray()); - result = await importTenantFullExportArchive(server, archiveBuffer, { onProgress }); + 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 }); } @@ -532,6 +535,11 @@ export default async function adminRoutes(server: FastifyInstance) { }) .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, 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() From 95ca7a4aaf8f7cbf4f7a263a8e795c51a48fe81e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 1 Sep 2026 06:20:11 +0000 Subject: [PATCH 04/10] Textvorlagen-Modal scrollbar machen --- frontend/pages/settings/texttemplates.vue | 128 +++++++++++----------- 1 file changed, 63 insertions(+), 65 deletions(-) diff --git a/frontend/pages/settings/texttemplates.vue b/frontend/pages/settings/texttemplates.vue index bab737c..d1152d1 100644 --- a/frontend/pages/settings/texttemplates.vue +++ b/frontend/pages/settings/texttemplates.vue @@ -238,64 +238,63 @@ const getDocLabel = (type) => { - + From 92686ead9a0ec21f956cecde6a8927296542281d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 1 Sep 2026 06:27:27 +0000 Subject: [PATCH 05/10] Bankkonto-Inhaber beim GoCardless-Sync aktualisieren --- .../modules/cron/bankstatementsync.service.ts | 38 +++++++++++++++++++ .../tests/bankStatementSyncOwnerName.test.ts | 17 +++++++++ 2 files changed, 55 insertions(+) create mode 100644 backend/tests/bankStatementSyncOwnerName.test.ts diff --git a/backend/src/modules/cron/bankstatementsync.service.ts b/backend/src/modules/cron/bankstatementsync.service.ts index 71aa17d..cded6be 100644 --- a/backend/src/modules/cron/bankstatementsync.service.ts +++ b/backend/src/modules/cron/bankstatementsync.service.ts @@ -52,6 +52,11 @@ 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 function bankStatementService(server: FastifyInstance) { let accessToken: string | null = null @@ -110,6 +115,23 @@ export function bankStatementService(server: FastifyInstance) { } } + // ----------------------------------------------- + // ✔ 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,6 +184,22 @@ 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 // --------------------------- diff --git a/backend/tests/bankStatementSyncOwnerName.test.ts b/backend/tests/bankStatementSyncOwnerName.test.ts new file mode 100644 index 0000000..efc0e83 --- /dev/null +++ b/backend/tests/bankStatementSyncOwnerName.test.ts @@ -0,0 +1,17 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { getBankAccountOwnerName } 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) +}) From 98a8d1d06122088ab2d69967277b98a60d83915e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 1 Sep 2026 06:42:43 +0000 Subject: [PATCH 06/10] Textvorlagen-Modal Layout korrigieren --- frontend/pages/settings/texttemplates.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/pages/settings/texttemplates.vue b/frontend/pages/settings/texttemplates.vue index d1152d1..5c7b4bd 100644 --- a/frontend/pages/settings/texttemplates.vue +++ b/frontend/pages/settings/texttemplates.vue @@ -238,9 +238,9 @@ const getDocLabel = (type) => { - +