import { and, desc, eq, ilike, inArray, isNull, lt, or, sql } from "drizzle-orm" import { authProfiles, customers, events, inventoryitemgroups, inventoryitems, projects, tenants, vehicles, vendors } from "../../../db/schema" import { insertHistoryItem } from "../../utils/history" import { arrayField, booleanField, dateField, idField, InputField, nullable, objectField, textField, uuidField } from "../masterdataFields" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { McpContext, McpTool } from "../types" export const eventStates = ["Entwurf", "Final"] export const eventRepeatIntervals = ["Keine Wiederholung", "Täglich", "Wöchentlich", "2-wöchentlich", "Monatlich", "Jährlich"] const enumField = (values: string[]): InputField => ({ schema: { type: "string", enum: values }, parse(value, path) { if (typeof value !== "string" || !values.includes(value)) throw new Error(`${path}: ungültiger Wert`) return value }, }) export const eventDateTimeField: InputField = { schema: { type: "string", format: "date-time", description: "ISO-Zeitpunkt mit Zeitzone, z. B. 2026-10-15T09:00:00+02:00." }, parse(value, path) { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) { throw new Error(`${path}: ISO-Zeitpunkt mit Zeitzone erwartet`) } dateField.parse(value.slice(0, 10), path) if (Number(value.slice(11, 13)) > 23 || Number(value.slice(14, 16)) > 59 || Number(value.slice(17, 19)) > 59 || !Number.isFinite(Date.parse(value))) throw new Error(`${path}: ungültiger Zeitpunkt`) return new Date(value) }, } const idsField = (field: InputField): InputField => ({ schema: { ...arrayField(field).schema, maxItems: 1000, uniqueItems: true }, parse(value, path) { if (!Array.isArray(value) || value.length > 1000) throw new Error(`${path}: maximal 1000 IDs erwartet`) return [...new Set(arrayField(field).parse(value, path))] }, }) const eventFields = { name: textField(true), startDate: eventDateTimeField, endDate: nullable(eventDateTimeField), eventtype: nullable(textField(true)), quick: booleanField, state: enumField(eventStates), color: nullable(textField()), repeatInterval: enumField(eventRepeatIntervals), notes: nullable(textField()), link: nullable(textField()), project: nullable(idField), customer: nullable(idField), vendor: nullable(idField), profiles: idsField(uuidField), vehicles: idsField(idField), inventoryitems: idsField(idField), inventoryitemgroups: idsField(uuidField), } const listFields = { query: textField(), project: idField, customer: idField, vendor: idField, eventtype: textField(true), from: eventDateTimeField, to: eventDateTimeField, state: enumField(eventStates), quick: booleanField, repeatInterval: enumField(eventRepeatIntervals), profile: uuidField, vehicle: idField, inventoryitem: idField, inventoryitemgroup: uuidField, includeRecurring: { ...booleanField, schema: { type: "boolean", default: true, description: "Bei from auch früher begonnene Wiederholungsserien liefern. Das Ergebnis enthält Serien-Stammsätze, keine einzelnen Vorkommen." } }, limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) }, offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) }, } const eventScope = (tenantId: number, id: number) => and(eq(events.id, id), eq(events.tenant, tenantId), eq(events.archived, false)) export function prepareEventInput(action: "create" | "update" | "archive", args: Record) { const input = objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).parse(args, "arguments") for (const key of action === "create" ? ["name", "startDate"] : ["id"]) { if (input[key] === undefined) throw new Error(`${key} ist erforderlich`) } if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich") return input } async function validateEventReferences(db: any, tenantId: number, input: Record) { const references = [ ["project", projects], ["customer", customers], ["vendor", vendors], ["profiles", authProfiles], ["vehicles", vehicles], ["inventoryitems", inventoryitems], ["inventoryitemgroups", inventoryitemgroups], ] as const for (const [field, table] of references) { const value = input[field] const ids = value == null ? [] : Array.isArray(value) ? value : [value] if (!ids.length) continue const scope = table === authProfiles ? and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.customer_for_portal)) : and(eq((table as any).tenant, tenantId), eq((table as any).archived, false)) const found = await db.select({ id: table.id }).from(table).where(and(inArray(table.id, ids), scope)).for("share") if (found.length !== ids.length) throw new Error(`${field}: verknüpfter Datensatz im aktiven Mandanten nicht verfügbar`) } } async function mutateEvent(context: McpContext, action: "create" | "update" | "archive", args: Record) { const { id, ...input } = prepareEventInput(action, args) return context.server.db.transaction(async tx => { let existing: typeof events.$inferSelect | undefined if (action !== "create") { ;[existing] = await tx.select().from(events).where(eventScope(context.tenantId, id)).limit(1).for("update") if (!existing) throw new Error("Termin nicht gefunden oder archiviert") } const merged = { ...existing, ...input } if (action !== "archive") { if (merged.endDate && new Date(merged.endDate).getTime() < new Date(merged.startDate).getTime()) { throw new Error("endDate darf nicht vor startDate liegen") } await validateEventReferences(tx, context.tenantId, input) } const change = { ...(action === "archive" ? { archived: true } : input), updatedAt: new Date(), updatedBy: context.userId } const [event] = action === "create" ? await tx.insert(events).values({ ...change, tenant: context.tenantId, name: input.name, startDate: input.startDate }).returning() : await tx.update(events).set(change).where(eventScope(context.tenantId, id)).returning() await insertHistoryItem({ ...context.server, db: tx } as any, { tenant_id: context.tenantId, created_by: context.userId, entity: "events", entityId: event.id, action: action === "create" ? "created" : action === "archive" ? "archived" : "updated", oldVal: existing, newVal: event }) return action === "archive" ? { id: event.id, success: true, action: "archived" } : { event } }) } export const calendarTools: McpTool[] = [ { name: "organisation.calendar.options", title: "Kalenderoptionen laden", description: "Lädt die konfigurierten Terminarten des Mandanten sowie erlaubte Status und Wiederholungen.", requiredPermissions: ["organisation.events.read"], inputSchema: objectField({}).schema, async handler(context, args) { objectField({}).parse(args, "arguments") const [tenant] = await context.server.db.select({ calendarConfig: tenants.calendarConfig }).from(tenants).where(eq(tenants.id, context.tenantId)).limit(1) if (!tenant) throw new Error("Mandant nicht gefunden") return { calendarConfig: tenant.calendarConfig, states: eventStates, repeatIntervals: eventRepeatIntervals } }, }, { name: "organisation.events.list", title: "Kalendertermine auflisten", description: "Listet aktive Termine und Serien-Stammsätze mit Text-, Zeitraum-, Status- und Ressourcenfiltern. from/to begrenzen Überlappungen (to exklusiv); früher begonnene Serien werden standardmäßig mitgeliefert, ohne einzelne Wiederholungen auszurechnen.", requiredPermissions: ["organisation.events.read"], inputSchema: objectField(listFields).schema, async handler(context, args) { const input = objectField(listFields).parse(args, "arguments") if (input.from && input.to && input.from >= input.to) throw new Error("from muss vor to liegen") const conditions = [eq(events.tenant, context.tenantId), eq(events.archived, false)] if (input.query) { const query = `%${input.query.replace(/[\\%_]/g, "\\$&")}%` conditions.push(or(ilike(events.name, query), ilike(events.notes, query), ilike(events.link, query))) } for (const key of ["project", "customer", "vendor", "eventtype", "state", "quick", "repeatInterval"]) { if (input[key] !== undefined) conditions.push(eq(events[key], input[key])) } for (const [key, column] of [["profile", events.profiles], ["vehicle", events.vehicles], ["inventoryitem", events.inventoryitems], ["inventoryitemgroup", events.inventoryitemgroups]] as const) { if (input[key] !== undefined) conditions.push(sql`${column} @> ${JSON.stringify([input[key]])}::jsonb`) } if (input.to) conditions.push(lt(events.startDate, input.to)) if (input.from) conditions.push(or( sql`${events.endDate} > ${input.from.toISOString()}::timestamptz`, sql`${events.startDate} >= ${input.from.toISOString()}::timestamptz`, input.includeRecurring !== false ? inArray(events.repeatInterval, eventRepeatIntervals.slice(1)) : undefined, )) const rows = await context.server.db.select().from(events).where(and(...conditions)).orderBy(desc(events.startDate), events.id) .limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input)) return paginatedResult(rows, input) }, }, { name: "organisation.events.get", title: "Kalendertermin laden", description: "Lädt einen aktiven Termin einschließlich Wiederholung und Ressourcenzuordnungen.", requiredPermissions: ["organisation.events.read"], inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] }, async handler(context, args) { const input = prepareEventInput("archive", args) const [event] = await context.server.db.select().from(events).where(eventScope(context.tenantId, input.id)).limit(1) if (!event) throw new Error("Termin nicht gefunden oder archiviert") return { event } }, }, ...(["create", "update", "archive"] as const).map(action => ({ name: `organisation.events.${action}`, title: action === "create" ? "Kalendertermin erstellen" : action === "update" ? "Kalendertermin bearbeiten" : "Kalendertermin archivieren", description: action === "create" ? "Erstellt einen Termin, standardmäßig Final und ohne Wiederholung. Zeitpunkte benötigen eine explizite Zeitzone." : action === "update" ? "Ändert nur angegebene Felder. Ressourcenlisten werden ersetzt; null leert optionale Felder. Bei Wiederholungen wird die gesamte Serie geändert." : "Archiviert einen Termin bzw. seine gesamte Wiederholungsserie.", requiredPermissions: ["organisation.events.write"], inputSchema: { ...objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).schema, required: action === "create" ? ["name", "startDate"] : ["id"] }, handler: (context: McpContext, args: Record) => mutateEvent(context, action, args), })), ]