MCP-Projektphasen aktualisierbar machen
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
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
This commit is contained in:
88
backend/src/mcp/projectPhases.ts
Normal file
88
backend/src/mcp/projectPhases.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export type ProjectPhase = {
|
||||||
|
key?: string
|
||||||
|
label?: string
|
||||||
|
active?: boolean
|
||||||
|
optional?: boolean
|
||||||
|
activated_at?: string
|
||||||
|
activated_by?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhaseSelector = {
|
||||||
|
phaseKey?: string | null
|
||||||
|
phaseLabel?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activateProjectPhase = (
|
||||||
|
value: unknown,
|
||||||
|
selector: PhaseSelector,
|
||||||
|
userId: string,
|
||||||
|
activatedAt = new Date().toISOString(),
|
||||||
|
) => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
throw new Error("Das Projekt hat keine Phasen")
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases = value as ProjectPhase[]
|
||||||
|
const phaseKey = String(selector.phaseKey || "").trim()
|
||||||
|
const phaseLabel = String(selector.phaseLabel || "").trim()
|
||||||
|
|
||||||
|
if (Boolean(phaseKey) === Boolean(phaseLabel)) {
|
||||||
|
throw new Error("Genau phaseKey oder phaseLabel ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = phases
|
||||||
|
.map((phase, index) => ({ phase, index }))
|
||||||
|
.filter(({ phase }) => phaseKey ? phase.key === phaseKey : phase.label === phaseLabel)
|
||||||
|
|
||||||
|
if (matches.length === 0) throw new Error("Projektphase nicht gefunden")
|
||||||
|
if (matches.length > 1) throw new Error("Projektphase ist nicht eindeutig; bitte phaseKey verwenden")
|
||||||
|
|
||||||
|
const { phase: target, index: targetIndex } = matches[0]
|
||||||
|
const activeIndex = phases.findIndex((phase) => phase.active === true)
|
||||||
|
const active = activeIndex >= 0 ? phases[activeIndex] : null
|
||||||
|
|
||||||
|
if (target.active) throw new Error(`Projektphase „${target.label || target.key}“ ist bereits aktiv`)
|
||||||
|
if (target.activated_at) throw new Error(`Projektphase „${target.label || target.key}“ wurde bereits aktiviert`)
|
||||||
|
|
||||||
|
const completesProject = target.label === "Abgeschlossen"
|
||||||
|
if (!completesProject && activeIndex >= 0) {
|
||||||
|
if (targetIndex <= activeIndex) {
|
||||||
|
throw new Error("Eine bereits durchlaufene Projektphase kann nicht erneut aktiviert werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredSkippedPhase = phases
|
||||||
|
.slice(activeIndex + 1, targetIndex)
|
||||||
|
.find((candidate) => !candidate.optional)
|
||||||
|
|
||||||
|
if (requiredSkippedPhase) {
|
||||||
|
throw new Error(`Die Phase „${requiredSkippedPhase.label || requiredSkippedPhase.key}“ muss zuerst aktiviert werden`)
|
||||||
|
}
|
||||||
|
} else if (!completesProject && activeIndex < 0 && targetIndex !== 0) {
|
||||||
|
throw new Error(`Die Phase „${phases[0].label || phases[0].key}“ muss zuerst aktiviert werden`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPhases = phases.map((phase, index) => {
|
||||||
|
if (index === targetIndex) {
|
||||||
|
return {
|
||||||
|
...phase,
|
||||||
|
active: true,
|
||||||
|
activated_at: activatedAt,
|
||||||
|
activated_by: userId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (phase.active) {
|
||||||
|
return {
|
||||||
|
...phase,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...phase }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
phases: nextPhases,
|
||||||
|
activePhase: String(target.label || "").trim() || null,
|
||||||
|
previousPhase: active ? String(active.label || "").trim() || null : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||||
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
import { activateProjectPhase } from "../projectPhases"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||||
@@ -136,6 +138,77 @@ export const organisationTools: McpTool[] = [
|
|||||||
return { project: rows[0] }
|
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",
|
name: "organisation.plants.list",
|
||||||
title: "Anlagen auflisten",
|
title: "Anlagen auflisten",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const adminPermissions = [
|
|||||||
"accounting.statement_allocations.write",
|
"accounting.statement_allocations.write",
|
||||||
"organisation.customers.read",
|
"organisation.customers.read",
|
||||||
"organisation.projects.read",
|
"organisation.projects.read",
|
||||||
|
"organisation.projects.write",
|
||||||
"organisation.plants.read",
|
"organisation.plants.read",
|
||||||
"organisation.events.read",
|
"organisation.events.read",
|
||||||
"organisation.tasks.read",
|
"organisation.tasks.read",
|
||||||
|
|||||||
75
backend/tests/mcpProjectPhases.test.ts
Normal file
75
backend/tests/mcpProjectPhases.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { organisationTools } from "../src/mcp/tools/organisation"
|
||||||
|
import { activateProjectPhase } from "../src/mcp/projectPhases"
|
||||||
|
|
||||||
|
const phases = [
|
||||||
|
{ key: "start", label: "Erstkontakt", active: true, optional: false },
|
||||||
|
{ key: "planning", label: "Planung", optional: false },
|
||||||
|
{ key: "review", label: "Prüfung", optional: true },
|
||||||
|
{ key: "execution", label: "Umsetzung", optional: false },
|
||||||
|
{ key: "done", label: "Abgeschlossen", optional: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
test("registers a write-protected project phase update tool", () => {
|
||||||
|
const tool = organisationTools.find((candidate) => candidate.name === "organisation.projects.phase.update")
|
||||||
|
|
||||||
|
assert.deepEqual(tool?.requiredPermissions, ["organisation.projects.write"])
|
||||||
|
assert.deepEqual((tool?.inputSchema as any).required, ["id"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("activates the next project phase by key and records actor and timestamp", () => {
|
||||||
|
const result = activateProjectPhase(phases, { phaseKey: "planning" }, "user-1", "2026-08-31T12:00:00.000Z")
|
||||||
|
|
||||||
|
assert.equal(result.activePhase, "Planung")
|
||||||
|
assert.equal(result.previousPhase, "Erstkontakt")
|
||||||
|
assert.equal(result.phases[0].active, false)
|
||||||
|
assert.deepEqual(result.phases[1], {
|
||||||
|
key: "planning",
|
||||||
|
label: "Planung",
|
||||||
|
optional: false,
|
||||||
|
active: true,
|
||||||
|
activated_at: "2026-08-31T12:00:00.000Z",
|
||||||
|
activated_by: "user-1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows skipping optional phases and selecting a unique phase label", () => {
|
||||||
|
const planningActive = phases.map((phase) => ({ ...phase, active: phase.key === "planning" }))
|
||||||
|
const result = activateProjectPhase(planningActive, { phaseLabel: "Umsetzung" }, "user-1", "2026-08-31T12:00:00.000Z")
|
||||||
|
|
||||||
|
assert.equal(result.activePhase, "Umsetzung")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects skipping required phases", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase(phases, { phaseKey: "execution" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/Planung.*zuerst aktiviert/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows completing a project directly like the UI", () => {
|
||||||
|
const result = activateProjectPhase(phases, { phaseKey: "done" }, "user-1", "2026-08-31T12:00:00.000Z")
|
||||||
|
|
||||||
|
assert.equal(result.activePhase, "Abgeschlossen")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects unknown, ambiguous and already activated phases", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase(phases, { phaseKey: "missing" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/nicht gefunden/,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase([
|
||||||
|
...phases,
|
||||||
|
{ key: "planning-2", label: "Planung", optional: true },
|
||||||
|
], { phaseLabel: "Planung" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/nicht eindeutig/,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase(phases, { phaseKey: "start" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/bereits aktiv/,
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user