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
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:
@@ -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]))
|
||||||
|
|||||||
322
backend/src/mcp/tools/wiki.ts
Normal file
322
backend/src/mcp/tools/wiki.ts
Normal 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 }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
22
backend/src/modules/banking-institution.ts
Normal file
22
backend/src/modules/banking-institution.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
26
backend/tests/bankStatementSyncOwnerName.test.ts
Normal file
26
backend/tests/bankStatementSyncOwnerName.test.ts
Normal 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)
|
||||||
|
})
|
||||||
18
backend/tests/bankingInstitution.test.ts
Normal file
18
backend/tests/bankingInstitution.test.ts
Normal 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)
|
||||||
|
})
|
||||||
79
backend/tests/mcpWiki.test.ts
Normal file
79
backend/tests/mcpWiki.test.ts
Normal 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`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user