167 lines
12 KiB
TypeScript
167 lines
12 KiB
TypeScript
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<string, unknown>) {
|
|
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<string, any>) {
|
|
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<string, unknown>) {
|
|
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<string, unknown>) => mutateEvent(context, action, args),
|
|
})),
|
|
]
|