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 = { 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) => 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) { 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]