From a025542545e5e7f9a41b6399050c90cb46b9ea60 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 5 Jul 2026 15:27:03 +0200 Subject: [PATCH] AP0.9: Operating Model Extension II (Evidence, Decision, Review, Recurring, due_at) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 21 +- backend/data_layer/attention.py | 128 ++++++- backend/main.py | 8 + .../008_operating_model_extension_ii.sql | 82 +++++ .../operating_model_ops.py | 72 ++++ backend/routers/actions.py | 15 +- backend/routers/decisions.py | 67 ++++ backend/routers/evidence.py | 71 ++++ backend/routers/initiatives.py | 214 ++++++++++- backend/routers/recurring.py | 84 +++++ backend/routers/reviews.py | 86 +++++ backend/services/actions.py | 54 ++- backend/services/decisions.py | 265 ++++++++++++++ backend/services/evidence.py | 318 ++++++++++++++++ backend/services/recurring.py | 260 ++++++++++++++ backend/services/reviews.py | 340 ++++++++++++++++++ backend/tests/test_ap09_operating_model.py | 165 +++++++++ backend/tests/test_migrations.py | 2 + backend/tests/test_rights_registry.py | 10 +- backend/version.py | 4 +- .../Sprint0_AP0_8_Completion_Report_v0.2.md | 62 ++++ docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md | 55 +++ .../Sprint0_AP0_9_Completion_Report_v0.1.md | 46 +++ frontend/package.json | 2 +- frontend/src/api/decisions.js | 23 ++ frontend/src/api/evidence.js | 23 ++ frontend/src/api/recurring.js | 23 ++ frontend/src/api/reviews.js | 23 ++ frontend/src/components/ActionForm.jsx | 16 + frontend/src/components/DecisionsSection.jsx | 90 +++++ frontend/src/components/EvidenceSection.jsx | 90 +++++ frontend/src/components/RecurringSection.jsx | 114 ++++++ frontend/src/components/ReviewsSection.jsx | 114 ++++++ frontend/src/components/StatusBadge.jsx | 11 + frontend/src/constants/status.js | 46 ++- frontend/src/pages/InitiativeDetailPage.jsx | 211 ++++++++++- 36 files changed, 3178 insertions(+), 37 deletions(-) create mode 100644 backend/migrations/008_operating_model_extension_ii.sql create mode 100644 backend/routers/decisions.py create mode 100644 backend/routers/evidence.py create mode 100644 backend/routers/recurring.py create mode 100644 backend/routers/reviews.py create mode 100644 backend/services/decisions.py create mode 100644 backend/services/evidence.py create mode 100644 backend/services/recurring.py create mode 100644 backend/services/reviews.py create mode 100644 backend/tests/test_ap09_operating_model.py create mode 100644 docs/sprints/Sprint0_AP0_8_Completion_Report_v0.2.md create mode 100644 docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md create mode 100644 docs/sprints/Sprint0_AP0_9_Completion_Report_v0.1.md create mode 100644 frontend/src/api/decisions.js create mode 100644 frontend/src/api/evidence.js create mode 100644 frontend/src/api/recurring.js create mode 100644 frontend/src/api/reviews.js create mode 100644 frontend/src/components/DecisionsSection.jsx create mode 100644 frontend/src/components/EvidenceSection.jsx create mode 100644 frontend/src/components/RecurringSection.jsx create mode 100644 frontend/src/components/ReviewsSection.jsx diff --git a/README.md b/README.md index 23b389d..3e5c567 100644 --- a/README.md +++ b/README.md @@ -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). -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 7–9:** `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` `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` diff --git a/backend/data_layer/attention.py b/backend/data_layer/attention.py index c87d8fb..6f217f1 100644 --- a/backend/data_layer/attention.py +++ b/backend/data_layer/attention.py @@ -17,6 +17,9 @@ AttentionKind = Literal[ "initiative_without_next_action", "stale_initiative", "milestone_at_risk", + "overdue_action", + "review_due", + "recurring_due", ] NextActionKind = Literal[ @@ -32,13 +35,21 @@ Severity = Literal["info", "warning", "critical"] _SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2} 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") def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]: 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): item[key] = str(item[key]) return item @@ -136,7 +147,7 @@ def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: FROM actions a WHERE a.tenant_id = %s AND a.priority = 'high' - AND a.status IN ('open', 'in_progress') + AND a.status IN ('open', 'ready', 'in_progress') {actor_filter} ORDER BY a.updated_at DESC LIMIT 30 @@ -164,7 +175,7 @@ def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: 'actions' AS data_source FROM actions a WHERE a.tenant_id = %s - AND a.status IN ('open', 'in_progress') + AND a.status IN ('open', 'ready', 'in_progress') AND NOT EXISTS ( SELECT 1 FROM action_assignments aa 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 WHERE a.initiative_id = i.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 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()] +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]]: """Regelbasierte Attention Items — tenant-scoped, erklärbar.""" 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(_stale_initiatives(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)) return items @@ -337,7 +451,7 @@ def get_next_action_candidates( 'Maßnahme zuweisen' AS recommended_action FROM actions a WHERE a.tenant_id = %s - AND a.status IN ('open', 'in_progress') + AND a.status IN ('open', 'ready', 'in_progress') AND NOT EXISTS ( SELECT 1 FROM action_assignments aa 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 WHERE a.initiative_id = i.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 LIMIT %s diff --git a/backend/main.py b/backend/main.py index 8bc4db1..88b1113 100644 --- a/backend/main.py +++ b/backend/main.py @@ -60,11 +60,15 @@ from routers import ( # noqa: E402 backlog, blockers, config, + decisions, + evidence, features, initiatives, me, milestones, prompts, + recurring, + reviews, workspace, ) @@ -78,6 +82,10 @@ app.include_router(actions.router) app.include_router(blockers.router) app.include_router(backlog.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(workspace.router) diff --git a/backend/migrations/008_operating_model_extension_ii.sql b/backend/migrations/008_operating_model_extension_ii.sql new file mode 100644 index 0000000..91e904d --- /dev/null +++ b/backend/migrations/008_operating_model_extension_ii.sql @@ -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; diff --git a/backend/rights_registrations/operating_model_ops.py b/backend/rights_registrations/operating_model_ops.py index cffe738..0fbcc4d 100644 --- a/backend/rights_registrations/operating_model_ops.py +++ b/backend/rights_registrations/operating_model_ops.py @@ -81,3 +81,75 @@ register_capability( 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, + ) +) diff --git a/backend/routers/actions.py b/backend/routers/actions.py index a29d436..b741400 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from typing import Literal, Optional from capabilities import require_capability @@ -17,8 +18,12 @@ router = APIRouter(prefix="/api/actions", tags=["actions"]) class ActionUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=255) 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 + due_at: Optional[str] = None + clear_due_at: bool = False class ActionAssignmentsRequest(BaseModel): @@ -52,6 +57,12 @@ def update_action( body: ActionUpdateRequest, 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: item = action_service.update_action( tenant_id=ctx.tenant_id, @@ -61,6 +72,8 @@ def update_action( description=body.description, status=body.status, priority=body.priority, + due_at=due_at, + clear_due_at=body.clear_due_at, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/decisions.py b/backend/routers/decisions.py new file mode 100644 index 0000000..66f860b --- /dev/null +++ b/backend/routers/decisions.py @@ -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") diff --git a/backend/routers/evidence.py b/backend/routers/evidence.py new file mode 100644 index 0000000..78403e4 --- /dev/null +++ b/backend/routers/evidence.py @@ -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") diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 7c444b6..2377434 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from typing import Literal, Optional from capabilities import require_capability @@ -10,8 +11,12 @@ from pydantic import BaseModel, Field from services import actions as action_service from services import backlog as backlog_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 milestones as milestone_service +from services import recurring as recurring_service +from services import reviews as review_service from tenant_context import TenantContext router = APIRouter(prefix="/api/initiatives", tags=["initiatives"]) @@ -36,8 +41,11 @@ class InitiativeUpdateRequest(BaseModel): class ActionCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) 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" + due_at: Optional[str] = None assigned_actor_ids: list[str] = Field(default_factory=list) @@ -63,6 +71,47 @@ class MilestoneCreateRequest(BaseModel): 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("") def list_initiatives( ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), @@ -163,6 +212,7 @@ def create_initiative_action( ctx: TenantContext = Depends(require_capability("kairo.action.manage")), ): try: + due_at = _parse_optional_datetime(body.due_at, "due_at") return action_service.create_action( tenant_id=ctx.tenant_id, initiative_id=initiative_id, @@ -170,6 +220,7 @@ def create_initiative_action( description=body.description, status=body.status, priority=body.priority, + due_at=due_at, assigned_actor_ids=body.assigned_actor_ids, user_id=ctx.user_id, ) @@ -302,3 +353,164 @@ def create_initiative_milestone( 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}/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 diff --git a/backend/routers/recurring.py b/backend/routers/recurring.py new file mode 100644 index 0000000..5c40f06 --- /dev/null +++ b/backend/routers/recurring.py @@ -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") diff --git a/backend/routers/reviews.py b/backend/routers/reviews.py new file mode 100644 index 0000000..357c4ab --- /dev/null +++ b/backend/routers/reviews.py @@ -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") diff --git a/backend/services/actions.py b/backend/services/actions.py index ee95fa2..6bab3aa 100644 --- a/backend/services/actions.py +++ b/backend/services/actions.py @@ -10,10 +10,21 @@ from db import get_connection from services.audit import log_audit 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"}) -OPEN_ACTION_STATUSES = frozenset({"open", "in_progress", "blocked"}) +ACTION_STATUSES = frozenset( + {"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]: @@ -25,6 +36,8 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result["created_at"] = result["created_at"].isoformat() if result.get("updated_at"): result["updated_at"] = result["updated_at"].isoformat() + if result.get("due_at"): + result["due_at"] = result["due_at"].isoformat() return result @@ -94,6 +107,7 @@ def create_action( description: str = "", status: ActionStatus = "open", priority: str = "normal", + due_at: Optional[Any] = None, assigned_actor_ids: Optional[list[str]] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: @@ -114,15 +128,14 @@ def create_action( try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( - """ + f""" 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) - RETURNING id, tenant_id, initiative_id, title, description, - status, priority, created_at, updated_at + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING {_ACTION_COLUMNS} """, - (tenant_id, initiative_id, title, description, status, priority), + (tenant_id, initiative_id, title, description, status, priority, due_at), ) row = _serialize_row(dict(cur.fetchone())) for actor_id in assigned_actor_ids: @@ -162,9 +175,8 @@ def list_actions_for_initiative( try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( - """ - SELECT id, tenant_id, initiative_id, title, description, - status, priority, created_at, updated_at + f""" + SELECT {_ACTION_COLUMNS} FROM actions WHERE tenant_id = %s AND initiative_id = %s ORDER BY updated_at DESC, title @@ -182,9 +194,8 @@ def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]: try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( - """ - SELECT id, tenant_id, initiative_id, title, description, - status, priority, created_at, updated_at + f""" + SELECT {_ACTION_COLUMNS} FROM actions WHERE id = %s AND tenant_id = %s """, @@ -208,6 +219,8 @@ def update_action( description: Optional[str] = None, status: Optional[ActionStatus] = None, priority: Optional[str] = None, + due_at: Optional[Any] = None, + clear_due_at: bool = False, ) -> Optional[dict[str, Any]]: existing = get_action(tenant_id=tenant_id, action_id=action_id) if not existing: @@ -234,6 +247,11 @@ def update_action( _validate_priority(priority) updates.append("priority = %s") 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: return existing @@ -249,15 +267,13 @@ def update_action( UPDATE actions SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s - RETURNING id, tenant_id, initiative_id, title, description, - status, priority, created_at, updated_at + RETURNING {_ACTION_COLUMNS} """, params, ) row = cur.fetchone() if not row: return None - result = _serialize_row(dict(row)) conn.commit() finally: conn.close() @@ -374,7 +390,7 @@ def list_open_actions_for_actor( cur.execute( """ 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 FROM actions a JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id diff --git a/backend/services/decisions.py b/backend/services/decisions.py new file mode 100644 index 0000000..7ce1eaf --- /dev/null +++ b/backend/services/decisions.py @@ -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 diff --git a/backend/services/evidence.py b/backend/services/evidence.py new file mode 100644 index 0000000..f82d31a --- /dev/null +++ b/backend/services/evidence.py @@ -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 diff --git a/backend/services/recurring.py b/backend/services/recurring.py new file mode 100644 index 0000000..f429b98 --- /dev/null +++ b/backend/services/recurring.py @@ -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 diff --git a/backend/services/reviews.py b/backend/services/reviews.py new file mode 100644 index 0000000..e7f2a6d --- /dev/null +++ b/backend/services/reviews.py @@ -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 diff --git a/backend/tests/test_ap09_operating_model.py b/backend/tests/test_ap09_operating_model.py new file mode 100644 index 0000000..a83d22b --- /dev/null +++ b/backend/tests/test_ap09_operating_model.py @@ -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 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 484fa3e..d5b369d 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -38,6 +38,7 @@ def test_migration_runner_finds_migrations(): assert "005_prompt_feature_config_registry" in names assert "006_initiatives_actions" in names assert "007_operating_model_extension_i" in names + assert "008_operating_model_extension_ii" in names 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 "006_initiatives_actions" in executed assert "007_operating_model_extension_i" in executed + assert "008_operating_model_extension_ii" in executed def test_core_table_exists(): diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py index 3e55771..b5a8ca7 100644 --- a/backend/tests/test_rights_registry.py +++ b/backend/tests/test_rights_registry.py @@ -35,6 +35,14 @@ def test_registry_contains_initial_capabilities(): "kairo.backlog.manage", "kairo.milestone.read", "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: with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM capabilities") - assert cur.fetchone()[0] == 25 + assert cur.fetchone()[0] == 33 cur.execute("SELECT COUNT(*) FROM role_capability_grants") assert cur.fetchone()[0] >= 5 finally: diff --git a/backend/version.py b/backend/version.py index 77a465a..2d6adbe 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.8.0-ap0.8" -DB_SCHEMA_VERSION = "007" +APP_VERSION = "0.9.0-ap0.9" +DB_SCHEMA_VERSION = "008" APP_NAME = "jinkendo-kairo" diff --git a/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.2.md b/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.2.md new file mode 100644 index 0000000..80f6136 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.2.md @@ -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 1–6 | ✓ | +| 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 7–9) — schließt Canonical MVP §12 Lücke vor AP0.10 Validation. + +--- + +*v0.2 final — ersetzt Sprint0_AP0_8_Completion_Report_v0.1.md* diff --git a/docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md b/docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md new file mode 100644 index 0000000..b401bb9 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md @@ -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 7–9. + +--- + +## 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 7–9, Initiative-Detail-Sektionen, Tests, README, Abschlussbericht. + +--- + +*Freigegeben — Umsetzung 9a→9e.* diff --git a/docs/sprints/Sprint0_AP0_9_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_9_Completion_Report_v0.1.md new file mode 100644 index 0000000..63cfc15 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_9_Completion_Report_v0.1.md @@ -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* diff --git a/frontend/package.json b/frontend/package.json index 380ad51..8a8075d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.8.0-ap0.8", + "version": "0.9.0-ap0.9", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/decisions.js b/frontend/src/api/decisions.js new file mode 100644 index 0000000..02ca986 --- /dev/null +++ b/frontend/src/api/decisions.js @@ -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' }) +} diff --git a/frontend/src/api/evidence.js b/frontend/src/api/evidence.js new file mode 100644 index 0000000..9646ac9 --- /dev/null +++ b/frontend/src/api/evidence.js @@ -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' }) +} diff --git a/frontend/src/api/recurring.js b/frontend/src/api/recurring.js new file mode 100644 index 0000000..a814109 --- /dev/null +++ b/frontend/src/api/recurring.js @@ -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' }) +} diff --git a/frontend/src/api/reviews.js b/frontend/src/api/reviews.js new file mode 100644 index 0000000..8358403 --- /dev/null +++ b/frontend/src/api/reviews.js @@ -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' }) +} diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx index 2e79faf..0c7c8fb 100644 --- a/frontend/src/components/ActionForm.jsx +++ b/frontend/src/components/ActionForm.jsx @@ -2,6 +2,13 @@ import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js' import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js' 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({ initial = {}, actors = [], @@ -23,6 +30,7 @@ export function ActionForm({ description: form.description.value, status: form.status.value, priority: form.priority.value, + due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null, assigned_actor_ids: selected, }) } @@ -59,6 +67,14 @@ export function ActionForm({ ))} + +
+

Entscheidungen

+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + +
+ )} + + {items.length === 0 && } + +
    + {items.map((item) => ( +
  • +
    + {item.title} + {item.outcome &&

    {item.outcome}

    } +
    +
    + + {canManage && ( + <> + + + + )} +
    +
  • + ))} +
+ + ) +} diff --git a/frontend/src/components/EvidenceSection.jsx b/frontend/src/components/EvidenceSection.jsx new file mode 100644 index 0000000..4e749c4 --- /dev/null +++ b/frontend/src/components/EvidenceSection.jsx @@ -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 ( +
+
+

Evidence

+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + +
+ )} + + {items.length === 0 && } + +
    + {items.map((item) => ( +
  • +
    + {item.title} + {item.description &&

    {item.description}

    } +
    +
    + + {canManage && ( + <> + + + + )} +
    +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/components/RecurringSection.jsx b/frontend/src/components/RecurringSection.jsx new file mode 100644 index 0000000..415ba81 --- /dev/null +++ b/frontend/src/components/RecurringSection.jsx @@ -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 ( +
+
+

Wiederkehrend

+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + + +
+ )} + + {items.length === 0 && } + +
    + {items.map((item) => ( +
  • +
    + {item.title} + {item.next_due_at && ( +

    Fällig: {formatDueAt(item.next_due_at)}

    + )} +
    +
    + + {canManage && ( + <> + + + + )} +
    +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/components/ReviewsSection.jsx b/frontend/src/components/ReviewsSection.jsx new file mode 100644 index 0000000..8ca078c --- /dev/null +++ b/frontend/src/components/ReviewsSection.jsx @@ -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 ( +
+
+

Reviews

+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + + +
+ )} + + {items.length === 0 && } + +
    + {items.map((item) => ( +
  • +
    + {item.title} + {item.due_at && ( +

    Fällig: {formatDueAt(item.due_at)}

    + )} +
    +
    + + {canManage && ( + <> + + + + )} +
    +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx index 37b78aa..1e2739f 100644 --- a/frontend/src/components/StatusBadge.jsx +++ b/frontend/src/components/StatusBadge.jsx @@ -22,6 +22,17 @@ const VARIANTS = { resolved: 'status-done', accepted_risk: 'status-paused', 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 }) { diff --git a/frontend/src/constants/status.js b/frontend/src/constants/status.js index bbff8aa..f9ac8c3 100644 --- a/frontend/src/constants/status.js +++ b/frontend/src/constants/status.js @@ -1,10 +1,22 @@ 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 BACKLOG_STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'converted'] 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 OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked'] +export const OPEN_ACTION_STATUSES = ['open', 'ready', 'in_progress', 'blocked', 'review_required'] export const INITIATIVE_STATUS_LABELS = { active: 'Aktiv', @@ -15,8 +27,10 @@ export const INITIATIVE_STATUS_LABELS = { export const ACTION_STATUS_LABELS = { open: 'Offen', + ready: 'Bereit', in_progress: 'In Arbeit', blocked: 'Blockiert', + review_required: 'Review nötig', done: 'Erledigt', discarded: 'Verworfen', } @@ -46,6 +60,30 @@ export const MILESTONE_STATUS_LABELS = { 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 = { low: 'Niedrig', normal: 'Normal', @@ -58,4 +96,8 @@ export const STATUS_LABELS_BY_KIND = { blocker: BLOCKER_STATUS_LABELS, backlog: BACKLOG_STATUS_LABELS, milestone: MILESTONE_STATUS_LABELS, + evidence: EVIDENCE_STATUS_LABELS, + decision: DECISION_STATUS_LABELS, + review: REVIEW_STATUS_LABELS, + recurring: RECURRING_STATUS_LABELS, } diff --git a/frontend/src/pages/InitiativeDetailPage.jsx b/frontend/src/pages/InitiativeDetailPage.jsx index acab978..3075dbc 100644 --- a/frontend/src/pages/InitiativeDetailPage.jsx +++ b/frontend/src/pages/InitiativeDetailPage.jsx @@ -25,13 +25,41 @@ import { updateMilestone, deleteMilestone, } 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 { PriorityBadge } from '../components/PriorityBadge.jsx' import { ActionForm } from '../components/ActionForm.jsx' import { BlockersSection } from '../components/BlockersSection.jsx' import { BacklogSection } from '../components/BacklogSection.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 { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' @@ -56,6 +84,10 @@ export function InitiativeDetailPage() { const [blockers, setBlockers] = useState([]) const [backlogItems, setBacklogItems] = 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 [error, setError] = useState(null) const [showActionForm, setShowActionForm] = useState(false) @@ -83,6 +115,10 @@ export function InitiativeDetailPage() { loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([]))) loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([]))) 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) } catch (err) { @@ -127,6 +163,8 @@ export function InitiativeDetailPage() { description: payload.description, status: payload.status, priority: payload.priority, + due_at: payload.due_at, + clear_due_at: !payload.due_at, }) if (capabilities.has('kairo.action.manage')) { 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) { return (
@@ -367,6 +525,11 @@ export function InitiativeDetailPage() { Zugewiesen: {action.assigned_actor_ids.length} Actor(s)

)} + {action.due_at && ( +

+ Fällig: {new Date(action.due_at).toLocaleString('de-DE')} +

+ )}
@@ -379,7 +542,7 @@ export function InitiativeDetailPage() { onChange={(e) => handleQuickStatus(action, e.target.value)} aria-label="Status ändern" > - {['open', 'in_progress', 'blocked', 'done', 'discarded'].map((s) => ( + {ACTION_STATUSES.map((s) => ( @@ -438,6 +601,50 @@ export function InitiativeDetailPage() { busy={formBusy} /> )} + + {capabilities.has('kairo.initiative.read') && ( + + )} + + {capabilities.has('kairo.initiative.read') && ( + + )} + + {capabilities.has('kairo.initiative.read') && ( + + )} + + {capabilities.has('kairo.initiative.read') && ( + + )}
) }