From 6afc799f69741a5830ce8a3e290a3448a0aeb01b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 11:08:53 +0000 Subject: [PATCH] MCP-Projektphasen aktualisierbar machen --- backend/src/mcp/projectPhases.ts | 88 ++++++++++++++++++++++++ backend/src/mcp/tools/organisation.ts | 73 ++++++++++++++++++++ backend/src/modules/bootstrap.service.ts | 1 + backend/tests/mcpProjectPhases.test.ts | 75 ++++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 backend/src/mcp/projectPhases.ts create mode 100644 backend/tests/mcpProjectPhases.test.ts diff --git a/backend/src/mcp/projectPhases.ts b/backend/src/mcp/projectPhases.ts new file mode 100644 index 0000000..5e0e522 --- /dev/null +++ b/backend/src/mcp/projectPhases.ts @@ -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, + } +} diff --git a/backend/src/mcp/tools/organisation.ts b/backend/src/mcp/tools/organisation.ts index 79d00bf..42a8ead 100644 --- a/backend/src/mcp/tools/organisation.ts +++ b/backend/src/mcp/tools/organisation.ts @@ -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, 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", diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index df8a8c9..13c94b7 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -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", diff --git a/backend/tests/mcpProjectPhases.test.ts b/backend/tests/mcpProjectPhases.test.ts new file mode 100644 index 0000000..2f5358a --- /dev/null +++ b/backend/tests/mcpProjectPhases.test.ts @@ -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/, + ) +})