Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 50s
Build and Push Docker Images / build-frontend (push) Successful in 25s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 23s

This commit is contained in:
2026-09-04 07:48:52 +02:00
15 changed files with 701 additions and 102 deletions

View File

@@ -1,11 +1,13 @@
import { accountingTools } from "./tools/accounting" import { accountingTools } from "./tools/accounting"
import { masterdataTools } from "./tools/masterdata" import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation" import { organisationTools } from "./tools/organisation"
import { wikiTools } from "./tools/wiki"
export const mcpTools = [ export const mcpTools = [
...accountingTools, ...accountingTools,
...masterdataTools, ...masterdataTools,
...organisationTools, ...organisationTools,
...wikiTools,
] ]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -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<string, unknown>, key: string) => {
const value = args[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const uuidArg = (args: Record<string, unknown>, key: string) => {
const value = stringArg(args, key)
if (!value || !UUID_PATTERN.test(value)) return null
return value
}
const pageIdArg = (args: Record<string, unknown>, 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<string, unknown>) => {
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<string, unknown>) => {
const value = Number(args.offset ?? 0)
if (!Number.isFinite(value)) return 0
return Math.max(Math.trunc(value), 0)
}
export function prepareWikiScope(args: Record<string, unknown>): 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<string, unknown> = {
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 }
},
},
]

View File

@@ -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
}

View File

