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

This commit is contained in:
root
2026-08-31 11:08:53 +00:00
parent 02ab26771d
commit 6afc799f69
4 changed files with 237 additions and 0 deletions

View 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,
}
}

View File

@@ -1,5 +1,7 @@
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) => {
@@ -136,6 +138,77 @@ export const organisationTools: McpTool[] = [
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",

View File

@@ -45,6 +45,7 @@ const adminPermissions = [
"accounting.statement_allocations.write",
"organisation.customers.read",
"organisation.projects.read",
"organisation.projects.write",
"organisation.plants.read",
"organisation.events.read",
"organisation.tasks.read",

View 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/,
)
})