All checks were successful
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-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 22s
481 lines
19 KiB
TypeScript
481 lines
19 KiB
TypeScript
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
|
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
|
import { insertHistoryItem } from "../../utils/history"
|
|
import { activateProjectPhase } from "../projectPhases"
|
|
import { McpTool } from "../types"
|
|
|
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
|
const raw = Number(args.limit ?? fallback)
|
|
if (!Number.isFinite(raw)) return fallback
|
|
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
|
}
|
|
|
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = args[key]
|
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
|
}
|
|
|
|
const numberArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = Number(args[key])
|
|
return Number.isFinite(value) ? value : null
|
|
}
|
|
|
|
export const organisationTools: McpTool[] = [
|
|
{
|
|
name: "organisation.customers.search",
|
|
title: "Kunden suchen",
|
|
description: "Sucht aktive Kunden des aktiven Mandanten.",
|
|
requiredPermissions: ["organisation.customers.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
|
includeArchived: { type: "boolean", default: false },
|
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(customers.tenant, context.tenantId)]
|
|
const query = stringArg(args, "query")
|
|
|
|
if (query) {
|
|
conditions.push(or(
|
|
ilike(customers.name, `%${query}%`),
|
|
ilike(customers.customerNumber, `%${query}%`),
|
|
ilike(customers.firstname, `%${query}%`),
|
|
ilike(customers.lastname, `%${query}%`),
|
|
ilike(customers.notes, `%${query}%`)
|
|
))
|
|
}
|
|
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select({
|
|
id: customers.id,
|
|
customerNumber: customers.customerNumber,
|
|
name: customers.name,
|
|
firstname: customers.firstname,
|
|
lastname: customers.lastname,
|
|
type: customers.type,
|
|
isCompany: customers.isCompany,
|
|
active: customers.active,
|
|
archived: customers.archived,
|
|
})
|
|
.from(customers)
|
|
.where(and(...conditions))
|
|
.orderBy(customers.name)
|
|
.limit(limitFromArgs(args))
|
|
|
|
return { rows }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.projects.list",
|
|
title: "Projekte auflisten",
|
|
description: "Listet Projekte des aktiven Mandanten mit optionalen Filtern.",
|
|
requiredPermissions: ["organisation.projects.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
|
customer: { type: "number" },
|
|
activePhase: { type: "string" },
|
|
includeArchived: { type: "boolean", default: false },
|
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(projects.tenant, context.tenantId)]
|
|
const query = stringArg(args, "query")
|
|
const customer = numberArg(args, "customer")
|
|
const activePhase = stringArg(args, "activePhase")
|
|
|
|
if (query) {
|
|
conditions.push(or(
|
|
ilike(projects.name, `%${query}%`),
|
|
ilike(projects.projectNumber, `%${query}%`),
|
|
ilike(projects.customerRef, `%${query}%`),
|
|
ilike(projects.notes, `%${query}%`)
|
|
))
|
|
}
|
|
if (customer) conditions.push(eq(projects.customer, customer))
|
|
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
|
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(projects)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(projects.createdAt))
|
|
.limit(limitFromArgs(args))
|
|
|
|
return { rows }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.projects.get",
|
|
title: "Projekt laden",
|
|
description: "Lädt ein Projekt des aktiven Mandanten anhand seiner ID.",
|
|
requiredPermissions: ["organisation.projects.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "number" },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(projects)
|
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
|
return { project: rows[0] }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.projects.phase.update",
|
|
title: "Projektphase aktualisieren",
|
|
description: "Aktiviert eine Projektphase anhand ihres Schlüssels oder ihrer eindeutigen Bezeichnung. Pflichtphasen können nicht übersprungen werden; optionale Phasen und der direkte Abschluss entsprechen dem Verhalten der Oberfläche.",
|
|
requiredPermissions: ["organisation.projects.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
oneOf: [
|
|
{ required: ["phaseKey"] },
|
|
{ required: ["phaseLabel"] },
|
|
],
|
|
properties: {
|
|
id: { type: "number", description: "Projekt-ID." },
|
|
phaseKey: { type: "string", description: "Technischer Schlüssel der zu aktivierenden Phase; alternativ zu phaseLabel." },
|
|
phaseLabel: { type: "string", description: "Eindeutige Bezeichnung der zu aktivierenden Phase, falls kein phaseKey bekannt ist." },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
|
|
const [existing] = await context.server.db
|
|
.select()
|
|
.from(projects)
|
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
if (!existing) throw new Error("Projekt nicht gefunden")
|
|
if (existing.archived) throw new Error("Die Phase eines archivierten Projekts kann nicht geändert werden")
|
|
|
|
const transition = activateProjectPhase(existing.phases, {
|
|
phaseKey: stringArg(args, "phaseKey"),
|
|
phaseLabel: stringArg(args, "phaseLabel"),
|
|
}, context.userId)
|
|
|
|
const [updated] = await context.server.db
|
|
.update(projects)
|
|
.set({
|
|
phases: transition.phases,
|
|
active_phase: transition.activePhase,
|
|
updatedAt: new Date(),
|
|
updatedBy: context.userId,
|
|
})
|
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
|
.returning()
|
|
|
|
if (!updated) throw new Error("Projekt nicht gefunden")
|
|
|
|
await insertHistoryItem(context.server, {
|
|
tenant_id: context.tenantId,
|
|
created_by: context.userId,
|
|
entity: "projects",
|
|
entityId: id,
|
|
action: "updated",
|
|
oldVal: existing,
|
|
newVal: updated,
|
|
text: transition.previousPhase
|
|
? `Projektphase von „${transition.previousPhase}“ auf „${transition.activePhase}“ geändert`
|
|
: `Projektphase „${transition.activePhase}“ aktiviert`,
|
|
})
|
|
|
|
return {
|
|
project: updated,
|
|
phaseTransition: {
|
|
previousPhase: transition.previousPhase,
|
|
activePhase: transition.activePhase,
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.plants.list",
|
|
title: "Anlagen auflisten",
|
|
description: "Listet Anlagen des aktiven Mandanten mit optionalem Kundenfilter.",
|
|
requiredPermissions: ["organisation.plants.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Suchtext für Name." },
|
|
customer: { type: "number" },
|
|
includeArchived: { type: "boolean", default: false },
|
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(plants.tenant, context.tenantId)]
|
|
const query = stringArg(args, "query")
|
|
const customer = numberArg(args, "customer")
|
|
|
|
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
|
if (customer) conditions.push(eq(plants.customer, customer))
|
|
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(plants)
|
|
.where(and(...conditions))
|
|
.orderBy(plants.name)
|
|
.limit(limitFromArgs(args))
|
|
|
|
return { rows }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.events.list",
|
|
title: "Termine auflisten",
|
|
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
|
|
requiredPermissions: ["organisation.events.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
|
|
project: { type: "number" },
|
|
customer: { type: "number" },
|
|
eventtype: { type: "string" },
|
|
includeArchived: { type: "boolean", default: false },
|
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(events.tenant, context.tenantId)]
|
|
const query = stringArg(args, "query")
|
|
const project = numberArg(args, "project")
|
|
const customer = numberArg(args, "customer")
|
|
const eventtype = stringArg(args, "eventtype")
|
|
|
|
if (query) {
|
|
conditions.push(or(
|
|
ilike(events.name, `%${query}%`),
|
|
ilike(events.notes, `%${query}%`),
|
|
ilike(events.link, `%${query}%`)
|
|
))
|
|
}
|
|
if (project) conditions.push(eq(events.project, project))
|
|
if (customer) conditions.push(eq(events.customer, customer))
|
|
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
|
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(events)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(events.startDate))
|
|
.limit(limitFromArgs(args))
|
|
|
|
return { rows }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.tasks.list",
|
|
title: "Aufgaben auflisten",
|
|
description: "Listet Aufgaben des aktiven Mandanten mit optionalen Filtern.",
|
|
requiredPermissions: ["organisation.tasks.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
|
project: { type: "number" },
|
|
customer: { type: "number" },
|
|
includeArchived: { type: "boolean", default: false },
|
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(tasks.tenant, context.tenantId)]
|
|
const query = stringArg(args, "query")
|
|
const project = numberArg(args, "project")
|
|
const customer = numberArg(args, "customer")
|
|
|
|
if (query) {
|
|
conditions.push(or(
|
|
ilike(tasks.name, `%${query}%`),
|
|
ilike(tasks.description, `%${query}%`),
|
|
ilike(tasks.categorie, `%${query}%`)
|
|
))
|
|
}
|
|
if (project) conditions.push(eq(tasks.project, project))
|
|
if (customer) conditions.push(eq(tasks.customer, customer))
|
|
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(tasks)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(tasks.createdAt))
|
|
.limit(limitFromArgs(args))
|
|
|
|
return { rows }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.tasks.get",
|
|
title: "Aufgabe laden",
|
|
description: "Lädt eine Aufgabe des aktiven Mandanten anhand ihrer ID.",
|
|
requiredPermissions: ["organisation.tasks.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "number" },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(tasks)
|
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
|
return { task: rows[0] }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.tasks.create",
|
|
title: "Aufgabe erstellen",
|
|
description: "Erstellt eine neue Aufgabe im aktiven Mandanten.",
|
|
requiredPermissions: ["organisation.tasks.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["name"],
|
|
properties: {
|
|
name: { type: "string" },
|
|
description: { type: "string" },
|
|
categorie: { type: "string" },
|
|
project: { type: "number" },
|
|
plant: { type: "number" },
|
|
customer: { type: "number" },
|
|
userId: { type: "string" },
|
|
profiles: { type: "array", items: { type: "string" } },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const name = stringArg(args, "name")
|
|
if (!name) throw new Error("name ist erforderlich")
|
|
|
|
const [created] = await context.server.db
|
|
.insert(tasks)
|
|
.values({
|
|
name,
|
|
description: stringArg(args, "description"),
|
|
categorie: stringArg(args, "categorie"),
|
|
tenant: context.tenantId,
|
|
userId: stringArg(args, "userId") || context.userId,
|
|
project: numberArg(args, "project"),
|
|
plant: numberArg(args, "plant"),
|
|
customer: numberArg(args, "customer"),
|
|
profiles: Array.isArray(args.profiles) ? args.profiles : [],
|
|
updatedAt: new Date(),
|
|
updatedBy: context.userId,
|
|
})
|
|
.returning()
|
|
|
|
return { task: created }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.tasks.update",
|
|
title: "Aufgabe aktualisieren",
|
|
description: "Aktualisiert Felder einer Aufgabe im aktiven Mandanten.",
|
|
requiredPermissions: ["organisation.tasks.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "number" },
|
|
name: { type: "string" },
|
|
description: { type: "string" },
|
|
categorie: { type: "string" },
|
|
project: { type: "number" },
|
|
plant: { type: "number" },
|
|
customer: { type: "number" },
|
|
userId: { type: "string" },
|
|
profiles: { type: "array", items: { type: "string" } },
|
|
archived: { type: "boolean" },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
|
|
const update: Record<string, unknown> = {
|
|
updatedAt: new Date(),
|
|
updatedBy: context.userId,
|
|
}
|
|
|
|
for (const key of ["name", "description", "categorie", "userId"] as const) {
|
|
if (args[key] !== undefined) update[key] = stringArg(args, key)
|
|
}
|
|
for (const key of ["project", "plant", "customer"] as const) {
|
|
if (args[key] !== undefined) update[key] = numberArg(args, key)
|
|
}
|
|
if (Array.isArray(args.profiles)) update.profiles = args.profiles
|
|
if (typeof args.archived === "boolean") update.archived = args.archived
|
|
|
|
const [updated] = await context.server.db
|
|
.update(tasks)
|
|
.set(update)
|
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
|
.returning()
|
|
|
|
if (!updated) throw new Error("Aufgabe nicht gefunden")
|
|
return { task: updated }
|
|
},
|
|
},
|
|
{
|
|
name: "organisation.tasks.archive",
|
|
title: "Aufgabe archivieren",
|
|
description: "Archiviert eine Aufgabe im aktiven Mandanten.",
|
|
requiredPermissions: ["organisation.tasks.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "number" },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
|
|
const [updated] = await context.server.db
|
|
.update(tasks)
|
|
.set({
|
|
archived: true,
|
|
updatedAt: new Date(),
|
|
updatedBy: context.userId,
|
|
})
|
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
|
.returning()
|
|
|
|
if (!updated) throw new Error("Aufgabe nicht gefunden")
|
|
return { task: updated }
|
|
},
|
|
},
|
|
]
|