AP0.9: Operating Model Extension II (Evidence, Decision, Review, Recurring, due_at)
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 1m6s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 21s
Test Suite / playwright-smoke (push) Successful in 14s

Schließt den zweiten OM-Slice entlang der Roadmap: neue Entitäten parallel im Vorhaben, erweiterte Action-Status/Fälligkeit und Attention-Regeln 7–9.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 15:27:03 +02:00
parent c9101db1e1
commit a025542545
36 changed files with 3178 additions and 37 deletions

View File

@ -211,7 +211,26 @@ Operating Model Extension I (AP0.8, Schema `007`):
Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage). Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage).
Zielarchitektur-Einordnung: AP0.8 ist Schritt 5 der Evolutionslinie (Signal/NextAction) und Operating-Model-Phase A — siehe `docs/architecture/Kairo_System_Target_State_v0.1.md` und `Sprint0_AP0_8_Assignment_v0.2.md`. Operating Model Extension II (AP0.9, Schema `008`):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/initiatives/{id}/evidence` | GET/POST | `kairo.evidence.read/manage` | Evidence eines Vorhabens |
| `/api/evidence/{id}` | GET/PATCH/DELETE | `kairo.evidence.read/manage` | Einzelnes Evidence |
| `/api/initiatives/{id}/decisions` | GET/POST | `kairo.decision.read/manage` | Entscheidungen |
| `/api/decisions/{id}` | GET/PATCH/DELETE | `kairo.decision.read/manage` | Einzelne Entscheidung |
| `/api/initiatives/{id}/reviews` | GET/POST | `kairo.review.read/manage` | Reviews |
| `/api/reviews/{id}` | GET/PATCH/DELETE | `kairo.review.read/manage` | Einzelnes Review |
| `/api/initiatives/{id}/recurring` | GET/POST | `kairo.recurring.read/manage` | Recurring-Elemente |
| `/api/recurring/{id}` | GET/PATCH/DELETE | `kairo.recurring.read/manage` | Einzelnes Recurring-Element |
**Action-Status (erweitert):** `open`, `ready`, `in_progress`, `blocked`, `review_required`, `done`, `discarded` — optional `due_at`
**Attention-Regeln 79:** `overdue_action`, `review_due`, `recurring_due`
Capabilities gesamt nach AP0.9: **33**.
Zielarchitektur-Einordnung: AP0.9 ist Schritt 7 der Evolutionslinie (Evidence/Review/Decision/Recurring) — siehe `docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md`.
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md` `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`

View File

@ -17,6 +17,9 @@ AttentionKind = Literal[
"initiative_without_next_action", "initiative_without_next_action",
"stale_initiative", "stale_initiative",
"milestone_at_risk", "milestone_at_risk",
"overdue_action",
"review_due",
"recurring_due",
] ]
NextActionKind = Literal[ NextActionKind = Literal[
@ -32,13 +35,21 @@ Severity = Literal["info", "warning", "critical"]
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2} _SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
OPEN_BLOCKER_STATUSES = ("open", "in_progress") OPEN_BLOCKER_STATUSES = ("open", "in_progress")
OPEN_ACTION_STATUSES = ("open", "in_progress", "blocked") OPEN_ACTION_STATUSES = ("open", "ready", "in_progress", "blocked", "review_required")
ACTIVE_INITIATIVE_STATUSES = ("active", "paused") ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]: def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
item = dict(row) item = dict(row)
for key in ("scope_id", "initiative_id", "action_id", "blocker_id", "milestone_id"): for key in (
"scope_id",
"initiative_id",
"action_id",
"blocker_id",
"milestone_id",
"review_id",
"recurring_element_id",
):
if item.get(key): if item.get(key):
item[key] = str(item[key]) item[key] = str(item[key])
return item return item
@ -136,7 +147,7 @@ def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
FROM actions a FROM actions a
WHERE a.tenant_id = %s WHERE a.tenant_id = %s
AND a.priority = 'high' AND a.priority = 'high'
AND a.status IN ('open', 'in_progress') AND a.status IN ('open', 'ready', 'in_progress')
{actor_filter} {actor_filter}
ORDER BY a.updated_at DESC ORDER BY a.updated_at DESC
LIMIT 30 LIMIT 30
@ -164,7 +175,7 @@ def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'actions' AS data_source 'actions' AS data_source
FROM actions a FROM actions a
WHERE a.tenant_id = %s WHERE a.tenant_id = %s
AND a.status IN ('open', 'in_progress') AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM action_assignments aa SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
@ -200,7 +211,7 @@ def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str,
SELECT 1 FROM actions a SELECT 1 FROM actions a
WHERE a.initiative_id = i.id WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'in_progress', 'blocked') AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
) )
ORDER BY i.updated_at DESC ORDER BY i.updated_at DESC
LIMIT 30 LIMIT 30
@ -264,6 +275,106 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
return [_serialize_attention(dict(r)) for r in cur.fetchall()] return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'overdue_action' AS kind,
'warning' AS severity,
a.title AS title,
'Maßnahme überfällig' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
NULL::uuid AS recurring_element_id,
'action_overdue' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.due_at IS NOT NULL
AND a.due_at < NOW()
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
ORDER BY a.due_at ASC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'review_due' AS kind,
'warning' AS severity,
r.title AS title,
'Review fällig' AS summary,
'review' AS scope_type,
r.id AS scope_id,
r.initiative_id,
r.action_id,
NULL::uuid AS blocker_id,
r.milestone_id,
r.id AS review_id,
NULL::uuid AS recurring_element_id,
'review_due' AS reason_code,
'reviews' AS data_source
FROM reviews r
WHERE r.tenant_id = %s
AND r.status = 'planned'
AND r.due_at IS NOT NULL
AND r.due_at <= NOW()
ORDER BY r.due_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
items = []
for row in cur.fetchall():
item = _serialize_attention(dict(row))
for fk in ("action_id", "milestone_id"):
if item.get(fk):
item[fk] = str(item[fk])
items.append(item)
return items
def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'recurring_due' AS kind,
'info' AS severity,
re.title AS title,
'Wiederkehrendes Element fällig' AS summary,
'recurring_element' AS scope_type,
re.id AS scope_id,
re.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
re.id AS recurring_element_id,
'recurring_due' AS reason_code,
'recurring_elements' AS data_source
FROM recurring_elements re
WHERE re.tenant_id = %s
AND re.status = 'active'
AND re.next_due_at IS NOT NULL
AND re.next_due_at <= NOW()
ORDER BY re.next_due_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]: def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
"""Regelbasierte Attention Items — tenant-scoped, erklärbar.""" """Regelbasierte Attention Items — tenant-scoped, erklärbar."""
conn = get_connection() conn = get_connection()
@ -277,6 +388,9 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
items.extend(_initiatives_without_next_action(cur, ctx)) items.extend(_initiatives_without_next_action(cur, ctx))
items.extend(_stale_initiatives(cur, ctx)) items.extend(_stale_initiatives(cur, ctx))
items.extend(_milestones_at_risk(cur, ctx)) items.extend(_milestones_at_risk(cur, ctx))
items.extend(_overdue_actions(cur, ctx))
items.extend(_reviews_due(cur, ctx))
items.extend(_recurring_due(cur, ctx))
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99)) items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
return items return items
@ -337,7 +451,7 @@ def get_next_action_candidates(
'Maßnahme zuweisen' AS recommended_action 'Maßnahme zuweisen' AS recommended_action
FROM actions a FROM actions a
WHERE a.tenant_id = %s WHERE a.tenant_id = %s
AND a.status IN ('open', 'in_progress') AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM action_assignments aa SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
@ -401,7 +515,7 @@ def get_next_action_candidates(
SELECT 1 FROM actions a SELECT 1 FROM actions a
WHERE a.initiative_id = i.id WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'in_progress', 'blocked') AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
) )
ORDER BY i.updated_at DESC ORDER BY i.updated_at DESC
LIMIT %s LIMIT %s

View File

