diff --git a/backend/main.py b/backend/main.py index 52f24e5..388e047 100644 --- a/backend/main.py +++ b/backend/main.py @@ -66,6 +66,7 @@ from routers import ( # noqa: E402 evidence, features, initiatives, + journey, me, milestones, projects, @@ -84,6 +85,8 @@ app.include_router(features.router) app.include_router(prompts.router) app.include_router(config.router) app.include_router(initiatives.router) +app.include_router(journey.initiative_router) +app.include_router(journey.roadmap_router) app.include_router(actions.router) app.include_router(projects.initiative_router) app.include_router(projects.items_router) diff --git a/backend/routers/actions.py b/backend/routers/actions.py index 71c1793..d908fe3 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -27,6 +27,8 @@ class ActionUpdateRequest(BaseModel): clear_due_at: bool = False project_id: Optional[str] = None clear_project: bool = False + roadmap_item_id: Optional[str] = None + clear_roadmap_item: bool = False class TaskCreateRequest(BaseModel): @@ -129,6 +131,8 @@ def update_action( clear_due_at=body.clear_due_at, project_id=body.project_id, clear_project=body.clear_project, + roadmap_item_id=body.roadmap_item_id, + clear_roadmap_item=body.clear_roadmap_item, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py index da9da27..87ecb7f 100644 --- a/backend/routers/backlog.py +++ b/backend/routers/backlog.py @@ -18,6 +18,7 @@ class BacklogCreateRequest(BaseModel): description: str = "" status: Literal["new", "triaged", "accepted", "rejected"] = "new" priority: Literal["low", "normal", "high"] = "normal" + roadmap_item_id: Optional[str] = None class BacklogUpdateRequest(BaseModel): @@ -25,6 +26,8 @@ class BacklogUpdateRequest(BaseModel): description: Optional[str] = None status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None priority: Optional[Literal["low", "normal", "high"]] = None + roadmap_item_id: Optional[str] = None + clear_roadmap_item: bool = False class BacklogConvertRequest(BaseModel): @@ -59,6 +62,8 @@ def update_backlog_item( description=body.description, status=body.status, priority=body.priority, + roadmap_item_id=body.roadmap_item_id, + clear_roadmap_item=body.clear_roadmap_item, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 535cd96..2fc2db3 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -49,6 +49,7 @@ class ActionCreateRequest(BaseModel): priority: Literal["low", "normal", "high"] = "normal" due_at: Optional[str] = None project_id: Optional[str] = None + roadmap_item_id: Optional[str] = None assigned_actor_ids: list[str] = Field(default_factory=list) @@ -65,6 +66,7 @@ class BacklogCreateRequest(BaseModel): description: str = "" status: Literal["new", "triaged", "accepted", "rejected"] = "new" priority: Literal["low", "normal", "high"] = "normal" + roadmap_item_id: Optional[str] = None class MilestoneCreateRequest(BaseModel): @@ -251,6 +253,7 @@ def create_initiative_action( priority=body.priority, due_at=due_at, project_id=body.project_id, + roadmap_item_id=body.roadmap_item_id, assigned_actor_ids=body.assigned_actor_ids, user_id=ctx.user_id, ) @@ -330,6 +333,7 @@ def create_initiative_backlog_item( description=body.description, status=body.status, priority=body.priority, + roadmap_item_id=body.roadmap_item_id, user_id=ctx.user_id, ) except ValueError as exc: diff --git a/backend/routers/journey.py b/backend/routers/journey.py new file mode 100644 index 0000000..41870da --- /dev/null +++ b/backend/routers/journey.py @@ -0,0 +1,44 @@ +"""Plan/Ist & Journey API — AP1.6.""" + +from __future__ import annotations + +from capabilities import require_capability +from fastapi import APIRouter, Depends, HTTPException +from services import plan_ist as plan_ist_service +from tenant_context import TenantContext + +initiative_router = APIRouter(prefix="/api/initiatives", tags=["journey"]) +roadmap_router = APIRouter(prefix="/api/roadmap-items", tags=["journey"]) + + +@initiative_router.get("/{initiative_id}/journey") +def list_initiative_journey( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + try: + events = plan_ist_service.list_journey_events( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + detail = str(exc) + if detail == "Initiative nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + return {"events": events} + + +@roadmap_router.get("/{roadmap_item_id}/contributions") +def list_gate_contributions( + roadmap_item_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + return plan_ist_service.list_gate_contributions( + tenant_id=ctx.tenant_id, roadmap_item_id=roadmap_item_id + ) + except ValueError as exc: + detail = str(exc) + if detail == "Gate nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc diff --git a/backend/services/actions.py b/backend/services/actions.py index a415f77..b1195a8 100644 --- a/backend/services/actions.py +++ b/backend/services/actions.py @@ -9,6 +9,7 @@ from psycopg2.extras import RealDictCursor from db import get_connection from services.audit import log_audit from services.initiatives import PRIORITIES, get_initiative +from services.plan_ist import validate_roadmap_item_in_initiative from services.projects import project_is_leaf ActionStatus = Literal[ @@ -23,14 +24,14 @@ OPEN_ACTION_STATUSES = frozenset( ) _ACTION_COLUMNS = """ - id, tenant_id, initiative_id, project_id, title, description, + id, tenant_id, initiative_id, project_id, roadmap_item_id, title, description, status, priority, due_at, created_at, updated_at """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "project_id", "owner_actor_id"): + for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "owner_actor_id"): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): @@ -135,6 +136,7 @@ def create_action( priority: str = "normal", due_at: Optional[Any] = None, project_id: Optional[str] = None, + roadmap_item_id: Optional[str] = None, assigned_actor_ids: Optional[list[str]] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: @@ -157,16 +159,32 @@ def create_action( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + validate_roadmap_item_in_initiative( + cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + roadmap_item_id=roadmap_item_id, + ) cur.execute( f""" INSERT INTO actions ( - tenant_id, initiative_id, project_id, title, description, + tenant_id, initiative_id, project_id, roadmap_item_id, title, description, status, priority, due_at ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_ACTION_COLUMNS} """, - (tenant_id, initiative_id, project_id, title, description, status, priority, due_at), + ( + tenant_id, + initiative_id, + project_id, + roadmap_item_id, + title, + description, + status, + priority, + due_at, + ), ) row = _serialize_row(dict(cur.fetchone())) for actor_id in assigned_actor_ids: @@ -254,6 +272,8 @@ def update_action( clear_due_at: bool = False, project_id: Optional[str] = None, clear_project: bool = False, + roadmap_item_id: Optional[str] = None, + clear_roadmap_item: bool = False, ) -> Optional[dict[str, Any]]: existing = get_action(tenant_id=tenant_id, action_id=action_id) if not existing: @@ -295,6 +315,22 @@ def update_action( ) updates.append("project_id = %s") params.append(project_id) + if clear_roadmap_item: + updates.append("roadmap_item_id = NULL") + elif roadmap_item_id is not None: + conn = get_connection() + try: + with conn.cursor() as cur: + validate_roadmap_item_in_initiative( + cur, + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + roadmap_item_id=roadmap_item_id, + ) + finally: + conn.close() + updates.append("roadmap_item_id = %s") + params.append(roadmap_item_id) if not updates: return existing diff --git a/backend/services/backlog.py b/backend/services/backlog.py index c4e944e..7561084 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -10,15 +10,21 @@ from db import get_connection from services.actions import create_action from services.audit import log_audit from services.initiatives import PRIORITIES, get_initiative +from services.plan_ist import validate_roadmap_item_in_initiative BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) +_BACKLOG_COLUMNS = """ + id, tenant_id, initiative_id, title, description, status, + priority, roadmap_item_id, converted_action_id, created_at, updated_at +""" + def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "converted_action_id"): + for key in ("id", "tenant_id", "initiative_id", "converted_action_id", "roadmap_item_id"): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): @@ -46,6 +52,7 @@ def create_backlog_item( description: str = "", status: BacklogStatus = "new", priority: str = "normal", + roadmap_item_id: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() @@ -59,16 +66,22 @@ def create_backlog_item( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + validate_roadmap_item_in_initiative( + cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + roadmap_item_id=roadmap_item_id, + ) cur.execute( - """ + f""" INSERT INTO backlog_items ( - tenant_id, initiative_id, title, description, status, priority + tenant_id, initiative_id, title, description, status, priority, + roadmap_item_id ) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id, tenant_id, initiative_id, title, description, status, - priority, converted_action_id, created_at, updated_at + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING {_BACKLOG_COLUMNS} """, - (tenant_id, initiative_id, title, description, status, priority), + (tenant_id, initiative_id, title, description, status, priority, roadmap_item_id), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() @@ -92,9 +105,8 @@ def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[d try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( - """ - SELECT id, tenant_id, initiative_id, title, description, status, - priority, converted_action_id, created_at, updated_at + f""" + SELECT {_BACKLOG_COLUMNS} FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s ORDER BY updated_at DESC, title @@ -111,9 +123,8 @@ def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[s try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( - """ - SELECT id, tenant_id, initiative_id, title, description, status, - priority, converted_action_id, created_at, updated_at + f""" + SELECT {_BACKLOG_COLUMNS} FROM backlog_items WHERE id = %s AND tenant_id = %s """, @@ -134,6 +145,8 @@ def update_backlog_item( description: Optional[str] = None, status: Optional[BacklogStatus] = None, priority: Optional[str] = None, + roadmap_item_id: Optional[str] = None, + clear_roadmap_item: bool = False, ) -> Optional[dict[str, Any]]: existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) if not existing: @@ -162,6 +175,22 @@ def update_backlog_item( _validate_priority(priority) updates.append("priority = %s") params.append(priority) + if clear_roadmap_item: + updates.append("roadmap_item_id = NULL") + elif roadmap_item_id is not None: + conn = get_connection() + try: + with conn.cursor() as cur: + validate_roadmap_item_in_initiative( + cur, + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + roadmap_item_id=roadmap_item_id, + ) + finally: + conn.close() + updates.append("roadmap_item_id = %s") + params.append(roadmap_item_id) if not updates: return existing @@ -177,8 +206,7 @@ def update_backlog_item( UPDATE backlog_items SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s - RETURNING id, tenant_id, initiative_id, title, description, status, - priority, converted_action_id, created_at, updated_at + RETURNING {_BACKLOG_COLUMNS} """, params, ) @@ -259,6 +287,7 @@ def convert_backlog_to_action( title=existing["title"], description=existing["description"] or "", priority=existing["priority"], + roadmap_item_id=existing.get("roadmap_item_id"), assigned_actor_ids=assigned_actor_ids or [], user_id=user_id, ) @@ -273,8 +302,7 @@ def convert_backlog_to_action( converted_action_id = %s, updated_at = NOW() WHERE id = %s AND tenant_id = %s - RETURNING id, tenant_id, initiative_id, title, description, status, - priority, converted_action_id, created_at, updated_at + RETURNING {_BACKLOG_COLUMNS} """, (action["id"], backlog_item_id, tenant_id), ) diff --git a/backend/services/plan_ist.py b/backend/services/plan_ist.py new file mode 100644 index 0000000..3c50d74 --- /dev/null +++ b/backend/services/plan_ist.py @@ -0,0 +1,322 @@ +"""Plan/Ist-Verknüpfung — Gates, Journey, Beiträge (AP1.6).""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.initiatives import get_initiative + +JourneyEventKind = Literal[ + "decision", + "review", + "evidence", + "action", + "backlog", + "gate", +] + + +def validate_roadmap_item_in_initiative( + cur, + *, + tenant_id: str, + initiative_id: str, + roadmap_item_id: Optional[str], +) -> None: + if not roadmap_item_id: + return + cur.execute( + """ + SELECT 1 FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s + """, + (roadmap_item_id, tenant_id, initiative_id), + ) + if not cur.fetchone(): + raise ValueError("Gate gehört nicht zu diesem Vorhaben") + + +def _iso(value: Any) -> Optional[str]: + if not value: + return None + return value.isoformat() if hasattr(value, "isoformat") else str(value) + + +def list_gate_contributions( + *, tenant_id: str, roadmap_item_id: str +) -> dict[str, Any]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT ri.id, ri.title, ri.status, r.initiative_id + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.id = %s AND ri.tenant_id = %s + """, + (roadmap_item_id, tenant_id), + ) + gate = cur.fetchone() + if not gate: + raise ValueError("Gate nicht gefunden") + initiative_id = str(gate["initiative_id"]) + + cur.execute( + """ + SELECT id, title, status, priority, converted_action_id + FROM backlog_items + WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s + ORDER BY updated_at DESC + """, + (tenant_id, initiative_id, roadmap_item_id), + ) + backlog = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, title, status, priority, project_id + FROM actions + WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s + ORDER BY updated_at DESC + """, + (tenant_id, initiative_id, roadmap_item_id), + ) + actions = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, title, status, parent_project_id + FROM projects + WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s + ORDER BY updated_at DESC + """, + (tenant_id, initiative_id, roadmap_item_id), + ) + projects = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT t.id, t.title, t.status, t.action_id, a.title AS action_title + FROM tasks t + JOIN actions a ON a.id = t.action_id AND a.tenant_id = t.tenant_id + WHERE t.tenant_id = %s AND a.initiative_id = %s AND t.roadmap_item_id = %s + ORDER BY t.updated_at DESC + """, + (tenant_id, initiative_id, roadmap_item_id), + ) + tasks = [dict(r) for r in cur.fetchall()] + finally: + conn.close() + + def _ids(rows: list[dict]) -> list[dict[str, Any]]: + out = [] + for row in rows: + item = dict(row) + for key in item: + if item[key] is not None and key.endswith("_id"): + item[key] = str(item[key]) + out.append(item) + return out + + return { + "roadmap_item_id": roadmap_item_id, + "initiative_id": initiative_id, + "gate_title": gate["title"], + "gate_status": gate["status"], + "backlog_items": _ids(backlog), + "actions": _ids(actions), + "projects": _ids(projects), + "tasks": _ids(tasks), + "counts": { + "backlog": len(backlog), + "actions": len(actions), + "projects": len(projects), + "tasks": len(tasks), + "total": len(backlog) + len(actions) + len(projects) + len(tasks), + }, + } + + +def list_journey_events(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + events: list[dict[str, Any]] = [] + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, title, status, outcome, created_at, updated_at + FROM decisions + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + for row in cur.fetchall(): + events.append( + { + "kind": "decision", + "entity_id": str(row["id"]), + "title": row["title"], + "status": row["status"], + "summary": row["outcome"] or row["status"], + "occurred_at": _iso(row["updated_at"] or row["created_at"]), + "roadmap_item_id": None, + } + ) + + cur.execute( + """ + SELECT id, title, status, summary, created_at, updated_at, milestone_id, roadmap_item_id + FROM reviews + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + for row in cur.fetchall(): + rid = row.get("roadmap_item_id") or row.get("milestone_id") + events.append( + { + "kind": "review", + "entity_id": str(row["id"]), + "title": row["title"], + "status": row["status"], + "summary": row["summary"] or row["status"], + "occurred_at": _iso(row["updated_at"] or row["created_at"]), + "roadmap_item_id": str(rid) if rid else None, + } + ) + + cur.execute( + """ + SELECT id, title, status, description, created_at, updated_at, + roadmap_item_id, action_id + FROM evidence + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + for row in cur.fetchall(): + events.append( + { + "kind": "evidence", + "entity_id": str(row["id"]), + "title": row["title"], + "status": row["status"], + "summary": row["description"] or row["status"], + "occurred_at": _iso(row["updated_at"] or row["created_at"]), + "roadmap_item_id": ( + str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None + ), + "action_id": str(row["action_id"]) if row.get("action_id") else None, + } + ) + + cur.execute( + """ + SELECT id, title, status, created_at, updated_at, roadmap_item_id, project_id + FROM actions + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + for row in cur.fetchall(): + events.append( + { + "kind": "action", + "entity_id": str(row["id"]), + "title": row["title"], + "status": row["status"], + "summary": f"Arbeitspaket — {row['status']}", + "occurred_at": _iso(row["created_at"]), + "roadmap_item_id": ( + str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None + ), + "project_id": str(row["project_id"]) if row.get("project_id") else None, + } + ) + if row["status"] in ("done", "discarded") and row["updated_at"] != row["created_at"]: + events.append( + { + "kind": "action", + "entity_id": str(row["id"]), + "title": row["title"], + "status": row["status"], + "summary": f"Arbeitspaket abgeschlossen — {row['status']}", + "occurred_at": _iso(row["updated_at"]), + "roadmap_item_id": ( + str(row["roadmap_item_id"]) + if row.get("roadmap_item_id") + else None + ), + "project_id": ( + str(row["project_id"]) if row.get("project_id") else None + ), + } + ) + + cur.execute( + """ + SELECT id, title, status, created_at, updated_at, roadmap_item_id, converted_action_id + FROM backlog_items + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + for row in cur.fetchall(): + if row["status"] == "converted": + events.append( + { + "kind": "backlog", + "entity_id": str(row["id"]), + "title": row["title"], + "status": row["status"], + "summary": "Backlog → Arbeitspaket committet", + "occurred_at": _iso(row["updated_at"]), + "roadmap_item_id": ( + str(row["roadmap_item_id"]) + if row.get("roadmap_item_id") + else None + ), + "action_id": ( + str(row["converted_action_id"]) + if row.get("converted_action_id") + else None + ), + } + ) + + cur.execute( + """ + SELECT ri.id, ri.title, ri.status, ri.updated_at, ri.created_at + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.tenant_id = %s AND r.initiative_id = %s + AND ri.status IN ('reached', 'moved', 'discarded', 'at_risk') + """, + (tenant_id, initiative_id), + ) + for row in cur.fetchall(): + events.append( + { + "kind": "gate", + "entity_id": str(row["id"]), + "title": row["title"], + "status": row["status"], + "summary": f"Zielzustand — {row['status']}", + "occurred_at": _iso(row["updated_at"] or row["created_at"]), + "roadmap_item_id": str(row["id"]), + } + ) + finally: + conn.close() + + events = [e for e in events if e.get("occurred_at")] + events.sort(key=lambda e: e["occurred_at"], reverse=True) + return events diff --git a/backend/tests/test_ap16_plan_ist.py b/backend/tests/test_ap16_plan_ist.py new file mode 100644 index 0000000..50a3b80 --- /dev/null +++ b/backend/tests/test_ap16_plan_ist.py @@ -0,0 +1,94 @@ +"""AP1.6 — Plan/Ist-Verknüpfung und Journey.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import ( + _auth, + _create_initiative, + _login, +) + + +def _create_gate(client, token, initiative_id, title="Gate A"): + item = client.post( + f"/api/initiatives/{initiative_id}/roadmap/items", + json={"title": title, "item_type": "milestone"}, + headers=_auth(token), + ) + assert item.status_code == 201 + return item.json() + + +def test_backlog_and_action_gate_link(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + gate = _create_gate(client, token, initiative_id) + + backlog = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Idee", "roadmap_item_id": gate["id"]}, + headers=_auth(token), + ) + assert backlog.status_code == 201 + assert backlog.json()["roadmap_item_id"] == gate["id"] + + action = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Arbeit", "roadmap_item_id": gate["id"]}, + headers=_auth(token), + ) + assert action.status_code == 201 + assert action.json()["roadmap_item_id"] == gate["id"] + + +def test_convert_backlog_keeps_gate_link(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + gate = _create_gate(client, token, initiative_id) + + backlog = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Commit me", "status": "accepted", "roadmap_item_id": gate["id"]}, + headers=_auth(token), + ).json() + + converted = client.post( + f"/api/backlog/{backlog['id']}/convert-to-action", + json={}, + headers=_auth(token), + ) + assert converted.status_code == 201 + assert converted.json()["action"]["roadmap_item_id"] == gate["id"] + + +def test_gate_contributions_and_journey(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + gate = _create_gate(client, token, initiative_id) + + client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Linked work", "roadmap_item_id": gate["id"]}, + headers=_auth(token), + ) + + contributions = client.get( + f"/api/roadmap-items/{gate['id']}/contributions", + headers=_auth(token), + ) + assert contributions.status_code == 200 + body = contributions.json() + assert body["counts"]["actions"] == 1 + assert body["counts"]["total"] == 1 + + journey = client.get( + f"/api/initiatives/{initiative_id}/journey", + headers=_auth(token), + ) + assert journey.status_code == 200 + kinds = {e["kind"] for e in journey.json()["events"]} + assert "action" in kinds diff --git a/backend/version.py b/backend/version.py index be59c33..d064670 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.14.1-ap1.5c" +APP_VERSION = "0.15.0-ap1.6" DB_SCHEMA_VERSION = "014" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md index c86be0c..c052a5c 100644 --- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md +++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md @@ -68,7 +68,8 @@ Phase E Steering AP1.2, AP1.3 ◐ Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal Phase G Ist-Hierarchie AP1.5 ✓ Phase G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6 -Phase H Plan/Ist-Verknüpfung AP1.6 ← **NÄCHSTES CODE** +Phase H Plan/Ist-Verknüpfung AP1.6 ✓ +Phase H2 Rekursive Tasks AP1.5d ← **NÄCHSTES CODE** Phase I Gate-Graph AP1.4d–4e nach AP1.6 Phase J Portfolio & Kontext AP1.8 geplant Phase K Agent Interface AP1.7 geplant (unverändert) @@ -91,7 +92,8 @@ Phase L Validation AP0.10d (laufend PO) parallel | AP1.2c | 0.12.0-ap1.2c | IA-Skeleton | | AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal | | AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route | -| AP1.5 | 0.14.0-ap1.5 | Project, Task flach, Ausführung-Tab | +| AP1.5c | 0.14.1-ap1.5c | Rekursive Projects | +| AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge | | DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset | | DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity | @@ -144,7 +146,7 @@ Phase L Validation AP0.10d (laufend PO) parallel --- -### AP1.6 — Plan/Ist-Verknüpfung & Journey (**NÄCHSTES CODE-PAKET**) +### AP1.6 — Plan/Ist-Verknüpfung & Journey ✓ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline. @@ -288,8 +290,8 @@ MVP-nah wenn: 0.13.1-ap1.4b ✓ Gate-Checkliste Backend 0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach) 0.14.1-ap1.5c ✓ Rekursive Projects -0.15.0-ap1.6 → Journey / Plan-Ist — NÄCHSTES -0.15.1-ap1.5d → Rekursive Tasks / Roll-up +0.15.0-ap1.6 ✓ Plan/Ist / Journey +0.15.1-ap1.5d → Rekursive Tasks — NÄCHSTES 0.15.2-ap1.4d → Gate-Graph 0.16.0-ap1.7 → Agent Interface 0.17.0-ap1.8 → Portfolio + Kontext diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 4ed88d9..a086bc2 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -1,7 +1,7 @@ # Kairo — Implementation Truth Table v0.1 **Status:** living document — bei jedem AP aktualisieren -**Stand:** 2026-07-06 (nach AP1.5c) +**Stand:** 2026-07-06 (nach AP1.6) **Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert** Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. @@ -40,9 +40,9 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. |---------|-------|-----------| | Initiative | ✓ | | | Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions | -| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) | +| Action | ✓ | Gate-Zuordnung AP1.6 | | ActionAssignment | ✓ | | -| BacklogItem | ✓ | | +| BacklogItem | ✓ | Gate-Zuordnung AP1.6 | | Blocker | ✓ | `action_id` optional | | Milestone | ◐ | Tabelle MVP-Brücke; kein Gate | | Evidence | ✓ | | diff --git a/frontend/src/api/journey.js b/frontend/src/api/journey.js new file mode 100644 index 0000000..ae9c15d --- /dev/null +++ b/frontend/src/api/journey.js @@ -0,0 +1,9 @@ +import { apiFetch } from './client.js' + +export function listInitiativeJourney(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/journey`) +} + +export function listGateContributions(roadmapItemId) { + return apiFetch(`/api/roadmap-items/${roadmapItemId}/contributions`) +} diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx index df9b86a..b65a49c 100644 --- a/frontend/src/components/ActionForm.jsx +++ b/frontend/src/components/ActionForm.jsx @@ -1,6 +1,7 @@ import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js' import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js' import { ActorSelect } from './ActorSelect.jsx' +import { GateSelect } from './GateSelect.jsx' import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js' function isoToLocalInput(iso) { @@ -13,6 +14,7 @@ function isoToLocalInput(iso) { export function ActionForm({ initial = {}, projects = [], + roadmapItems = [], actors = [], actorsLoading = false, actorsError = null, @@ -34,6 +36,7 @@ export function ActionForm({ priority: form.priority.value, due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null, project_id: form.project_id?.value || undefined, + roadmap_item_id: form.roadmap_item_id?.value || undefined, assigned_actor_ids: selected, }) } @@ -93,6 +96,10 @@ export function ActionForm({ )} + {action.description}

)}
+ {action.roadmap_item_id && ( + Gate: {gateTitleById(roadmapItems, action.roadmap_item_id) || '…'} + )} {action.assigned_actor_ids?.length > 0 && ( {action.assigned_actor_ids.length} zugewiesen )} diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx index 3d1cd45..7740865 100644 --- a/frontend/src/components/BacklogSection.jsx +++ b/frontend/src/components/BacklogSection.jsx @@ -7,23 +7,32 @@ import { StatusBadge } from './StatusBadge.jsx' import { PriorityBadge } from './PriorityBadge.jsx' import { EmptyState } from './EmptyState.jsx' +import { GateSelect, gateTitleById } from './GateSelect.jsx' + export function BacklogSection({ items, + roadmapItems = [], canManage, onCreate, + onUpdateGate, onUpdateStatus, onConvert, onDelete, busy, }) { const [title, setTitle] = useState('') + const [gateId, setGateId] = useState('') const [showForm, setShowForm] = useState(false) async function handleSubmit(e) { e.preventDefault() if (!title.trim()) return - await onCreate({ title: title.trim() }) + await onCreate({ + title: title.trim(), + roadmap_item_id: gateId || undefined, + }) setTitle('') + setGateId('') setShowForm(false) } @@ -53,6 +62,19 @@ export function BacklogSection({ required /> + {roadmapItems.length > 0 && ( + + )} @@ -69,10 +91,30 @@ export function BacklogSection({ {item.description && (

{item.description}

)} + {item.roadmap_item_id && ( +

+ Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id} +

+ )}
+ {canManage && item.status !== 'converted' && roadmapItems.length > 0 && ( + + )} {canManage && item.status !== 'converted' && ( <> + {allowEmpty && } + {roadmapItems.map((item) => ( + + ))} + + + ) +} + +export function gateTitleById(roadmapItems, gateId) { + if (!gateId) return null + return roadmapItems.find((item) => item.id === gateId)?.title || null +} diff --git a/frontend/src/components/InitiativeActionsHub.jsx b/frontend/src/components/InitiativeActionsHub.jsx index 521723c..83e00af 100644 --- a/frontend/src/components/InitiativeActionsHub.jsx +++ b/frontend/src/components/InitiativeActionsHub.jsx @@ -6,6 +6,7 @@ export function InitiativeActionsHub({ initiativeId, actions, projects = [], + roadmapItems = [], actionContextById, hideDone, onHideDoneChange, @@ -62,6 +63,7 @@ export function InitiativeActionsHub({
))}
diff --git a/frontend/src/components/JourneyTimeline.jsx b/frontend/src/components/JourneyTimeline.jsx new file mode 100644 index 0000000..2835f83 --- /dev/null +++ b/frontend/src/components/JourneyTimeline.jsx @@ -0,0 +1,71 @@ +import { Link } from 'react-router-dom' + +const KIND_LABELS = { + decision: 'Entscheidung', + review: 'Review', + evidence: 'Nachweis', + action: 'Arbeitspaket', + backlog: 'Backlog', + gate: 'Zielzustand', +} + +function formatWhen(iso) { + if (!iso) return '' + try { + return new Date(iso).toLocaleString('de-DE', { + dateStyle: 'medium', + timeStyle: 'short', + }) + } catch { + return iso + } +} + +export function JourneyTimeline({ events, initiativeId, roadmapItems = [] }) { + if (!events?.length) { + return

Noch keine Journey-Einträge — Entscheidungen, Nachweise und committete Arbeit erscheinen hier.

+ } + + const gateTitles = Object.fromEntries(roadmapItems.map((item) => [item.id, item.title])) + + return ( +
    + {events.map((event, index) => ( +
  1. +
    + {KIND_LABELS[event.kind] || event.kind} + +
    + {event.title} + {event.summary &&

    {event.summary}

    } +
    + {event.roadmap_item_id && gateTitles[event.roadmap_item_id] && ( + + Gate: {gateTitles[event.roadmap_item_id]} + + )} + {event.kind === 'action' && ( + + Arbeitspaket öffnen + + )} + {event.kind === 'gate' && ( + + Gate öffnen + + )} +
    +
  2. + ))} +
+ ) +} diff --git a/frontend/src/components/ProjectsSection.jsx b/frontend/src/components/ProjectsSection.jsx index c8fae1a..d7c0c53 100644 --- a/frontend/src/components/ProjectsSection.jsx +++ b/frontend/src/components/ProjectsSection.jsx @@ -6,9 +6,11 @@ import { containerKindLabel, flattenProjectOptions, } from '../utils/projectTree.js' +import { GateSelect, gateTitleById } from './GateSelect.jsx' function ProjectTreeNodes({ projects, + roadmapItems, parentId, depth, selectedProjectId, @@ -42,6 +44,11 @@ function ProjectTreeNodes({ {containerKindLabel(project.container_kind)} )} + {project.roadmap_item_id && ( + + Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)} + + )} {project.description && (

{project.description}

)} @@ -62,6 +69,7 @@ function ProjectTreeNodes({
Release + {roadmapItems.length > 0 && ( + + )} @@ -218,6 +243,7 @@ export function ProjectsSection({ { if (!actionId) return @@ -37,8 +39,12 @@ export function TasksSection({ actionId, canManage }) { if (!title.trim()) return setBusy(true) try { - await createActionTask(actionId, { title: title.trim() }) + await createActionTask(actionId, { + title: title.trim(), + roadmap_item_id: gateId || undefined, + }) setTitle('') + setGateId('') await load() } catch (err) { setError(err.message) @@ -59,6 +65,21 @@ export function TasksSection({ actionId, canManage }) { } } + async function handleGate(taskId, roadmapItemId) { + setBusy(true) + try { + await updateTask(taskId, { + roadmap_item_id: roadmapItemId || undefined, + clear_roadmap_item: !roadmapItemId, + }) + await load() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + async function handleDelete(taskId) { setBusy(true) try { @@ -79,7 +100,7 @@ export function TasksSection({ actionId, canManage }) {

Aufgaben

- Kleinste ausführbare Schritte — später Zuordnung zu Gates (Zielerreichung). + Kleinste ausführbare Schritte — optional einem Zielzustand (Gate) zugeordnet.

@@ -98,6 +119,19 @@ export function TasksSection({ actionId, canManage }) { placeholder="Konkreter nächster Schritt" /> + {roadmapItems.length > 0 && ( + + )} @@ -113,8 +147,29 @@ export function TasksSection({ actionId, canManage }) {
  • {task.title} + {task.roadmap_item_id && ( +

    + Gate: {gateTitleById(roadmapItems, task.roadmap_item_id)} +

    + )}
    + {canManage && roadmapItems.length > 0 && ( + + )} {canManage ? (