- archive.entities.list / archive.entities.restore für alle per MCP archivierbaren Entitäten mit Berechtigungsprüfung je Entität und Historieneintrag - accounting.bank_accounts.balances liefert Stände von Bankkonten und Kassenbüchern, optional zu einem Stichtag Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
9.0 KiB
TypeScript
151 lines
9.0 KiB
TypeScript
import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm"
|
|
import * as tables from "../../../db/schema"
|
|
import { assertToolPermission } from "../authz"
|
|
import { assertInternalDocumentAccess } from "../documentFiles"
|
|
import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields"
|
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
|
import { McpContext, McpTool } from "../types"
|
|
import { insertHistoryItem } from "../../utils/history"
|
|
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
|
|
|
|
type ArchiveEntity = {
|
|
label: string
|
|
table: any
|
|
/** Key used by the history log and the generic resource routes. */
|
|
resource: string
|
|
permission: string
|
|
searchColumns: string[]
|
|
uuid?: boolean
|
|
internalDocuments?: boolean
|
|
}
|
|
|
|
const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity =>
|
|
({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid })
|
|
|
|
/** Every entity the MCP can archive; restore checks the matching write permission. */
|
|
export const archiveEntities: Record<string, ArchiveEntity> = {
|
|
customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]),
|
|
vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]),
|
|
contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]),
|
|
products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]),
|
|
services: masterdata("Leistung", tables.services, "services", "services", ["name"]),
|
|
cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true),
|
|
branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]),
|
|
teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]),
|
|
vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]),
|
|
inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]),
|
|
projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] },
|
|
events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] },
|
|
tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] },
|
|
outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] },
|
|
incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] },
|
|
files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true },
|
|
}
|
|
|
|
const entityField: InputField = {
|
|
schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." },
|
|
parse(value, path) {
|
|
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`)
|
|
return value
|
|
},
|
|
}
|
|
const entityIdField: InputField = {
|
|
schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." },
|
|
parse: (value) => value,
|
|
}
|
|
const searchField: InputField = {
|
|
schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." },
|
|
parse(value, path) {
|
|
const text = textField().parse(value, path)
|
|
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
|
|
return text
|
|
},
|
|
}
|
|
const listInput = objectField({
|
|
entity: entityField,
|
|
search: searchField,
|
|
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
|
|
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
|
|
})
|
|
const restoreInput = objectField({ entity: entityField, id: entityIdField })
|
|
|
|
const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table))
|
|
|
|
/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */
|
|
const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record<string, any>) => row
|
|
|
|
async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") {
|
|
assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] })
|
|
if (entity.internalDocuments) await assertInternalDocumentAccess(context)
|
|
}
|
|
|
|
export function prepareArchiveRestoreInput(args: Record<string, unknown>) {
|
|
const input = restoreInput.parse(args, "arguments")
|
|
if (!input.entity) throw new Error("entity ist erforderlich")
|
|
if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich")
|
|
const entity = archiveEntities[input.entity]
|
|
return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") }
|
|
}
|
|
|
|
const listTool: McpTool = {
|
|
name: "archive.entities.list",
|
|
title: "Archivierte Einträge auflisten",
|
|
description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.",
|
|
// Checked per entity in the handler.
|
|
requiredPermissions: [],
|
|
inputSchema: { ...listInput.schema, required: ["entity"] },
|
|
async handler(context, args) {
|
|
const input = listInput.parse(args, "arguments")
|
|
if (!input.entity) throw new Error("entity ist erforderlich")
|
|
const entity = archiveEntities[input.entity]
|
|
await authorize(context, listTool, entity, "read")
|
|
const table = entity.table
|
|
const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)]
|
|
if (input.search) {
|
|
const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%`
|
|
conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern))))
|
|
}
|
|
const updatedAt = columnKey(table, "updatedAt", "updated_at")!
|
|
const rows = await context.server.db.select().from(table).where(and(...conditions))
|
|
.orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
|
|
const result = paginatedResult(rows, input)
|
|
return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) }
|
|
},
|
|
}
|
|
|
|
const restoreTool: McpTool = {
|
|
name: "archive.entities.restore",
|
|
title: "Archivierten Eintrag wiederherstellen",
|
|
description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`,
|
|
requiredPermissions: [],
|
|
readOnly: false,
|
|
inputSchema: { ...restoreInput.schema, required: ["entity", "id"] },
|
|
async handler(context, args) {
|
|
const { key, entity, id } = prepareArchiveRestoreInput(args)
|
|
await authorize(context, restoreTool, entity, "write")
|
|
const table = entity.table
|
|
return context.server.db.transaction(async (tx) => {
|
|
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
|
|
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId))
|
|
const [existing] = await tx.select().from(table).where(scope).limit(1).for("update")
|
|
if (!existing) throw new Error(`${entity.label} nicht gefunden`)
|
|
if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`)
|
|
const [result] = await tx.update(table).set({
|
|
archived: false,
|
|
[columnKey(table, "updatedAt", "updated_at")!]: new Date(),
|
|
[columnKey(table, "updatedBy", "updated_by")!]: context.userId,
|
|
}).where(scope).returning()
|
|
if (["products", "services"].includes(entity.resource)) {
|
|
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
|
|
}
|
|
await insertHistoryItem(server, {
|
|
tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id,
|
|
action: "restored", oldVal: existing, newVal: result,
|
|
})
|
|
return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) }
|
|
})
|
|
},
|
|
}
|
|
|
|
export const archiveTools: McpTool[] = [listTool, restoreTool]
|