@ -60,11 +60,15 @@ from routers import ( # noqa: E402
backlog, backlog,
blockers, blockers,
config, config,
decisions,
evidence,
features, features,
initiatives, initiatives,
me, me,
milestones, milestones,
prompts, prompts,
recurring,
reviews,
workspace, workspace,
) )
@ -78,6 +82,10 @@ app.include_router(actions.router)
app.include_router(blockers.router) app.include_router(blockers.router)
app.include_router(backlog.router) app.include_router(backlog.router)
app.include_router(milestones.router) app.include_router(milestones.router)
app.include_router(evidence.router)
app.include_router(decisions.router)
app.include_router(reviews.router)
app.include_router(recurring.router)
app.include_router(actors.router) app.include_router(actors.router)
app.include_router(workspace.router) app.include_router(workspace.router)

View File

@ -0,0 +1,82 @@
-- AP0.9: Operating Model Extension II — evidence, decisions, reviews, recurring_elements, action due_at
ALTER TABLE actions ADD COLUMN due_at TIMESTAMPTZ NULL;
ALTER TABLE actions DROP CONSTRAINT actions_status_check;
ALTER TABLE actions ADD CONSTRAINT actions_status_check
CHECK (status IN (
'open', 'ready', 'in_progress', 'blocked', 'review_required', 'done', 'discarded'
));
CREATE INDEX idx_actions_due_at ON actions(tenant_id, due_at) WHERE due_at IS NOT NULL;
CREATE TABLE evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'submitted'
CHECK (status IN ('submitted', 'accepted', 'rejected')),
submitted_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_evidence_tenant_initiative ON evidence(tenant_id, initiative_id);
CREATE TABLE decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'proposed'
CHECK (status IN ('proposed', 'decided', 'superseded')),
outcome TEXT NOT NULL DEFAULT '',
decided_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_decisions_tenant_initiative ON decisions(tenant_id, initiative_id);
CREATE TABLE reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
summary TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'completed', 'skipped')),
due_at TIMESTAMPTZ NULL,
reviewed_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_reviews_tenant_initiative ON reviews(tenant_id, initiative_id);
CREATE INDEX idx_reviews_due_at ON reviews(tenant_id, due_at) WHERE due_at IS NOT NULL;
CREATE TABLE recurring_elements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'ended')),
interval_days INT NULL CHECK (interval_days IS NULL OR interval_days > 0),
next_due_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_recurring_elements_tenant_initiative ON recurring_elements(tenant_id, initiative_id);
CREATE INDEX idx_recurring_elements_next_due ON recurring_elements(tenant_id, next_due_at)
WHERE next_due_at IS NOT NULL;

View File