@@ -50,6 +50,8 @@ const adminPermissions = [
"organisation.events.read", "organisation.events.read",
"organisation.tasks.read", "organisation.tasks.read",
"organisation.tasks.write", "organisation.tasks.write",
"wiki.read",
"wiki.write",
] ]
const defaultTaxTypes = [ const defaultTaxTypes = [

View File

@@ -52,6 +52,21 @@ const normalizeDate = (val: any) => {
return isNaN(d.getTime()) ? null : d 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) { export function bankStatementService(server: FastifyInstance) {
let accessToken: string | null = null let accessToken: string | null = null
@@ -78,7 +93,7 @@ export function bankStatementService(server: FastifyInstance) {
// ----------------------------------------------- // -----------------------------------------------
// ✔ Salden laden // ✔ Salden laden
// ----------------------------------------------- // -----------------------------------------------
const getBalanceData = async (accountId: string): Promise<any> => { const getBalanceData = async (accountId: string, tenantId: number): Promise<any> => {
try { try {
if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId) if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId)
const {data} = await axios.get( const {data} = await axios.get(
@@ -95,21 +110,37 @@ export function bankStatementService(server: FastifyInstance) {
} catch (err: any) { } catch (err: any) {
server.log.error(err.response?.data ?? err) server.log.error(err.response?.data ?? err)
const expired = if (isExpiredBankingError(err)) {
err.response?.data?.summary?.includes("expired") ||
err.response?.data?.detail?.includes("expired")
if (expired) {
await server.db await server.db
.update(bankaccounts) .update(bankaccounts)
.set({expired: true}) .set({expired: true})
.where(eq(bankaccounts.accountId, accountId)) .where(and(
eq(bankaccounts.accountId, accountId),
eq(bankaccounts.tenant, tenantId),
))
} }
throw err throw err
} }
} }
// -----------------------------------------------
// ✔ Kontoinhaber laden
// -----------------------------------------------
const getAccountData = async (accountId: string): Promise<any> => {
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 // ✔ Transaktionen laden
// ----------------------------------------------- // -----------------------------------------------
@@ -162,10 +193,26 @@ export function bankStatementService(server: FastifyInstance) {
for (const account of accounts) { for (const account of accounts) {
try { 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 // 1. BALANCE SYNC
// --------------------------- // ---------------------------
const balData = await getBalanceData(account.accountId) const balData = await getBalanceData(account.accountId, tenantId)
if (balData) { if (balData) {
const closing = balData.balances.find( const closing = balData.balances.find(

View File

@@ -86,7 +86,12 @@ async function requestPushServer<T>(method: "GET" | "POST" | "DELETE", path: str
if (!response.ok) { if (!response.ok) {
const message = data?.message || data?.error || `Push-Server Anfrage fehlgeschlagen (${response.status})` 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 return data as T

View File

@@ -1,7 +1,11 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import multipart from "@fastify/multipart"; 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 { import {
authTenantUsers, authTenantUsers,
@@ -463,8 +467,9 @@ export default async function adminRoutes(server: FastifyInstance) {
const startTenantImportJob = async ( const startTenantImportJob = async (
jobId: string, jobId: string,
currentUser: { id: string; email: string }, currentUser: { id: string; email: string },
source: { archiveBuffer: Buffer } | { exportData: TenantFullExport } source: { archiveStoragePath: string } | { exportData: TenantFullExport }
) => { ) => {
let temporaryArchivePath: string | null = null;
try { try {
await server.db await server.db
.update(tenantExportJobs) .update(tenantExportJobs)
@@ -487,9 +492,21 @@ export default async function adminRoutes(server: FastifyInstance) {
.where(eq(tenantExportJobs.id, jobId)); .where(eq(tenantExportJobs.id, jobId));
}; };
const result = "archiveBuffer" in source let result: Awaited<ReturnType<typeof importTenantFullExport>>;
? await importTenantFullExportArchive(server, source.archiveBuffer, { onProgress }) if ("archiveStoragePath" in source) {
: await importTenantFullExport(server, source.exportData, { onProgress }); 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 access = await completeImportedTenantAccess(currentUser, result);
const importResult = { success: true, ...access, ...result }; const importResult = { success: true, ...access, ...result };
@@ -517,6 +534,20 @@ export default async function adminRoutes(server: FastifyInstance) {
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(tenantExportJobs.id, jobId)); .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 // POST /admin/tenant-imports
// ------------------------------------------------------------- // -------------------------------------------------------------
server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => { server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => {
let archiveStoragePath: string | null = null;
let temporaryArchivePath: string | null = null;
try { try {
const currentUser = await requireAdmin(req, reply); const currentUser = await requireAdmin(req, reply);
if (!currentUser) return; if (!currentUser) return;
const isMultipart = req.headers["content-type"]?.includes("multipart/form-data"); 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 filename = "tenant-export.json";
let fileSize: number | null = null; let fileSize: number | null = null;
let targetTenantId: 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(); const data: any = await req.file();
if (!data?.file) return reply.code(400).send({ error: "export file required" }); 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"; filename = data.filename || "tenant-export.fedeo-export.zip";
fileSize = archiveBuffer.length;
targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
if (targetTenantId) { if (targetTenantId) {
return reply.code(409).send({ 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 { } else {
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
const exportData = "format" in body ? body : body.exportData; const exportData = "format" in body ? body : body.exportData;
@@ -1493,6 +1538,19 @@ export default async function adminRoutes(server: FastifyInstance) {
}); });
} catch (err: any) { } catch (err: any) {
console.error("ERROR /admin/tenant-imports:", err); 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 message = err?.message || "Internal Server Error";
const statusCode = message.includes("Tenant mit dieser ID existiert bereits") ? 409 : 500; const statusCode = message.includes("Tenant mit dieser ID existiert bereits") ? 409 : 500;
return reply.code(statusCode).send({ error: message }); return reply.code(statusCode).send({ error: message });

View File

@@ -8,6 +8,7 @@ import { decrypt, encrypt } from "../utils/crypt"
import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { centralServicesClient } from "../modules/push-server.client" import { centralServicesClient } from "../modules/push-server.client"
import { findBankInstitutionByBic } from "../modules/banking-institution"
import { import {
bankrequisitions, bankrequisitions,
@@ -997,7 +998,10 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) 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 let data: any
if (useCentralBanking) { if (useCentralBanking) {
data = await centralServicesClient.createBankingRequisition({ institutionId: institutionid, redirect, userLanguage: "de" }) 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") if (!bank) return reply.code(404).send("Bank not found")

View File

@@ -1129,9 +1129,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "Resource not found" }) return reply.code(404).send({ error: "Resource not found" })
} }
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId } let data: Record<string, any> = { ...body }
//@ts-ignore delete data.updatedAt
delete data.updatedBy; 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") { if (resource === "filetags") {
delete data.isSystemUsed delete data.isSystemUsed
@@ -1144,9 +1152,11 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (portalCustomerId) { if (portalCustomerId) {
data = { data = {
...sanitizePortalCustomerUpdate(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") { 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 }) if (prepared.error) return reply.code(400).send({ error: prepared.error })
data = { data = {
...prepared.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") { if (resource === "costcentres") {

View File

@@ -982,10 +982,10 @@ export const importTenantFullExport = async (
export const importTenantFullExportArchive = async ( export const importTenantFullExportArchive = async (
server: FastifyInstance, server: FastifyInstance,
archiveBuffer: Buffer, archive: Buffer | Blob,
options: ImportOptions = {} options: ImportOptions = {}
): Promise<ImportResult> => { ): Promise<ImportResult> => {
const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer]))) const reader = new ZipReader(new BlobReader(archive instanceof Blob ? archive : new Blob([archive])))
try { try {
const entries = await reader.getEntries() const entries = await reader.getEntries()

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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`)
}
})

View File

@@ -74,16 +74,20 @@ const addAccount = async (account) => {
} }
const updateAccount = 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 { 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 // useEntities feuert bereits einen Success-Toast
// reqData.value = null // Das würde das Modal leeren, ggf. gewünscht? Im Original war es drin. await setupPage()
setupPage()
} catch (error) { } catch (error) {
console.log(error) console.log(error)
toast.add({title: "Es gab einen Fehler beim Aktualisieren des Accounts", color:"error"}) toast.add({title: "Es gab einen Fehler beim Aktualisieren des Accounts", color:"error"})

View File

@@ -238,64 +238,63 @@ const getDocLabel = (type) => {
</UTable> </UTable>
</UDashboardPanelContent> </UDashboardPanelContent>
<UModal v-model:open="editTemplateModalOpen" :ui="{ width: 'sm:max-w-4xl' }"> <UModal v-model:open="editTemplateModalOpen" :ui="{ content: 'max-h-[85vh] overflow-hidden sm:max-w-4xl' }">
<template #content> <template #content>
<UCard> <div class="mx-auto flex h-[85vh] max-h-[85vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-default shadow-xl ring-1 ring-black/5">
<template #header> <div class="flex shrink-0 items-center justify-between border-b border-default px-6 py-5 sm:px-7">
<div class="flex justify-between items-center"> <h3 class="text-lg font-semibold">
<h3 class="text-lg font-semibold"> {{ itemInfo.id ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen' }}
{{ itemInfo.id ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen' }} </h3>
</h3> <UButton color="gray" variant="ghost" icon="i-heroicons-x-mark" @click="editTemplateModalOpen = false"/>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark" @click="editTemplateModalOpen = false"/>
</div>
</template>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-4">
<UFormField label="Bezeichnung" required>
<UInput v-model="itemInfo.name" placeholder="z.B. Standard Angebotstext" icon="i-heroicons-tag"/>
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="Dokumententyp" required>
<USelectMenu
v-model="itemInfo.documentType"
:options="Object.keys(dataStore.documentTypesForCreation || {})
.filter(i => i !== 'serialInvoices')
.map(i => ({ label: dataStore.documentTypesForCreation[i].label, key: i }))"
option-attribute="label"
value-attribute="key"
/>
</UFormField>
<UFormField label="Position" required>
<USelectMenu
v-model="itemInfo.pos"
:options="[
{ label: 'Einleitung (Oben)', key: 'startText' },
{ label: 'Endtext (Unten)', key: 'endText' }
]"
option-attribute="label"
value-attribute="key"
/>
</UFormField>
</div>
<UFormField label="Text Inhalt" required help="Klicken Sie rechts auf eine Variable, um sie einzufügen.">
<UTextarea
ref="textareaRef"
v-model="itemInfo.text"
:rows="10"
placeholder="Sehr geehrte Damen und Herren..."
class="font-mono text-sm"
/>
</UFormField>
<UCheckbox v-model="itemInfo.default" label="Als Standard für diesen Typ verwenden"/>
</div> </div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 h-fit"> <div class="min-h-0 flex-1 overflow-y-auto px-6 py-5 sm:px-7">
<div class="grid min-w-0 grid-cols-1 gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(16rem,1fr)]">
<div class="min-w-0 space-y-4">
<UFormField label="Bezeichnung" required>
<UInput v-model="itemInfo.name" placeholder="z.B. Standard Angebotstext" icon="i-heroicons-tag"/>
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="Dokumententyp" required>
<USelectMenu
v-model="itemInfo.documentType"
:options="Object.keys(dataStore.documentTypesForCreation || {})
.filter(i => i !== 'serialInvoices')
.map(i => ({ label: dataStore.documentTypesForCreation[i].label, key: i }))"
option-attribute="label"
value-attribute="key"
/>
</UFormField>
<UFormField label="Position" required>
<USelectMenu
v-model="itemInfo.pos"
:options="[
{ label: 'Einleitung (Oben)', key: 'startText' },
{ label: 'Endtext (Unten)', key: 'endText' }
]"
option-attribute="label"
value-attribute="key"
/>
</UFormField>
</div>
<UFormField label="Text Inhalt" required help="Klicken Sie rechts auf eine Variable, um sie einzufügen.">
<UTextarea
ref="textareaRef"
v-model="itemInfo.text"
:rows="10"
placeholder="Sehr geehrte Damen und Herren..."
class="font-mono text-sm"
/>
</UFormField>
<UCheckbox v-model="itemInfo.default" label="Als Standard für diesen Typ verwenden"/>
</div>
<div class="h-fit min-w-0 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<h4 class="font-semibold mb-3 flex items-center gap-2"> <h4 class="font-semibold mb-3 flex items-center gap-2">
<UIcon name="i-heroicons-variable"/> <UIcon name="i-heroicons-variable"/>
Variablen Variablen
@@ -309,12 +308,12 @@ const getDocLabel = (type) => {
v-for="v in variableDefinitions" v-for="v in variableDefinitions"
:key="v.key" :key="v.key"
@click="insertVariable(v.key)" @click="insertVariable(v.key)"
class="group flex items-center justify-between p-2 rounded hover:bg-white dark:hover:bg-gray-700 border border-transparent hover:border-gray-200 dark:hover:border-gray-600 transition-colors text-left" class="group flex min-w-0 items-center justify-between rounded border border-transparent p-2 text-left transition-colors hover:border-gray-200 hover:bg-white dark:hover:border-gray-600 dark:hover:bg-gray-700"
type="button" type="button"
> >
<div> <div>
<code <code
class="text-xs font-bold text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-950/50 px-1 py-0.5 rounded">{{ class="break-all rounded bg-primary-50 px-1 py-0.5 text-xs font-bold text-primary-600 dark:bg-primary-950/50 dark:text-primary-400">{{
v.key v.key
}}</code> }}</code>
<div class="text-xs text-gray-500 mt-0.5">{{ v.desc }}</div> <div class="text-xs text-gray-500 mt-0.5">{{ v.desc }}</div>
@@ -340,16 +339,16 @@ const getDocLabel = (type) => {
{{ example }} {{ example }}
</code> </code>
</div> </div>
</div>
</div>
</div> </div>
</div> </div>
</div> <div class="flex shrink-0 justify-end gap-3 border-t border-default px-6 py-4 sm:px-7">
<UButton color="gray" variant="ghost" @click="editTemplateModalOpen = false">
<template #footer> Abbrechen
<div class="flex justify-end gap-3"> </UButton>
<UButton color="gray" variant="ghost" @click="editTemplateModalOpen = false">
Abbrechen
</UButton>
<UButton <UButton
v-if="!itemInfo.id" v-if="!itemInfo.id"
@@ -370,9 +369,8 @@ const getDocLabel = (type) => {
> >
Speichern Speichern
</UButton> </UButton>
</div> </div>
</template> </div>
</UCard>
</template> </template>
</UModal> </UModal>
</template> </template>