@ -81,3 +81,75 @@ register_capability(
default_grants=_MEMBER_MANAGE, default_grants=_MEMBER_MANAGE,
) )
) )
register_capability(
CapabilityRegistration(
key="kairo.evidence.read",
module="evidence",
description="Evidence im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.evidence.manage",
module="evidence",
description="Evidence anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.decision.read",
module="decision",
description="Entscheidungen im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.decision.manage",
module="decision",
description="Entscheidungen anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.review.read",
module="review",
description="Reviews im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.review.manage",
module="review",
description="Reviews anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.recurring.read",
module="recurring",
description="Recurring-Elemente im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.recurring.manage",
module="recurring",
description="Recurring-Elemente anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
from capabilities import require_capability from capabilities import require_capability
@ -17,8 +18,12 @@ router = APIRouter(prefix="/api/actions", tags=["actions"])
class ActionUpdateRequest(BaseModel): class ActionUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255) title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
status: Optional[Literal["open", "in_progress", "blocked", "done", "discarded"]] = None status: Optional[
Literal["open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"]
] = None
priority: Optional[Literal["low", "normal", "high"]] = None priority: Optional[Literal["low", "normal", "high"]] = None
due_at: Optional[str] = None
clear_due_at: bool = False
class ActionAssignmentsRequest(BaseModel): class ActionAssignmentsRequest(BaseModel):
@ -52,6 +57,12 @@ def update_action(
body: ActionUpdateRequest, body: ActionUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")), ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
): ):
due_at = None
if body.due_at is not None:
try:
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
try: try:
item = action_service.update_action( item = action_service.update_action(
tenant_id=ctx.tenant_id, tenant_id=ctx.tenant_id,
@ -61,6 +72,8 @@ def update_action(
description=body.description, description=body.description,
status=body.status, status=body.status,
priority=body.priority, priority=body.priority,
due_at=due_at,
clear_due_at=body.clear_due_at,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -0,0 +1,67 @@
"""Decision API — AP0.9c."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import decisions as decision_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/decisions", tags=["decisions"])
class DecisionUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["proposed", "decided", "superseded"]] = None
outcome: Optional[str] = None
decided_by_actor_id: Optional[str] = None
@router.get("/{decision_id}")
def get_decision(
decision_id: str,
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
):
item = decision_service.get_decision(tenant_id=ctx.tenant_id, decision_id=decision_id)
if not item:
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
return item
@router.patch("/{decision_id}")
def update_decision(
decision_id: str,
body: DecisionUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
):
try:
item = decision_service.update_decision(
tenant_id=ctx.tenant_id,
decision_id=decision_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
outcome=body.outcome,
decided_by_actor_id=body.decided_by_actor_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
return item
@router.delete("/{decision_id}", status_code=204)
def delete_decision(
decision_id: str,
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
):
if not decision_service.delete_decision(
tenant_id=ctx.tenant_id, decision_id=decision_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")

View File

@ -0,0 +1,71 @@
"""Evidence API — AP0.9b."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import evidence as evidence_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/evidence", tags=["evidence"])
class EvidenceUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["submitted", "accepted", "rejected"]] = None
action_id: Optional[str] = None
milestone_id: Optional[str] = None
clear_action_id: bool = False
clear_milestone_id: bool = False
@router.get("/{evidence_id}")
def get_evidence(
evidence_id: str,
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
):
item = evidence_service.get_evidence(tenant_id=ctx.tenant_id, evidence_id=evidence_id)
if not item:
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
return item
@router.patch("/{evidence_id}")
def update_evidence(
evidence_id: str,
body: EvidenceUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
):
try:
item = evidence_service.update_evidence(
tenant_id=ctx.tenant_id,
evidence_id=evidence_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
milestone_id=body.milestone_id,
clear_action_id=body.clear_action_id,
clear_milestone_id=body.clear_milestone_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
return item
@router.delete("/{evidence_id}", status_code=204)
def delete_evidence(
evidence_id: str,
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
):
if not evidence_service.delete_evidence(
tenant_id=ctx.tenant_id, evidence_id=evidence_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
from capabilities import require_capability from capabilities import require_capability
@ -10,8 +11,12 @@ from pydantic import BaseModel, Field
from services import actions as action_service from services import actions as action_service
from services import backlog as backlog_service from services import backlog as backlog_service
from services import blockers as blocker_service from services import blockers as blocker_service
from services import decisions as decision_service
from services import evidence as evidence_service
from services import initiatives as initiative_service from services import initiatives as initiative_service
from services import milestones as milestone_service from services import milestones as milestone_service
from services import recurring as recurring_service
from services import reviews as review_service
from tenant_context import TenantContext from tenant_context import TenantContext
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"]) router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
@ -36,8 +41,11 @@ class InitiativeUpdateRequest(BaseModel):
class ActionCreateRequest(BaseModel): class ActionCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
description: str = "" description: str = ""
status: Literal["open", "in_progress", "blocked", "done", "discarded"] = "open" status: Literal[
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
] = "open"
priority: Literal["low", "normal", "high"] = "normal" priority: Literal["low", "normal", "high"] = "normal"
due_at: Optional[str] = None
assigned_actor_ids: list[str] = Field(default_factory=list) assigned_actor_ids: list[str] = Field(default_factory=list)
@ -63,6 +71,47 @@ class MilestoneCreateRequest(BaseModel):
target_date: Optional[str] = None target_date: Optional[str] = None
class EvidenceCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["submitted", "accepted", "rejected"] = "submitted"
action_id: Optional[str] = None
milestone_id: Optional[str] = None
class DecisionCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["proposed", "decided", "superseded"] = "proposed"
outcome: str = ""
class ReviewCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
summary: str = ""
status: Literal["planned", "completed", "skipped"] = "planned"
due_at: Optional[str] = None
action_id: Optional[str] = None
milestone_id: Optional[str] = None
class RecurringCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["active", "paused", "ended"] = "active"
interval_days: Optional[int] = Field(default=None, gt=0)
next_due_at: Optional[str] = None
def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Ungültiges {field_name}") from exc
@router.get("") @router.get("")
def list_initiatives( def list_initiatives(
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
@ -163,6 +212,7 @@ def create_initiative_action(
ctx: TenantContext = Depends(require_capability("kairo.action.manage")), ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
): ):
try: try:
due_at = _parse_optional_datetime(body.due_at, "due_at")
return action_service.create_action( return action_service.create_action(
tenant_id=ctx.tenant_id, tenant_id=ctx.tenant_id,
initiative_id=initiative_id, initiative_id=initiative_id,
@ -170,6 +220,7 @@ def create_initiative_action(
description=body.description, description=body.description,
status=body.status, status=body.status,
priority=body.priority, priority=body.priority,
due_at=due_at,
assigned_actor_ids=body.assigned_actor_ids, assigned_actor_ids=body.assigned_actor_ids,
user_id=ctx.user_id, user_id=ctx.user_id,
) )
@ -302,3 +353,164 @@ def create_initiative_milestone(
if detail == "Initiative nicht gefunden": if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/evidence")
def list_initiative_evidence(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
):
try:
return evidence_service.list_evidence_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/evidence", status_code=201)
def create_initiative_evidence(
initiative_id: str,
body: EvidenceCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
):
try:
return evidence_service.create_evidence(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
milestone_id=body.milestone_id,
submitted_by_actor_id=ctx.actor_id,
user_id=ctx.user_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
@router.get("/{initiative_id}/decisions")
def list_initiative_decisions(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
):
try:
return decision_service.list_decisions_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/decisions", status_code=201)
def create_initiative_decision(
initiative_id: str,
body: DecisionCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
):
try:
return decision_service.create_decision(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
outcome=body.outcome,
decided_by_actor_id=ctx.actor_id if body.status == "decided" else None,
user_id=ctx.user_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
@router.get("/{initiative_id}/reviews")
def list_initiative_reviews(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
):
try:
return review_service.list_reviews_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/reviews", status_code=201)
def create_initiative_review(
initiative_id: str,
body: ReviewCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
):
due_at = _parse_optional_datetime(body.due_at, "due_at")
try:
return review_service.create_review(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
summary=body.summary,
status=body.status,
due_at=due_at,
action_id=body.action_id,
milestone_id=body.milestone_id,
reviewed_by_actor_id=ctx.actor_id,
user_id=ctx.user_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
@router.get("/{initiative_id}/recurring")
def list_initiative_recurring(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
):
try:
return recurring_service.list_recurring_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/recurring", status_code=201)
def create_initiative_recurring(
initiative_id: str,
body: RecurringCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
next_due_at = _parse_optional_datetime(body.next_due_at, "next_due_at")
try:
return recurring_service.create_recurring_element(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
interval_days=body.interval_days,
next_due_at=next_due_at,
user_id=ctx.user_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

View File

@ -0,0 +1,84 @@
"""RecurringElement API — AP0.9e."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import recurring as recurring_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/recurring", tags=["recurring"])
class RecurringUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["active", "paused", "ended"]] = None
interval_days: Optional[int] = Field(default=None, gt=0)
clear_interval_days: bool = False
next_due_at: Optional[str] = None
clear_next_due_at: bool = False
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
if value is None:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges next_due_at") from exc
@router.get("/{recurring_id}")
def get_recurring_element(
recurring_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
):
item = recurring_service.get_recurring_element(
tenant_id=ctx.tenant_id, recurring_id=recurring_id
)
if not item:
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
return item
@router.patch("/{recurring_id}")
def update_recurring_element(
recurring_id: str,
body: RecurringUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
next_due_at = _parse_datetime(body.next_due_at) if body.next_due_at is not None else None
try:
item = recurring_service.update_recurring_element(
tenant_id=ctx.tenant_id,
recurring_id=recurring_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
interval_days=body.interval_days,
clear_interval_days=body.clear_interval_days,
next_due_at=next_due_at,
clear_next_due_at=body.clear_next_due_at,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
return item
@router.delete("/{recurring_id}", status_code=204)
def delete_recurring_element(
recurring_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
if not recurring_service.delete_recurring_element(
tenant_id=ctx.tenant_id, recurring_id=recurring_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")

View File

@ -0,0 +1,86 @@
"""Review API — AP0.9d."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import reviews as review_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
class ReviewUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
summary: Optional[str] = None
status: Optional[Literal["planned", "completed", "skipped"]] = None
due_at: Optional[str] = None
clear_due_at: bool = False
action_id: Optional[str] = None
milestone_id: Optional[str] = None
clear_action_id: bool = False
clear_milestone_id: bool = False
def _parse_due_at(value: Optional[str]) -> Optional[datetime]:
if value is None:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
@router.get("/{review_id}")
def get_review(
review_id: str,
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
):
item = review_service.get_review(tenant_id=ctx.tenant_id, review_id=review_id)
if not item:
raise HTTPException(status_code=404, detail="Review nicht gefunden")
return item
@router.patch("/{review_id}")
def update_review(
review_id: str,
body: ReviewUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
):
due_at = _parse_due_at(body.due_at) if body.due_at is not None else None
try:
item = review_service.update_review(
tenant_id=ctx.tenant_id,
review_id=review_id,
user_id=ctx.user_id,
title=body.title,
summary=body.summary,
status=body.status,
due_at=due_at,
clear_due_at=body.clear_due_at,
action_id=body.action_id,
milestone_id=body.milestone_id,
clear_action_id=body.clear_action_id,
clear_milestone_id=body.clear_milestone_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Review nicht gefunden")
return item
@router.delete("/{review_id}", status_code=204)
def delete_review(
review_id: str,
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
):
if not review_service.delete_review(
tenant_id=ctx.tenant_id, review_id=review_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Review nicht gefunden")

View File

@ -10,10 +10,21 @@ from db import get_connection
from services.audit import log_audit from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative from services.initiatives import PRIORITIES, get_initiative
ActionStatus = Literal["open", "in_progress", "blocked", "done", "discarded"] ActionStatus = Literal[
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
]
ACTION_STATUSES = frozenset({"open", "in_progress", "blocked", "done", "discarded"}) ACTION_STATUSES = frozenset(
OPEN_ACTION_STATUSES = frozenset({"open", "in_progress", "blocked"}) {"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"}
)
OPEN_ACTION_STATUSES = frozenset(
{"open", "ready", "in_progress", "blocked", "review_required"}
)
_ACTION_COLUMNS = """
id, tenant_id, initiative_id, title, description,
status, priority, due_at, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
@ -25,6 +36,8 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result["created_at"] = result["created_at"].isoformat() result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"): if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat() result["updated_at"] = result["updated_at"].isoformat()
if result.get("due_at"):
result["due_at"] = result["due_at"].isoformat()
return result return result
@ -94,6 +107,7 @@ def create_action(
description: str = "", description: str = "",
status: ActionStatus = "open", status: ActionStatus = "open",
priority: str = "normal", priority: str = "normal",
due_at: Optional[Any] = None,
assigned_actor_ids: Optional[list[str]] = None, assigned_actor_ids: Optional[list[str]] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -114,15 +128,14 @@ def create_action(
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( cur.execute(
""" f"""
INSERT INTO actions ( INSERT INTO actions (
tenant_id, initiative_id, title, description, status, priority tenant_id, initiative_id, title, description, status, priority, due_at
) )
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, description, RETURNING {_ACTION_COLUMNS}
status, priority, created_at, updated_at
""", """,
(tenant_id, initiative_id, title, description, status, priority), (tenant_id, initiative_id, title, description, status, priority, due_at),
) )
row = _serialize_row(dict(cur.fetchone())) row = _serialize_row(dict(cur.fetchone()))
for actor_id in assigned_actor_ids: for actor_id in assigned_actor_ids:
@ -162,9 +175,8 @@ def list_actions_for_initiative(
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( cur.execute(
""" f"""
SELECT id, tenant_id, initiative_id, title, description, SELECT {_ACTION_COLUMNS}
status, priority, created_at, updated_at
FROM actions FROM actions
WHERE tenant_id = %s AND initiative_id = %s WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title ORDER BY updated_at DESC, title
@ -182,9 +194,8 @@ def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]:
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( cur.execute(
""" f"""
SELECT id, tenant_id, initiative_id, title, description, SELECT {_ACTION_COLUMNS}
status, priority, created_at, updated_at
FROM actions FROM actions
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
""", """,
@ -208,6 +219,8 @@ def update_action(
description: Optional[str] = None, description: Optional[str] = None,
status: Optional[ActionStatus] = None, status: Optional[ActionStatus] = None,
priority: Optional[str] = None, priority: Optional[str] = None,
due_at: Optional[Any] = None,
clear_due_at: bool = False,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id) existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing: if not existing:
@ -234,6 +247,11 @@ def update_action(
_validate_priority(priority) _validate_priority(priority)
updates.append("priority = %s") updates.append("priority = %s")
params.append(priority) params.append(priority)
if clear_due_at:
updates.append("due_at = NULL")
elif due_at is not None:
updates.append("due_at = %s")
params.append(due_at)
if not updates: if not updates:
return existing return existing
@ -249,15 +267,13 @@ def update_action(
UPDATE actions UPDATE actions
SET {", ".join(updates)} SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description, RETURNING {_ACTION_COLUMNS}
status, priority, created_at, updated_at
""", """,
params, params,
) )
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None return None
result = _serialize_row(dict(row))
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
@ -374,7 +390,7 @@ def list_open_actions_for_actor(
cur.execute( cur.execute(
""" """
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description, SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
a.status, a.priority, a.created_at, a.updated_at, a.status, a.priority, a.due_at, a.created_at, a.updated_at,
i.title AS initiative_title i.title AS initiative_title
FROM actions a FROM actions a
JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id

View File

@ -0,0 +1,265 @@
"""Decision service — tenant-scoped CRUD (AP0.9c)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
DecisionStatus = Literal["proposed", "decided", "superseded"]
DECISION_STATUSES = frozenset({"proposed", "decided", "superseded"})
_DECISION_COLUMNS = """
id, tenant_id, initiative_id, title, description, status, outcome,
decided_by_actor_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", "decided_by_actor_id"):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("created_at", "updated_at"):
if result.get(ts_key):
result[ts_key] = result[ts_key].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in DECISION_STATUSES:
raise ValueError(f"Ungültiger Decision-Status: {status}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def create_decision(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: DecisionStatus = "proposed",
outcome: str = "",
decided_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
if decided_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=decided_by_actor_id
):
raise ValueError("Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO decisions (
tenant_id, initiative_id, title, description, status, outcome,
decided_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING {_DECISION_COLUMNS}
""",
(
tenant_id,
initiative_id,
title,
description,
status,
outcome,
decided_by_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"decision.created",
user_id=user_id,
tenant_id=tenant_id,
details={"decision_id": row["id"], "initiative_id": initiative_id},
)
return row
def list_decisions_for_initiative(
*, 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")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_DECISION_COLUMNS}
FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_decision(*, tenant_id: str, decision_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_DECISION_COLUMNS}
FROM decisions
WHERE id = %s AND tenant_id = %s
""",
(decision_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_decision(
*,
tenant_id: str,
decision_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[DecisionStatus] = None,
outcome: Optional[str] = None,
decided_by_actor_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_decision(tenant_id=tenant_id, decision_id=decision_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if outcome is not None:
updates.append("outcome = %s")
params.append(outcome)
if decided_by_actor_id is not None:
if decided_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=decided_by_actor_id
):
raise ValueError("Actor gehört nicht zum Tenant")
updates.append("decided_by_actor_id = %s")
params.append(decided_by_actor_id or None)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([decision_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE decisions
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_DECISION_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"decision.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"decision_id": decision_id},
)
if status is not None and status != old_status:
log_audit(
"decision.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"decision_id": decision_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_decision(
*,
tenant_id: str,
decision_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM decisions WHERE id = %s AND tenant_id = %s RETURNING id",
(decision_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"decision.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"decision_id": decision_id},
)
return deleted

View File

@ -0,0 +1,318 @@
"""Evidence service — tenant-scoped CRUD (AP0.9b)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
EvidenceStatus = Literal["submitted", "accepted", "rejected"]
EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"})
_EVIDENCE_COLUMNS = """
id, tenant_id, initiative_id, action_id, milestone_id, title, description,
status, submitted_by_actor_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",
"action_id",
"milestone_id",
"submitted_by_actor_id",
):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("created_at", "updated_at"):
if result.get(ts_key):
result[ts_key] = result[ts_key].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in EVIDENCE_STATUSES:
raise ValueError(f"Ungültiger Evidence-Status: {status}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(action_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _milestone_in_initiative(
*, tenant_id: str, initiative_id: str, milestone_id: str
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM milestones
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(milestone_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def create_evidence(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: EvidenceStatus = "submitted",
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
submitted_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
if action_id and not _action_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
if milestone_id and not _milestone_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
if submitted_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=submitted_by_actor_id
):
raise ValueError("Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO evidence (
tenant_id, initiative_id, action_id, milestone_id,
title, description, status, submitted_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_EVIDENCE_COLUMNS}
""",
(
tenant_id,
initiative_id,
action_id,
milestone_id,
title,
description,
status,
submitted_by_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"evidence.created",
user_id=user_id,
tenant_id=tenant_id,
details={"evidence_id": row["id"], "initiative_id": initiative_id},
)
return row
def list_evidence_for_initiative(
*, 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")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_EVIDENCE_COLUMNS}
FROM evidence
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_evidence(*, tenant_id: str, evidence_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_EVIDENCE_COLUMNS}
FROM evidence
WHERE id = %s AND tenant_id = %s
""",
(evidence_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_evidence(
*,
tenant_id: str,
evidence_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[EvidenceStatus] = None,
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
clear_action_id: bool = False,
clear_milestone_id: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id)
if not existing:
return None
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_action_id:
updates.append("action_id = NULL")
elif action_id is not None:
if not _action_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
action_id=action_id,
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
updates.append("action_id = %s")
params.append(action_id)
if clear_milestone_id:
updates.append("milestone_id = NULL")
elif milestone_id is not None:
if not _milestone_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
milestone_id=milestone_id,
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
updates.append("milestone_id = %s")
params.append(milestone_id)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([evidence_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE evidence
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_EVIDENCE_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"evidence.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"evidence_id": evidence_id},
)
return result
def delete_evidence(
*,
tenant_id: str,
evidence_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM evidence WHERE id = %s AND tenant_id = %s RETURNING id",
(evidence_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"evidence.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"evidence_id": evidence_id},
)
return deleted

View File

@ -0,0 +1,260 @@
"""RecurringElement service — tenant-scoped CRUD (AP0.9e)."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
RecurringStatus = Literal["active", "paused", "ended"]
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
_RECURRING_COLUMNS = """
id, tenant_id, initiative_id, title, description, status,
interval_days, next_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"):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("created_at", "updated_at", "next_due_at"):
if result.get(ts_key):
result[ts_key] = result[ts_key].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in RECURRING_STATUSES:
raise ValueError(f"Ungültiger Recurring-Status: {status}")
def create_recurring_element(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: RecurringStatus = "active",
interval_days: Optional[int] = None,
next_due_at: Optional[datetime] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if interval_days is not None and interval_days <= 0:
raise ValueError("interval_days muss positiv sein")
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO recurring_elements (
tenant_id, initiative_id, title, description, status,
interval_days, next_due_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING {_RECURRING_COLUMNS}
""",
(
tenant_id,
initiative_id,
title,
description,
status,
interval_days,
next_due_at,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"recurring.created",
user_id=user_id,
tenant_id=tenant_id,
details={"recurring_id": row["id"], "initiative_id": initiative_id},
)
return row
def list_recurring_for_initiative(
*, 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")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_RECURRING_COLUMNS}
FROM recurring_elements
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_recurring_element(
*, tenant_id: str, recurring_id: str
) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_RECURRING_COLUMNS}
FROM recurring_elements
WHERE id = %s AND tenant_id = %s
""",
(recurring_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_recurring_element(
*,
tenant_id: str,
recurring_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[RecurringStatus] = None,
interval_days: Optional[int] = None,
clear_interval_days: bool = False,
next_due_at: Optional[datetime] = None,
clear_next_due_at: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_interval_days:
updates.append("interval_days = NULL")
elif interval_days is not None:
if interval_days <= 0:
raise ValueError("interval_days muss positiv sein")
updates.append("interval_days = %s")
params.append(interval_days)
if clear_next_due_at:
updates.append("next_due_at = NULL")
elif next_due_at is not None:
updates.append("next_due_at = %s")
params.append(next_due_at)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([recurring_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE recurring_elements
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_RECURRING_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"recurring.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"recurring_id": recurring_id},
)
if status is not None and status != old_status:
log_audit(
"recurring.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"recurring_id": recurring_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_recurring_element(
*,
tenant_id: str,
recurring_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
DELETE FROM recurring_elements
WHERE id = %s AND tenant_id = %s RETURNING id
""",
(recurring_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"recurring.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"recurring_id": recurring_id},
)
return deleted

340
backend/services/reviews.py Normal file
View File

@ -0,0 +1,340 @@
"""Review service — tenant-scoped CRUD (AP0.9d)."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
ReviewStatus = Literal["planned", "completed", "skipped"]
REVIEW_STATUSES = frozenset({"planned", "completed", "skipped"})
_REVIEW_COLUMNS = """
id, tenant_id, initiative_id, action_id, milestone_id, title, summary,
status, due_at, reviewed_by_actor_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",
"action_id",
"milestone_id",
"reviewed_by_actor_id",
):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("created_at", "updated_at", "due_at"):
if result.get(ts_key):
result[ts_key] = result[ts_key].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in REVIEW_STATUSES:
raise ValueError(f"Ungültiger Review-Status: {status}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(action_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _milestone_in_initiative(
*, tenant_id: str, initiative_id: str, milestone_id: str
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM milestones
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(milestone_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def create_review(
*,
tenant_id: str,
initiative_id: str,
title: str,
summary: str = "",
status: ReviewStatus = "planned",
due_at: Optional[datetime] = None,
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
reviewed_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
if action_id and not _action_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
if milestone_id and not _milestone_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
if reviewed_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=reviewed_by_actor_id
):
raise ValueError("Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO reviews (
tenant_id, initiative_id, action_id, milestone_id,
title, summary, status, due_at, reviewed_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_REVIEW_COLUMNS}
""",
(
tenant_id,
initiative_id,
action_id,
milestone_id,
title,
summary,
status,
due_at,
reviewed_by_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"review.created",
user_id=user_id,
tenant_id=tenant_id,
details={"review_id": row["id"], "initiative_id": initiative_id},
)
return row
def list_reviews_for_initiative(
*, 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")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_REVIEW_COLUMNS}
FROM reviews
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_review(*, tenant_id: str, review_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_REVIEW_COLUMNS}
FROM reviews
WHERE id = %s AND tenant_id = %s
""",
(review_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_review(
*,
tenant_id: str,
review_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
summary: Optional[str] = None,
status: Optional[ReviewStatus] = None,
due_at: Optional[datetime] = None,
clear_due_at: bool = False,
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
clear_action_id: bool = False,
clear_milestone_id: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_review(tenant_id=tenant_id, review_id=review_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if summary is not None:
updates.append("summary = %s")
params.append(summary)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_due_at:
updates.append("due_at = NULL")
elif due_at is not None:
updates.append("due_at = %s")
params.append(due_at)
if clear_action_id:
updates.append("action_id = NULL")
elif action_id is not None:
if not _action_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
action_id=action_id,
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
updates.append("action_id = %s")
params.append(action_id)
if clear_milestone_id:
updates.append("milestone_id = NULL")
elif milestone_id is not None:
if not _milestone_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
milestone_id=milestone_id,
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
updates.append("milestone_id = %s")
params.append(milestone_id)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([review_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE reviews
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_REVIEW_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"review.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"review_id": review_id},
)
if status is not None and status != old_status:
log_audit(
"review.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"review_id": review_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_review(
*,
tenant_id: str,
review_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM reviews WHERE id = %s AND tenant_id = %s RETURNING id",
(review_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"review.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"review_id": review_id},
)
return deleted

View File

@ -0,0 +1,165 @@
"""AP0.9 — Operating Model Extension II tests."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_action,
_create_initiative,
_login,
)
def _create_evidence(client, token, initiative_id, **kwargs):
body = {"title": "Evidence Test", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/evidence",
json=body,
headers=_auth(token),
)
def _create_decision(client, token, initiative_id, **kwargs):
body = {"title": "Decision Test", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/decisions",
json=body,
headers=_auth(token),
)
def _create_review(client, token, initiative_id, **kwargs):
body = {"title": "Review Test", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/reviews",
json=body,
headers=_auth(token),
)
def _create_recurring(client, token, initiative_id, **kwargs):
body = {"title": "Recurring Test", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/recurring",
json=body,
headers=_auth(token),
)
def test_action_ready_status_and_due_at(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
due = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
created = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Due Action", "status": "ready", "due_at": due},
headers=_auth(token),
)
assert created.status_code == 201
action = created.json()
assert action["status"] == "ready"
assert action["due_at"] is not None
def test_attention_overdue_action(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
action = _create_action(client, token, initiative_id, title="Overdue").json()
past = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
client.patch(
f"/api/actions/{action['id']}",
json={"due_at": past},
headers=_auth(token),
)
res = client.get("/api/workspace/attention", headers=_auth(token))
assert res.status_code == 200
assert any(i["kind"] == "overdue_action" for i in res.json())
def test_attention_review_due(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
created = _create_review(
client, token, initiative_id, status="planned", due_at=past
)
assert created.status_code == 201
res = client.get("/api/workspace/attention", headers=_auth(token))
assert res.status_code == 200
assert any(i["kind"] == "review_due" for i in res.json())
def test_attention_recurring_due(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
created = _create_recurring(
client, token, initiative_id, status="active", next_due_at=past
)
assert created.status_code == 201
res = client.get("/api/workspace/attention", headers=_auth(token))
assert res.status_code == 200
assert any(i["kind"] == "recurring_due" for i in res.json())
def test_evidence_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = _create_evidence(client, token, initiative_id)
assert created.status_code == 201
ev_id = created.json()["id"]
listed = client.get(
f"/api/initiatives/{initiative_id}/evidence", headers=_auth(token)
)
assert listed.status_code == 200
assert len(listed.json()) == 1
deleted = client.delete(f"/api/evidence/{ev_id}", headers=_auth(token))
assert deleted.status_code == 204
def test_decision_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = _create_decision(client, token, initiative_id, status="proposed")
assert created.status_code == 201
dec_id = created.json()["id"]
patched = client.patch(
f"/api/decisions/{dec_id}",
json={"status": "decided", "outcome": "Ja"},
headers=_auth(token),
)
assert patched.status_code == 200
assert patched.json()["status"] == "decided"
def test_member_has_ap09_capabilities(client):
member = provision_user_in_tenant(tenant_role="member")
token = _login(client, member)
ctx = client.get("/api/me/context", headers=_auth(token)).json()
caps = ctx["capabilities"]
for key in (
"kairo.evidence.read",
"kairo.evidence.manage",
"kairo.decision.read",
"kairo.decision.manage",
"kairo.review.read",
"kairo.review.manage",
"kairo.recurring.read",
"kairo.recurring.manage",
):
assert key in caps

View File

@ -38,6 +38,7 @@ def test_migration_runner_finds_migrations():
assert "005_prompt_feature_config_registry" in names assert "005_prompt_feature_config_registry" in names
assert "006_initiatives_actions" in names assert "006_initiatives_actions" in names
assert "007_operating_model_extension_i" in names assert "007_operating_model_extension_i" in names
assert "008_operating_model_extension_ii" in names
def test_migration_runner_is_idempotent(): def test_migration_runner_is_idempotent():
@ -54,6 +55,7 @@ def test_migration_runner_is_idempotent():
assert "005_prompt_feature_config_registry" in executed assert "005_prompt_feature_config_registry" in executed
assert "006_initiatives_actions" in executed assert "006_initiatives_actions" in executed
assert "007_operating_model_extension_i" in executed assert "007_operating_model_extension_i" in executed
assert "008_operating_model_extension_ii" in executed
def test_core_table_exists(): def test_core_table_exists():

View File

@ -35,6 +35,14 @@ def test_registry_contains_initial_capabilities():
"kairo.backlog.manage", "kairo.backlog.manage",
"kairo.milestone.read", "kairo.milestone.read",
"kairo.milestone.manage", "kairo.milestone.manage",
"kairo.evidence.read",
"kairo.evidence.manage",
"kairo.decision.read",
"kairo.decision.manage",
"kairo.review.read",
"kairo.review.manage",
"kairo.recurring.read",
"kairo.recurring.manage",
} }
@ -46,7 +54,7 @@ def test_sync_is_idempotent():
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities") cur.execute("SELECT COUNT(*) FROM capabilities")
assert cur.fetchone()[0] == 25 assert cur.fetchone()[0] == 33
cur.execute("SELECT COUNT(*) FROM role_capability_grants") cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5 assert cur.fetchone()[0] >= 5
finally: finally:

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.8.0-ap0.8" APP_VERSION = "0.9.0-ap0.9"
DB_SCHEMA_VERSION = "007" DB_SCHEMA_VERSION = "008"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,62 @@
# AP0.8 Abschlussbericht Operating Model Extension I
**Status:** abgeschlossen (Remote-QA)
**Stand:** 2026-07-05 (final)
**Version:** Backend/Frontend `0.8.0-ap0.8` · Schema `007`
**Commits:** `fe3bc4f`, `c9101db`
---
## 1. Scope und Einordnung
AP0.8 liefert den ersten Operating-Model-Ausbau nach dem Product Reset: Attention/NextAction (regelbasiert), Blocker, BacklogItem, Milestone minimal.
**Bewusste Grenze (bestätigt durch Nutzer-QA):** Objekte stehen **parallel zur Maßnahme** — keine Steuerungsgraph-Logik, keine Abhängigkeiten, keine Sprint-/Methoden-Anbindung, kein durchgängiger Next-Best-Action-Flow in der UI. Das ist **architekturkonform für AP0.8** und wird erst mit Steering Core / AP1.x+ verbunden.
---
## 2. Definition of Done — Prüfmatrix
| Kriterium | Ergebnis |
|-----------|----------|
| Migration 007, Schema 007 | ✓ Pi: `0.8.0-ap0.8` |
| Attention Regeln 16 | ✓ |
| Blocker, Backlog, Milestone CRUD | ✓ |
| Convert Backlog → Action | ✓ |
| AttentionWidget + Initiative-Sektionen | ✓ (nach UI-Fix `c9101db`) |
| 25 Capabilities | ✓ |
| Remote Deploy + lauffähig | ✓ Nutzer bestätigt |
| Kein KI/MCP/Steering | ✓ |
| Abschlussbericht + README | ✓ |
---
## 3. QA-Feedback und technische Schulden
| Feedback | Einordnung | Folge |
|----------|------------|-------|
| UI „nicht schön“, erweiterungsnötig | Akzeptiert für MVP-Slice | AP0.10 Validation; kein Redesign in AP0.8 |
| Alles parallel zu Maßnahmen | Erwarteter Zwischenzustand | AP0.9 optionale FKs; Steuerung später |
| Verbindungen / Abhängigkeiten fehlen | Nicht-Scope AP0.8 | SteeringContext, Method Registry → AP1.x |
| Next Best Action nicht durchgängig | API vorhanden, kein UI-Widget | AP0.9+ oder dedizierter UI-Slice |
| Sprints etc. fehlen | Nicht-Scope | Roadmap/WorkCycles später |
---
## 4. Abweichungen vom Auftrag
| Punkt | Abweichung |
|-------|------------|
| Attention-Widget Capability | UI: `kairo.workspace.read` (Fix Deploy-Sichtbarkeit) |
| Initiative-OM-Sektionen | Sichtbar bei `kairo.initiative.read` |
| Regel 1b open_blocker | Ergänzung v0.2 |
---
## 5. Empfehlung nächster Schritt
**AP0.9 — Operating Model Extension II** (Evidence, Decision, Review, RecurringElement, Action-Status-Erweiterung, Due Dates, Attention 79) — schließt Canonical MVP §12 Lücke vor AP0.10 Validation.
---
*v0.2 final — ersetzt Sprint0_AP0_8_Completion_Report_v0.1.md*

View File

@ -0,0 +1,55 @@
# AP0.9 Operating Model Extension II
## Implementierungsauftrag v0.1 (freigegeben)
**Status:** freigegeben zur Umsetzung
**Stand:** 2026-07-05
**Vorgänger:** AP0.8 ✓
**Zielversion:** `0.9.0-ap0.9` · Schema `008`
---
## Einordnung
AP0.9 schließt **Steuerung und Nachvollziehbarkeit** im Operating Model — noch **ohne** Steering Core, Methoden, Abhängigkeiten oder Sprint-Logik.
AP0.8-Feedback: parallele Sektionen sind **akzeptiert**; AP0.9 führt **optionale Verknüpfungen** (Evidence/Review → Action/Milestone) ein, aber **keine** orchestrated Steuerung.
Evolutionslinie: System Target State §24 Schritt 7 (Evidence/Review/Decision) + Attention-Regeln 79.
---
## Scope
| Slice | Inhalt |
|-------|--------|
| 9a | Action: Status `ready`, `review_required`; `due_at`; Attention Regel 7 |
| 9b | Evidence minimal |
| 9c | Decision minimal |
| 9d | Review minimal + Attention Regel 8 |
| 9e | RecurringElement + Attention Regel 9 |
## Nicht-Scope
- SteeringContext, Method Registry, Dependencies, Sprints
- Review → Backlog/Action Auto-Erzeugung
- Kalender, LLM, MCP, Workflow
- UI-Redesign
---
## Migration `008_operating_model_extension_ii.sql`
- `ALTER actions``due_at`, erweiterte Status-Checks
- `evidence`, `decisions`, `reviews`, `recurring_elements`
## Capabilities (+8 → 33 gesamt)
`kairo.evidence.*`, `kairo.decision.*`, `kairo.review.*`, `kairo.recurring.*`
## Abnahme
Schema 008, CRUD tenant-sicher, Attention 79, Initiative-Detail-Sektionen, Tests, README, Abschlussbericht.
---
*Freigegeben — Umsetzung 9a→9e.*

View File

@ -0,0 +1,46 @@
# AP0.9 Abschlussbericht Operating Model Extension II
**Status:** implementiert (Remote-QA ausstehend)
**Stand:** 2026-07-05
**Version:** Backend/Frontend `0.9.0-ap0.9` · Schema `008`
---
## 1. Geliefert
| Slice | Inhalt | Status |
|-------|--------|--------|
| 9a | Action `ready`/`review_required`, `due_at`, Attention Regel 7 | ✓ |
| 9b | Evidence CRUD | ✓ |
| 9c | Decision CRUD | ✓ |
| 9d | Review CRUD + Attention Regel 8 | ✓ |
| 9e | RecurringElement CRUD + Attention Regel 9 | ✓ |
Migration `008_operating_model_extension_ii.sql`, 8 neue Capabilities (33 gesamt), Initiative-Detail-Sektionen, Tests `test_ap09_operating_model.py`.
---
## 2. Bewusste Grenzen (Fortführung AP0.8-Feedback)
- Sektionen weiterhin **parallel** im Vorhaben-Detail — kein Steuerungsgraph
- Optionale FKs Evidence/Review → Action/Milestone in API, **keine** UI-Verknüpfung
- Kein Steering Core, keine Abhängigkeiten, keine Sprints
- NextActionCandidates unverändert (kein UI-Widget)
---
## 3. Abnahme
- [ ] Pi-Deploy `0.9.0-ap0.9` / Schema `008`
- [ ] Gitea `test.yml` grün
- [ ] Manuelle QA Vorhaben-Detail (neue Sektionen, Fälligkeit Maßnahme)
---
## 4. Nächster Schritt
**AP0.10 — Operating Model Validation** laut Corrected MVP Roadmap.
---
*v0.1 — ersetzt nach Remote-QA durch v0.2*

View File

@ -1,6 +1,6 @@
{ {
"name": "kairo-jinkendo-frontend", "name": "kairo-jinkendo-frontend",
"version": "0.8.0-ap0.8", "version": "0.9.0-ap0.9",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listInitiativeDecisions(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/decisions`)
}
export function createInitiativeDecision(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/decisions`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateDecision(decisionId, body) {
return apiFetch(`/api/decisions/${decisionId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteDecision(decisionId) {
return apiFetch(`/api/decisions/${decisionId}`, { method: 'DELETE' })
}

View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listInitiativeEvidence(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/evidence`)
}
export function createInitiativeEvidence(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/evidence`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateEvidence(evidenceId, body) {
return apiFetch(`/api/evidence/${evidenceId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteEvidence(evidenceId) {
return apiFetch(`/api/evidence/${evidenceId}`, { method: 'DELETE' })
}

View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listInitiativeRecurring(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/recurring`)
}
export function createInitiativeRecurring(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/recurring`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateRecurring(recurringId, body) {
return apiFetch(`/api/recurring/${recurringId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteRecurring(recurringId) {
return apiFetch(`/api/recurring/${recurringId}`, { method: 'DELETE' })
}

View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listInitiativeReviews(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/reviews`)
}
export function createInitiativeReview(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/reviews`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateReview(reviewId, body) {
return apiFetch(`/api/reviews/${reviewId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteReview(reviewId) {
return apiFetch(`/api/reviews/${reviewId}`, { method: 'DELETE' })
}

View File

@ -2,6 +2,13 @@ import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js' import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { ActorSelect } from './ActorSelect.jsx' import { ActorSelect } from './ActorSelect.jsx'
function isoToLocalInput(iso) {
if (!iso) return ''
const d = new Date(iso)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function ActionForm({ export function ActionForm({
initial = {}, initial = {},
actors = [], actors = [],
@ -23,6 +30,7 @@ export function ActionForm({
description: form.description.value, description: form.description.value,
status: form.status.value, status: form.status.value,
priority: form.priority.value, priority: form.priority.value,
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
assigned_actor_ids: selected, assigned_actor_ids: selected,
}) })
} }
@ -59,6 +67,14 @@ export function ActionForm({
))} ))}
</select> </select>
</label> </label>
<label>
Fällig am (optional)
<input
name="due_at"
type="datetime-local"
defaultValue={isoToLocalInput(initial.due_at)}
/>
</label>
<ActorSelect <ActorSelect
actors={actors} actors={actors}
defaultSelected={defaultActors} defaultSelected={defaultActors}

View File

@ -0,0 +1,90 @@
import { useState } from 'react'
import { DECISION_STATUSES, DECISION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
export function DecisionsSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
const [title, setTitle] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({ title: title.trim() })
setTitle('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Entscheidungen</h2>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Entscheidung anlegen'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && <EmptyState message="Keine Entscheidungen." />}
<ul className="item-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{item.title}</strong>
{item.outcome && <p className="list-item-desc">{item.outcome}</p>}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="decision" status={item.status} />
{canManage && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Entscheidungs-Status"
>
{DECISION_STATUSES.map((s) => (
<option key={s} value={s}>
{DECISION_STATUS_LABELS[s]}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -0,0 +1,90 @@
import { useState } from 'react'
import { EVIDENCE_STATUSES, EVIDENCE_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
const [title, setTitle] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({ title: title.trim() })
setTitle('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Evidence</h2>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Evidence hinzufügen'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && <EmptyState message="Kein Evidence." />}
<ul className="item-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{item.title}</strong>
{item.description && <p className="list-item-desc">{item.description}</p>}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="evidence" status={item.status} />
{canManage && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Evidence-Status"
>
{EVIDENCE_STATUSES.map((s) => (
<option key={s} value={s}>
{EVIDENCE_STATUS_LABELS[s]}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { RECURRING_STATUSES, RECURRING_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
function formatDueAt(iso) {
if (!iso) return null
try {
return new Date(iso).toLocaleString('de-DE')
} catch {
return iso
}
}
export function RecurringSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
const [title, setTitle] = useState('')
const [nextDueAt, setNextDueAt] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({
title: title.trim(),
next_due_at: nextDueAt ? new Date(nextDueAt).toISOString() : null,
})
setTitle('')
setNextDueAt('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Wiederkehrend</h2>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Element anlegen'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
/>
</label>
<label>
Nächste Fälligkeit
<input
type="datetime-local"
value={nextDueAt}
onChange={(e) => setNextDueAt(e.target.value)}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && <EmptyState message="Keine wiederkehrenden Elemente." />}
<ul className="item-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{item.title}</strong>
{item.next_due_at && (
<p className="muted list-item-sub">Fällig: {formatDueAt(item.next_due_at)}</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="recurring" status={item.status} />
{canManage && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Recurring-Status"
>
{RECURRING_STATUSES.map((s) => (
<option key={s} value={s}>
{RECURRING_STATUS_LABELS[s]}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { REVIEW_STATUSES, REVIEW_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
function formatDueAt(iso) {
if (!iso) return null
try {
return new Date(iso).toLocaleString('de-DE')
} catch {
return iso
}
}
export function ReviewsSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
const [title, setTitle] = useState('')
const [dueAt, setDueAt] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({
title: title.trim(),
due_at: dueAt ? new Date(dueAt).toISOString() : null,
})
setTitle('')
setDueAt('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Reviews</h2>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Review planen'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
/>
</label>
<label>
Fällig am
<input
type="datetime-local"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && <EmptyState message="Keine Reviews." />}
<ul className="item-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{item.title}</strong>
{item.due_at && (
<p className="muted list-item-sub">Fällig: {formatDueAt(item.due_at)}</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="review" status={item.status} />
{canManage && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Review-Status"
>
{REVIEW_STATUSES.map((s) => (
<option key={s} value={s}>
{REVIEW_STATUS_LABELS[s]}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -22,6 +22,17 @@ const VARIANTS = {
resolved: 'status-done', resolved: 'status-done',
accepted_risk: 'status-paused', accepted_risk: 'status-paused',
dismissed: 'status-discarded', dismissed: 'status-discarded',
ready: 'status-active',
review_required: 'status-progress',
submitted: 'status-open',
accepted: 'status-done',
rejected: 'status-discarded',
proposed: 'status-open',
decided: 'status-done',
superseded: 'status-paused',
completed: 'status-done',
skipped: 'status-paused',
ended: 'status-archived',
} }
export function StatusBadge({ kind = 'action', status }) { export function StatusBadge({ kind = 'action', status }) {

View File

@ -1,10 +1,22 @@
export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived'] export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
export const ACTION_STATUSES = ['open', 'in_progress', 'blocked', 'done', 'discarded'] export const ACTION_STATUSES = [
'open',
'ready',
'in_progress',
'blocked',
'review_required',
'done',
'discarded',
]
export const BLOCKER_STATUSES = ['open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed'] export const BLOCKER_STATUSES = ['open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed']
export const BACKLOG_STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'converted'] export const BACKLOG_STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'converted']
export const MILESTONE_STATUSES = ['planned', 'active', 'at_risk', 'reached', 'moved', 'discarded'] export const MILESTONE_STATUSES = ['planned', 'active', 'at_risk', 'reached', 'moved', 'discarded']
export const EVIDENCE_STATUSES = ['submitted', 'accepted', 'rejected']
export const DECISION_STATUSES = ['proposed', 'decided', 'superseded']
export const REVIEW_STATUSES = ['planned', 'completed', 'skipped']
export const RECURRING_STATUSES = ['active', 'paused', 'ended']
export const PRIORITIES = ['low', 'normal', 'high'] export const PRIORITIES = ['low', 'normal', 'high']
export const OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked'] export const OPEN_ACTION_STATUSES = ['open', 'ready', 'in_progress', 'blocked', 'review_required']
export const INITIATIVE_STATUS_LABELS = { export const INITIATIVE_STATUS_LABELS = {
active: 'Aktiv', active: 'Aktiv',
@ -15,8 +27,10 @@ export const INITIATIVE_STATUS_LABELS = {
export const ACTION_STATUS_LABELS = { export const ACTION_STATUS_LABELS = {
open: 'Offen', open: 'Offen',
ready: 'Bereit',
in_progress: 'In Arbeit', in_progress: 'In Arbeit',
blocked: 'Blockiert', blocked: 'Blockiert',
review_required: 'Review nötig',
done: 'Erledigt', done: 'Erledigt',
discarded: 'Verworfen', discarded: 'Verworfen',
} }
@ -46,6 +60,30 @@ export const MILESTONE_STATUS_LABELS = {
discarded: 'Verworfen', discarded: 'Verworfen',
} }
export const EVIDENCE_STATUS_LABELS = {
submitted: 'Eingereicht',
accepted: 'Akzeptiert',
rejected: 'Abgelehnt',
}
export const DECISION_STATUS_LABELS = {
proposed: 'Vorgeschlagen',
decided: 'Entschieden',
superseded: 'Ersetzt',
}
export const REVIEW_STATUS_LABELS = {
planned: 'Geplant',
completed: 'Abgeschlossen',
skipped: 'Übersprungen',
}
export const RECURRING_STATUS_LABELS = {
active: 'Aktiv',
paused: 'Pausiert',
ended: 'Beendet',
}
export const PRIORITY_LABELS = { export const PRIORITY_LABELS = {
low: 'Niedrig', low: 'Niedrig',
normal: 'Normal', normal: 'Normal',
@ -58,4 +96,8 @@ export const STATUS_LABELS_BY_KIND = {
blocker: BLOCKER_STATUS_LABELS, blocker: BLOCKER_STATUS_LABELS,
backlog: BACKLOG_STATUS_LABELS, backlog: BACKLOG_STATUS_LABELS,
milestone: MILESTONE_STATUS_LABELS, milestone: MILESTONE_STATUS_LABELS,
evidence: EVIDENCE_STATUS_LABELS,
decision: DECISION_STATUS_LABELS,
review: REVIEW_STATUS_LABELS,
recurring: RECURRING_STATUS_LABELS,
} }

View File

@ -25,13 +25,41 @@ import {
updateMilestone, updateMilestone,
deleteMilestone, deleteMilestone,
} from '../api/milestones.js' } from '../api/milestones.js'
import { ACTION_STATUS_LABELS } from '../constants/status.js' import {
listInitiativeEvidence,
createInitiativeEvidence,
updateEvidence,
deleteEvidence,
} from '../api/evidence.js'
import {
listInitiativeDecisions,
createInitiativeDecision,
updateDecision,
deleteDecision,
} from '../api/decisions.js'
import {
listInitiativeReviews,
createInitiativeReview,
updateReview,
deleteReview,
} from '../api/reviews.js'
import {
listInitiativeRecurring,
createInitiativeRecurring,
updateRecurring,
deleteRecurring,
} from '../api/recurring.js'
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from '../components/StatusBadge.jsx' import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { ActionForm } from '../components/ActionForm.jsx' import { ActionForm } from '../components/ActionForm.jsx'
import { BlockersSection } from '../components/BlockersSection.jsx' import { BlockersSection } from '../components/BlockersSection.jsx'
import { BacklogSection } from '../components/BacklogSection.jsx' import { BacklogSection } from '../components/BacklogSection.jsx'
import { MilestonesSection } from '../components/MilestonesSection.jsx' import { MilestonesSection } from '../components/MilestonesSection.jsx'
import { EvidenceSection } from '../components/EvidenceSection.jsx'
import { DecisionsSection } from '../components/DecisionsSection.jsx'
import { ReviewsSection } from '../components/ReviewsSection.jsx'
import { RecurringSection } from '../components/RecurringSection.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
@ -56,6 +84,10 @@ export function InitiativeDetailPage() {
const [blockers, setBlockers] = useState([]) const [blockers, setBlockers] = useState([])
const [backlogItems, setBacklogItems] = useState([]) const [backlogItems, setBacklogItems] = useState([])
const [milestones, setMilestones] = useState([]) const [milestones, setMilestones] = useState([])
const [evidenceItems, setEvidenceItems] = useState([])
const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([])
const [recurringItems, setRecurringItems] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [showActionForm, setShowActionForm] = useState(false) const [showActionForm, setShowActionForm] = useState(false)
@ -83,6 +115,10 @@ export function InitiativeDetailPage() {
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([]))) loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([]))) loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([]))) loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
} }
await Promise.all(loads) await Promise.all(loads)
} catch (err) { } catch (err) {
@ -127,6 +163,8 @@ export function InitiativeDetailPage() {
description: payload.description, description: payload.description,
status: payload.status, status: payload.status,
priority: payload.priority, priority: payload.priority,
due_at: payload.due_at,
clear_due_at: !payload.due_at,
}) })
if (capabilities.has('kairo.action.manage')) { if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(actionId, payload.assigned_actor_ids || []) await setActionAssignments(actionId, payload.assigned_actor_ids || [])
@ -252,6 +290,126 @@ export function InitiativeDetailPage() {
} }
} }
async function handleCreateEvidence(body) {
setFormBusy(true)
try {
await createInitiativeEvidence(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleEvidenceStatus(evidenceId, status) {
try {
await updateEvidence(evidenceId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteEvidence(evidenceId) {
try {
await deleteEvidence(evidenceId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateDecision(body) {
setFormBusy(true)
try {
await createInitiativeDecision(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDecisionStatus(decisionId, status) {
try {
await updateDecision(decisionId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteDecision(decisionId) {
try {
await deleteDecision(decisionId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateReview(body) {
setFormBusy(true)
try {
await createInitiativeReview(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleReviewStatus(reviewId, status) {
try {
await updateReview(reviewId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteReview(reviewId) {
try {
await deleteReview(reviewId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateRecurring(body) {
setFormBusy(true)
try {
await createInitiativeRecurring(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleRecurringStatus(recurringId, status) {
try {
await updateRecurring(recurringId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteRecurring(recurringId) {
try {
await deleteRecurring(recurringId)
await load()
} catch (err) {
setError(err.message)
}
}
if (loading) { if (loading) {
return ( return (
<div className="app-page"> <div className="app-page">
@ -367,6 +525,11 @@ export function InitiativeDetailPage() {
Zugewiesen: {action.assigned_actor_ids.length} Actor(s) Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
</p> </p>
)} )}
{action.due_at && (
<p className="muted list-item-sub">
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
</p>
)}
</div> </div>
<div className="list-item-meta action-controls"> <div className="list-item-meta action-controls">
<StatusBadge status={action.status} /> <StatusBadge status={action.status} />
@ -379,7 +542,7 @@ export function InitiativeDetailPage() {
onChange={(e) => handleQuickStatus(action, e.target.value)} onChange={(e) => handleQuickStatus(action, e.target.value)}
aria-label="Status ändern" aria-label="Status ändern"
> >
{['open', 'in_progress', 'blocked', 'done', 'discarded'].map((s) => ( {ACTION_STATUSES.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{ACTION_STATUS_LABELS[s]} {ACTION_STATUS_LABELS[s]}
</option> </option>
@ -438,6 +601,50 @@ export function InitiativeDetailPage() {
busy={formBusy} busy={formBusy}
/> />
)} )}
{capabilities.has('kairo.initiative.read') && (
<EvidenceSection
items={evidenceItems}
canManage={capabilities.has('kairo.evidence.manage')}
onCreate={handleCreateEvidence}
onUpdateStatus={handleEvidenceStatus}
onDelete={handleDeleteEvidence}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<DecisionsSection
items={decisions}
canManage={capabilities.has('kairo.decision.manage')}
onCreate={handleCreateDecision}
onUpdateStatus={handleDecisionStatus}
onDelete={handleDeleteDecision}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<ReviewsSection
items={reviews}
canManage={capabilities.has('kairo.review.manage')}
onCreate={handleCreateReview}
onUpdateStatus={handleReviewStatus}
onDelete={handleDeleteReview}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<RecurringSection
items={recurringItems}
canManage={capabilities.has('kairo.recurring.manage')}
onCreate={handleCreateRecurring}
onUpdateStatus={handleRecurringStatus}
onDelete={handleDeleteRecurring}
busy={formBusy}
/>
)}
</div> </div>
) )
} }