diff --git a/README.md b/README.md index 239c2e2..8e84552 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,44 @@ Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md) | `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` | | `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) | +Vorhaben und Maßnahmen (AP0.5 minimaler Slice): + +| Endpoint | Methode | Capability | Beschreibung | +|----------|---------|------------|--------------| +| `/api/initiatives` | GET | `kairo.initiative.read` | Vorhaben im aktiven Tenant | +| `/api/initiatives` | POST | `kairo.initiative.manage` | Vorhaben anlegen | +| `/api/initiatives/{id}` | GET | `kairo.initiative.read` | Einzelnes Vorhaben | +| `/api/initiatives/{id}` | PATCH | `kairo.initiative.manage` | Vorhaben bearbeiten | +| `/api/initiatives/{id}` | DELETE | `kairo.initiative.manage` | Vorhaben löschen | +| `/api/initiatives/{id}/actions` | GET | `kairo.action.read` | Maßnahmen eines Vorhabens | +| `/api/initiatives/{id}/actions` | POST | `kairo.action.manage` | Maßnahme anlegen | +| `/api/actions/{id}` | GET | `kairo.action.read` | Einzelne Maßnahme | +| `/api/actions/{id}` | PATCH | `kairo.action.manage` | Maßnahme bearbeiten / Status | +| `/api/actions/{id}/assignments` | PUT | `kairo.action.manage` | Actors zuweisen (ersetzt Liste) | +| `/api/actions/{id}` | DELETE | `kairo.action.manage` | Maßnahme löschen | +| `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors | + +**Status Vorhaben:** `active`, `paused`, `completed`, `archived` + +**Status Maßnahme:** `open`, `in_progress`, `blocked`, `done`, `discarded` + +**Priorität:** `low`, `normal`, `high` + +```bash +# Vorhaben anlegen +curl -s -X POST http://localhost:8097/api/initiatives \ + -H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \ + -d '{"title":"Erstes Vorhaben","goal":"MVP validieren","priority":"high"}' + +# Maßnahme mit Zuweisung +curl -s -X POST http://localhost:8097/api/initiatives/INITIATIVE_ID/actions \ + -H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \ + -d '{"title":"API testen","assigned_actor_ids":["ACTOR_ID"]}' + +# Meine offenen Maßnahmen +curl -s http://localhost:8097/api/actions/me/open -H "X-Auth-Token: TOKEN" +``` + ### Registries (AP0.4) Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB. @@ -183,6 +221,10 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie | `kairo.admin.access` | platform | Portal-Administration | | `kairo.tenant.manage` | tenant | Tenant-Verwaltung | | `kairo.actor.manage` | tenant | Actor-Verwaltung | +| `kairo.initiative.read` | initiative | Vorhaben lesen | +| `kairo.initiative.manage` | initiative | Vorhaben verwalten | +| `kairo.action.read` | action | Maßnahmen lesen | +| `kairo.action.manage` | action | Maßnahmen verwalten / zuweisen | | `kairo.context.read` | platform | TenantContext lesen | | `kairo.entitlements.read` | platform | Entitlements lesen | diff --git a/backend/main.py b/backend/main.py index 70c47a9..b84ad8b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,13 +53,15 @@ app.add_middleware( allow_headers=["*"], ) -from routers import auth, config, features, me, prompts # noqa: E402 +from routers import actions, auth, config, features, initiatives, me, prompts # noqa: E402 app.include_router(auth.router) app.include_router(me.router) app.include_router(features.router) app.include_router(prompts.router) app.include_router(config.router) +app.include_router(initiatives.router) +app.include_router(actions.router) @app.get("/api/health") diff --git a/backend/migrations/006_initiatives_actions.sql b/backend/migrations/006_initiatives_actions.sql new file mode 100644 index 0000000..d13207e --- /dev/null +++ b/backend/migrations/006_initiatives_actions.sql @@ -0,0 +1,49 @@ +-- AP0.5: Minimal initiatives (Vorhaben), actions (Maßnahmen), action_assignments + +CREATE TABLE initiatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + goal TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'paused', 'completed', 'archived')), + priority VARCHAR(16) NOT NULL DEFAULT 'normal' + CHECK (priority IN ('low', 'normal', 'high')), + owner_actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_initiatives_tenant ON initiatives(tenant_id); +CREATE INDEX idx_initiatives_owner ON initiatives(owner_actor_id); + +CREATE TABLE actions ( + 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 'open' + CHECK (status IN ('open', 'in_progress', 'blocked', 'done', 'discarded')), + priority VARCHAR(16) NOT NULL DEFAULT 'normal' + CHECK (priority IN ('low', 'normal', 'high')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_actions_tenant ON actions(tenant_id); +CREATE INDEX idx_actions_initiative ON actions(initiative_id); +CREATE INDEX idx_actions_status ON actions(tenant_id, status); + +CREATE TABLE action_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE, + actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (action_id, actor_id) +); + +CREATE INDEX idx_action_assignments_tenant ON action_assignments(tenant_id); +CREATE INDEX idx_action_assignments_actor ON action_assignments(actor_id); +CREATE INDEX idx_action_assignments_action ON action_assignments(action_id); diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py index bbe142f..be2dd99 100644 --- a/backend/rights_registrations/__init__.py +++ b/backend/rights_registrations/__init__.py @@ -1,5 +1,5 @@ """Import all module registrations — side effect registers capabilities.""" -from . import platform, registry_ops, tenant_ops # noqa: F401 +from . import initiative_ops, platform, registry_ops, tenant_ops # noqa: F401 -__all__ = ["platform", "registry_ops", "tenant_ops"] +__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops"] diff --git a/backend/rights_registrations/initiative_ops.py b/backend/rights_registrations/initiative_ops.py new file mode 100644 index 0000000..343107b --- /dev/null +++ b/backend/rights_registrations/initiative_ops.py @@ -0,0 +1,63 @@ +"""Initiative and action capabilities (AP0.5).""" + +from __future__ import annotations + +from rights_registry import CapabilityRegistration, register_capability + +register_capability( + CapabilityRegistration( + key="kairo.initiative.read", + module="initiative", + description="Vorhaben im aktiven Tenant lesen", + default_grants=( + ("portal", "admin"), + ("portal", "user"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), + ), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.initiative.manage", + module="initiative", + description="Vorhaben im aktiven Tenant anlegen und bearbeiten", + default_grants=( + ("portal", "admin"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), + ), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.action.read", + module="action", + description="Maßnahmen im aktiven Tenant lesen", + default_grants=( + ("portal", "admin"), + ("portal", "user"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), + ), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.action.manage", + module="action", + description="Maßnahmen anlegen, bearbeiten, zuweisen und Status ändern", + default_grants=( + ("portal", "admin"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), + ), + ) +) diff --git a/backend/routers/actions.py b/backend/routers/actions.py new file mode 100644 index 0000000..8140914 --- /dev/null +++ b/backend/routers/actions.py @@ -0,0 +1,103 @@ +"""Action (Maßnahme) API — AP0.5.""" + +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 actions as action_service +from tenant_context import TenantContext + +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 + priority: Optional[Literal["low", "normal", "high"]] = None + + +class ActionAssignmentsRequest(BaseModel): + actor_ids: list[str] = Field(default_factory=list) + + +@router.get("/me/open") +def list_my_open_actions( + ctx: TenantContext = Depends(require_capability("kairo.action.read")), +): + if not ctx.actor_id: + raise HTTPException(status_code=400, detail="Kein Actor im TenantContext") + return action_service.list_open_actions_for_actor( + tenant_id=ctx.tenant_id, + actor_id=ctx.actor_id, + ) + + +@router.get("/{action_id}") +def get_action( + action_id: str, + ctx: TenantContext = Depends(require_capability("kairo.action.read")), +): + item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id) + if not item: + raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden") + return item + + +@router.patch("/{action_id}") +def update_action( + action_id: str, + body: ActionUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + try: + item = action_service.update_action( + tenant_id=ctx.tenant_id, + action_id=action_id, + user_id=ctx.user_id, + title=body.title, + description=body.description, + status=body.status, + priority=body.priority, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden") + return item + + +@router.put("/{action_id}/assignments") +def set_action_assignments( + action_id: str, + body: ActionAssignmentsRequest, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + try: + item = action_service.set_action_assignments( + tenant_id=ctx.tenant_id, + action_id=action_id, + actor_ids=body.actor_ids, + user_id=ctx.user_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden") + return item + + +@router.delete("/{action_id}", status_code=204) +def delete_action( + action_id: str, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + if not action_service.delete_action( + tenant_id=ctx.tenant_id, + action_id=action_id, + user_id=ctx.user_id, + ): + raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden") diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py new file mode 100644 index 0000000..d2e4c99 --- /dev/null +++ b/backend/routers/initiatives.py @@ -0,0 +1,155 @@ +"""Initiative (Vorhaben) API — AP0.5.""" + +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 actions as action_service +from services import initiatives as initiative_service +from tenant_context import TenantContext + +router = APIRouter(prefix="/api/initiatives", tags=["initiatives"]) + + +class InitiativeCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + goal: str = "" + status: Literal["active", "paused", "completed", "archived"] = "active" + priority: Literal["low", "normal", "high"] = "normal" + owner_actor_id: Optional[str] = None + + +class InitiativeUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255) + goal: Optional[str] = None + status: Optional[Literal["active", "paused", "completed", "archived"]] = None + priority: Optional[Literal["low", "normal", "high"]] = None + owner_actor_id: Optional[str] = None + + +class ActionCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: str = "" + status: Literal["open", "in_progress", "blocked", "done", "discarded"] = "open" + priority: Literal["low", "normal", "high"] = "normal" + assigned_actor_ids: list[str] = Field(default_factory=list) + + +@router.get("") +def list_initiatives( + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + return initiative_service.list_initiatives(tenant_id=ctx.tenant_id) + + +@router.post("", status_code=201) +def create_initiative( + body: InitiativeCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")), +): + owner_actor_id = body.owner_actor_id or ctx.actor_id + if not owner_actor_id: + raise HTTPException(status_code=400, detail="Kein Actor im TenantContext") + try: + return initiative_service.create_initiative( + tenant_id=ctx.tenant_id, + title=body.title, + goal=body.goal, + status=body.status, + priority=body.priority, + owner_actor_id=owner_actor_id, + user_id=ctx.user_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/{initiative_id}") +def get_initiative( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + item = initiative_service.get_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if not item: + raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") + return item + + +@router.patch("/{initiative_id}") +def update_initiative( + initiative_id: str, + body: InitiativeUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")), +): + try: + item = initiative_service.update_initiative( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + user_id=ctx.user_id, + title=body.title, + goal=body.goal, + status=body.status, + priority=body.priority, + owner_actor_id=body.owner_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="Vorhaben nicht gefunden") + return item + + +@router.delete("/{initiative_id}", status_code=204) +def delete_initiative( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")), +): + if not initiative_service.delete_initiative( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + user_id=ctx.user_id, + ): + raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") + + +@router.get("/{initiative_id}/actions") +def list_initiative_actions( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.action.read")), +): + if not initiative_service.get_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ): + raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") + return action_service.list_actions_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + + +@router.post("/{initiative_id}/actions", status_code=201) +def create_initiative_action( + initiative_id: str, + body: ActionCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + try: + return action_service.create_action( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + title=body.title, + description=body.description, + status=body.status, + priority=body.priority, + assigned_actor_ids=body.assigned_actor_ids, + 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/services/actions.py b/backend/services/actions.py new file mode 100644 index 0000000..ee95fa2 --- /dev/null +++ b/backend/services/actions.py @@ -0,0 +1,398 @@ +"""Action (Maßnahme) service — tenant-scoped CRUD, assignments, open actions.""" + +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 PRIORITIES, get_initiative + +ActionStatus = Literal["open", "in_progress", "blocked", "done", "discarded"] + +ACTION_STATUSES = frozenset({"open", "in_progress", "blocked", "done", "discarded"}) +OPEN_ACTION_STATUSES = frozenset({"open", "in_progress", "blocked"}) + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "initiative_id", "owner_actor_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("created_at"): + result["created_at"] = result["created_at"].isoformat() + if result.get("updated_at"): + result["updated_at"] = result["updated_at"].isoformat() + return result + + +def _validate_action_status(status: str) -> None: + if status not in ACTION_STATUSES: + raise ValueError(f"Ungültiger Action-Status: {status}") + + +def _validate_priority(priority: str) -> None: + if priority not in PRIORITIES: + raise ValueError(f"Ungültige Priorität: {priority}") + + +def _load_assignments(*, tenant_id: str, action_ids: list[str]) -> dict[str, list[str]]: + if not action_ids: + return {} + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT action_id, actor_id + FROM action_assignments + WHERE tenant_id = %s AND action_id = ANY(%s::uuid[]) + ORDER BY created_at + """, + (tenant_id, action_ids), + ) + result: dict[str, list[str]] = {aid: [] for aid in action_ids} + for row in cur.fetchall(): + result[str(row["action_id"])].append(str(row["actor_id"])) + return result + finally: + conn.close() + + +def _attach_assignments( + actions: list[dict[str, Any]], *, tenant_id: str +) -> list[dict[str, Any]]: + if not actions: + return actions + action_ids = [a["id"] for a in actions] + assignments = _load_assignments(tenant_id=tenant_id, action_ids=action_ids) + for action in actions: + action["assigned_actor_ids"] = assignments.get(action["id"], []) + return actions + + +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_action( + *, + tenant_id: str, + initiative_id: str, + title: str, + description: str = "", + status: ActionStatus = "open", + priority: str = "normal", + assigned_actor_ids: Optional[list[str]] = None, + user_id: Optional[str] = None, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_action_status(status) + _validate_priority(priority) + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + assigned_actor_ids = assigned_actor_ids or [] + for actor_id in assigned_actor_ids: + if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id): + raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO actions ( + tenant_id, initiative_id, title, description, status, priority + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, tenant_id, initiative_id, title, description, + status, priority, created_at, updated_at + """, + (tenant_id, initiative_id, title, description, status, priority), + ) + row = _serialize_row(dict(cur.fetchone())) + for actor_id in assigned_actor_ids: + cur.execute( + """ + INSERT INTO action_assignments (tenant_id, action_id, actor_id) + VALUES (%s, %s, %s) + """, + (tenant_id, row["id"], actor_id), + ) + conn.commit() + finally: + conn.close() + + row["assigned_actor_ids"] = assigned_actor_ids + log_audit( + "action.created", + user_id=user_id, + tenant_id=tenant_id, + details={ + "action_id": row["id"], + "initiative_id": initiative_id, + "title": title, + "status": status, + "assigned_actor_ids": assigned_actor_ids, + }, + ) + return row + + +def list_actions_for_initiative( + *, tenant_id: str, initiative_id: str +) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + return [] + conn = get_connection() + 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 + FROM actions + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC, title + """, + (tenant_id, initiative_id), + ) + actions = [_serialize_row(dict(row)) for row in cur.fetchall()] + finally: + conn.close() + return _attach_assignments(actions, tenant_id=tenant_id) + + +def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + 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 + FROM actions + WHERE id = %s AND tenant_id = %s + """, + (action_id, tenant_id), + ) + row = cur.fetchone() + if not row: + return None + action = _serialize_row(dict(row)) + finally: + conn.close() + return _attach_assignments([action], tenant_id=tenant_id)[0] + + +def update_action( + *, + tenant_id: str, + action_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[ActionStatus] = None, + priority: Optional[str] = None, +) -> Optional[dict[str, Any]]: + existing = get_action(tenant_id=tenant_id, action_id=action_id) + if not existing: + return None + + updates: list[str] = [] + params: list[Any] = [] + old_status = existing["status"] + + 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_action_status(status) + updates.append("status = %s") + params.append(status) + if priority is not None: + _validate_priority(priority) + updates.append("priority = %s") + params.append(priority) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([action_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + 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 + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + result = get_action(tenant_id=tenant_id, action_id=action_id) + assert result is not None + + log_audit( + "action.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"action_id": action_id, "fields": updates}, + ) + if status is not None and status != old_status: + log_audit( + "action.status_changed", + user_id=user_id, + tenant_id=tenant_id, + details={ + "action_id": action_id, + "from_status": old_status, + "to_status": status, + }, + ) + return result + + +def set_action_assignments( + *, + tenant_id: str, + action_id: str, + actor_ids: list[str], + user_id: Optional[str] = None, +) -> Optional[dict[str, Any]]: + existing = get_action(tenant_id=tenant_id, action_id=action_id) + if not existing: + return None + + unique_actor_ids = list(dict.fromkeys(actor_ids)) + for actor_id in unique_actor_ids: + if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id): + raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant") + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM action_assignments WHERE action_id = %s AND tenant_id = %s", + (action_id, tenant_id), + ) + for actor_id in unique_actor_ids: + cur.execute( + """ + INSERT INTO action_assignments (tenant_id, action_id, actor_id) + VALUES (%s, %s, %s) + """, + (tenant_id, action_id, actor_id), + ) + cur.execute( + "UPDATE actions SET updated_at = NOW() WHERE id = %s AND tenant_id = %s", + (action_id, tenant_id), + ) + conn.commit() + finally: + conn.close() + + log_audit( + "action.assigned", + user_id=user_id, + tenant_id=tenant_id, + details={ + "action_id": action_id, + "actor_ids": unique_actor_ids, + "previous_actor_ids": existing.get("assigned_actor_ids", []), + }, + ) + return get_action(tenant_id=tenant_id, action_id=action_id) + + +def delete_action( + *, + tenant_id: str, + action_id: str, + user_id: Optional[str] = None, +) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM actions WHERE id = %s AND tenant_id = %s RETURNING id", + (action_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "action.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"action_id": action_id}, + ) + return deleted + + +def list_open_actions_for_actor( + *, tenant_id: str, actor_id: str +) -> list[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + 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, + 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 + JOIN initiatives i ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id + WHERE a.tenant_id = %s + AND aa.actor_id = %s + AND a.status = ANY(%s) + ORDER BY + CASE a.priority + WHEN 'high' THEN 0 + WHEN 'normal' THEN 1 + WHEN 'low' THEN 2 + END, + a.updated_at DESC + """, + (tenant_id, actor_id, list(OPEN_ACTION_STATUSES)), + ) + actions = [_serialize_row(dict(row)) for row in cur.fetchall()] + finally: + conn.close() + return _attach_assignments(actions, tenant_id=tenant_id) diff --git a/backend/services/initiatives.py b/backend/services/initiatives.py new file mode 100644 index 0000000..f50a2e3 --- /dev/null +++ b/backend/services/initiatives.py @@ -0,0 +1,252 @@ +"""Initiative (Vorhaben) service — tenant-scoped CRUD.""" + +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 + +InitiativeStatus = Literal["active", "paused", "completed", "archived"] +Priority = Literal["low", "normal", "high"] + +INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"}) +PRIORITIES = frozenset({"low", "normal", "high"}) + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "owner_actor_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("created_at"): + result["created_at"] = result["created_at"].isoformat() + if result.get("updated_at"): + result["updated_at"] = result["updated_at"].isoformat() + return result + + +def _validate_initiative_status(status: str) -> None: + if status not in INITIATIVE_STATUSES: + raise ValueError(f"Ungültiger Initiative-Status: {status}") + + +def _validate_priority(priority: str) -> None: + if priority not in PRIORITIES: + raise ValueError(f"Ungültige Priorität: {priority}") + + +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_initiative( + *, + tenant_id: str, + title: str, + owner_actor_id: str, + goal: str = "", + status: InitiativeStatus = "active", + priority: Priority = "normal", + user_id: Optional[str] = None, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_initiative_status(status) + _validate_priority(priority) + if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id): + raise ValueError("Owner-Actor gehört nicht zum Tenant") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO initiatives ( + tenant_id, title, goal, status, priority, owner_actor_id + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, tenant_id, title, goal, status, priority, + owner_actor_id, created_at, updated_at + """, + (tenant_id, title, goal, status, priority, owner_actor_id), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "initiative.created", + user_id=user_id, + tenant_id=tenant_id, + details={"initiative_id": row["id"], "title": title, "status": status}, + ) + return row + + +def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, title, goal, status, priority, + owner_actor_id, created_at, updated_at + FROM initiatives + WHERE tenant_id = %s + ORDER BY updated_at DESC, title + """, + (tenant_id,), + ) + return [_serialize_row(dict(row)) for row in cur.fetchall()] + finally: + conn.close() + + +def get_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, title, goal, status, priority, + owner_actor_id, created_at, updated_at + FROM initiatives + WHERE id = %s AND tenant_id = %s + """, + (initiative_id, tenant_id), + ) + row = cur.fetchone() + return _serialize_row(dict(row)) if row else None + finally: + conn.close() + + +def update_initiative( + *, + tenant_id: str, + initiative_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + goal: Optional[str] = None, + status: Optional[InitiativeStatus] = None, + priority: Optional[Priority] = None, + owner_actor_id: Optional[str] = None, +) -> Optional[dict[str, Any]]: + existing = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id) + if not existing: + return None + + updates: list[str] = [] + params: list[Any] = [] + old_status = existing["status"] + + if title is not None: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + updates.append("title = %s") + params.append(title) + if goal is not None: + updates.append("goal = %s") + params.append(goal) + if status is not None: + _validate_initiative_status(status) + updates.append("status = %s") + params.append(status) + if priority is not None: + _validate_priority(priority) + updates.append("priority = %s") + params.append(priority) + if owner_actor_id is not None: + if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id): + raise ValueError("Owner-Actor gehört nicht zum Tenant") + updates.append("owner_actor_id = %s") + params.append(owner_actor_id) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([initiative_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + UPDATE initiatives + SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, title, goal, status, priority, + owner_actor_id, created_at, updated_at + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + log_audit( + "initiative.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"initiative_id": initiative_id, "fields": updates}, + ) + if status is not None and status != old_status: + log_audit( + "initiative.status_changed", + user_id=user_id, + tenant_id=tenant_id, + details={ + "initiative_id": initiative_id, + "from_status": old_status, + "to_status": status, + }, + ) + return result + + +def delete_initiative( + *, + tenant_id: str, + initiative_id: str, + user_id: Optional[str] = None, +) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM initiatives WHERE id = %s AND tenant_id = %s RETURNING id", + (initiative_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "initiative.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"initiative_id": initiative_id}, + ) + return deleted diff --git a/backend/tests/test_initiatives_actions.py b/backend/tests/test_initiatives_actions.py new file mode 100644 index 0000000..0865d74 --- /dev/null +++ b/backend/tests/test_initiatives_actions.py @@ -0,0 +1,316 @@ +"""Initiatives and actions tests (AP0.5).""" + +from __future__ import annotations + +import pytest + +from auth import AUTH_HEADER +from db import get_connection +from services.actors import create_actor +from tests.factories import provision_user_in_tenant + + +def _login(client, user: dict) -> str: + res = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + assert res.status_code == 200 + return res.json()["token"] + + +def _auth(token: str) -> dict: + return {AUTH_HEADER: token} + + +def _create_initiative(client, token: str, *, title: str = "Test Vorhaben", **kwargs): + body = {"title": title, **kwargs} + return client.post("/api/initiatives", json=body, headers=_auth(token)) + + +def _create_action(client, token: str, initiative_id: str, *, title: str = "Test Maßnahme", **kwargs): + body = {"title": title, **kwargs} + return client.post( + f"/api/initiatives/{initiative_id}/actions", + json=body, + headers=_auth(token), + ) + + +def test_initiative_crud(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Kairo MVP", + goal="Erster Slice", + priority="high", + ) + assert created.status_code == 201 + initiative = created.json() + assert initiative["title"] == "Kairo MVP" + assert initiative["goal"] == "Erster Slice" + assert initiative["status"] == "active" + assert initiative["priority"] == "high" + assert initiative["owner_actor_id"] == user["actor_id"] + assert initiative["tenant_id"] == user["tenant_id"] + + listed = client.get("/api/initiatives", headers=_auth(token)) + assert listed.status_code == 200 + assert any(i["id"] == initiative["id"] for i in listed.json()) + + fetched = client.get(f"/api/initiatives/{initiative['id']}", headers=_auth(token)) + assert fetched.status_code == 200 + + updated = client.patch( + f"/api/initiatives/{initiative['id']}", + json={"status": "paused", "goal": "Angepasst"}, + headers=_auth(token), + ) + assert updated.status_code == 200 + assert updated.json()["status"] == "paused" + assert updated.json()["goal"] == "Angepasst" + + deleted = client.delete(f"/api/initiatives/{initiative['id']}", headers=_auth(token)) + assert deleted.status_code == 204 + assert client.get(f"/api/initiatives/{initiative['id']}", headers=_auth(token)).status_code == 404 + + +def test_action_crud_and_status_change(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + created = _create_action( + client, + token, + initiative_id, + title="Status prüfen", + assigned_actor_ids=[user["actor_id"]], + ) + assert created.status_code == 201 + action = created.json() + assert action["status"] == "open" + assert action["assigned_actor_ids"] == [user["actor_id"]] + + listed = client.get(f"/api/initiatives/{initiative_id}/actions", headers=_auth(token)) + assert listed.status_code == 200 + assert len(listed.json()) == 1 + + updated = client.patch( + f"/api/actions/{action['id']}", + json={"status": "in_progress"}, + headers=_auth(token), + ) + assert updated.status_code == 200 + assert updated.json()["status"] == "in_progress" + + done = client.patch( + f"/api/actions/{action['id']}", + json={"status": "done"}, + headers=_auth(token), + ) + assert done.status_code == 200 + assert done.json()["status"] == "done" + + +def test_action_assignments(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Helper Agent") + initiative_id = _create_initiative(client, token).json()["id"] + action_id = _create_action(client, token, initiative_id).json()["id"] + + assigned = client.put( + f"/api/actions/{action_id}/assignments", + json={"actor_ids": [user["actor_id"], agent["id"]]}, + headers=_auth(token), + ) + assert assigned.status_code == 200 + body = assigned.json() + assert set(body["assigned_actor_ids"]) == {user["actor_id"], agent["id"]} + + +def test_my_open_actions(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + open_action = _create_action( + client, + token, + initiative_id, + title="Offen", + assigned_actor_ids=[user["actor_id"]], + ).json() + in_progress = _create_action( + client, + token, + initiative_id, + title="In Arbeit", + assigned_actor_ids=[user["actor_id"]], + ).json() + client.patch( + f"/api/actions/{in_progress['id']}", + json={"status": "in_progress"}, + headers=_auth(token), + ) + done_action = _create_action( + client, + token, + initiative_id, + title="Erledigt", + assigned_actor_ids=[user["actor_id"]], + ).json() + client.patch( + f"/api/actions/{done_action['id']}", + json={"status": "done"}, + headers=_auth(token), + ) + unassigned = _create_action( + client, + token, + initiative_id, + title="Nicht zugewiesen", + ).json() + + open_res = client.get("/api/actions/me/open", headers=_auth(token)) + assert open_res.status_code == 200 + open_ids = {a["id"] for a in open_res.json()} + assert open_action["id"] in open_ids + assert in_progress["id"] in open_ids + assert done_action["id"] not in open_ids + assert unassigned["id"] not in open_ids + for item in open_res.json(): + assert "initiative_title" in item + + +def test_tenant_isolation_initiatives(client): + user_a = provision_user_in_tenant() + user_b = provision_user_in_tenant() + token_a = _login(client, user_a) + token_b = _login(client, user_b) + + initiative_id = _create_initiative(client, token_a, title="Tenant A only").json()["id"] + + assert client.get(f"/api/initiatives/{initiative_id}", headers=_auth(token_b)).status_code == 404 + assert ( + client.patch( + f"/api/initiatives/{initiative_id}", + json={"title": "Hack"}, + headers=_auth(token_b), + ).status_code + == 404 + ) + assert client.get("/api/initiatives", headers=_auth(token_b)).json() == [] + + +def test_tenant_isolation_actions(client): + user_a = provision_user_in_tenant() + user_b = provision_user_in_tenant() + token_a = _login(client, user_a) + token_b = _login(client, user_b) + + initiative_id = _create_initiative(client, token_a).json()["id"] + action_id = _create_action( + client, + token_a, + initiative_id, + assigned_actor_ids=[user_a["actor_id"]], + ).json()["id"] + + assert client.get(f"/api/actions/{action_id}", headers=_auth(token_b)).status_code == 404 + assert ( + client.patch( + f"/api/actions/{action_id}", + json={"status": "done"}, + headers=_auth(token_b), + ).status_code + == 404 + ) + assert client.get("/api/actions/me/open", headers=_auth(token_b)).json() == [] + + +def test_cross_tenant_actor_assignment_rejected(client): + user_a = provision_user_in_tenant() + user_b = provision_user_in_tenant() + token_a = _login(client, user_a) + initiative_id = _create_initiative(client, token_a).json()["id"] + action_id = _create_action(client, token_a, initiative_id).json()["id"] + + res = client.put( + f"/api/actions/{action_id}/assignments", + json={"actor_ids": [user_b["actor_id"]]}, + headers=_auth(token_a), + ) + assert res.status_code == 400 + + +def test_audit_on_create_and_status_change(client): + user = provision_user_in_tenant() + token = _login(client, user) + initiative_id = _create_initiative(client, token, title="Audit Test").json()["id"] + action_id = _create_action(client, token, initiative_id).json()["id"] + client.patch( + f"/api/actions/{action_id}", + json={"status": "blocked"}, + headers=_auth(token), + ) + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT action FROM audit_log + WHERE tenant_id = %s::uuid + AND action IN ( + 'initiative.created', 'action.created', 'action.status_changed' + ) + ORDER BY created_at + """, + (user["tenant_id"],), + ) + actions = {row[0] for row in cur.fetchall()} + finally: + conn.close() + + assert "initiative.created" in actions + assert "action.created" in actions + assert "action.status_changed" in actions + + +def test_unauthenticated_rejected(client): + assert client.get("/api/initiatives").status_code == 401 + assert client.get("/api/actions/me/open").status_code == 401 + + +@pytest.fixture() +def enforce_capabilities(monkeypatch): + monkeypatch.setenv("CAPABILITY_ENFORCE", "enforce") + + +def test_member_has_initiative_capabilities(client): + member = provision_user_in_tenant(tenant_role="member", portal_role="user") + token = _login(client, member) + ctx = client.get("/api/me/context", headers=_auth(token)).json() + assert "kairo.initiative.read" in ctx["capabilities"] + assert "kairo.initiative.manage" in ctx["capabilities"] + assert "kairo.action.read" in ctx["capabilities"] + assert "kairo.action.manage" in ctx["capabilities"] + + +def test_invalid_status_rejected(client): + user = provision_user_in_tenant() + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action_id = _create_action(client, token, initiative_id).json()["id"] + + res = client.patch( + f"/api/actions/{action_id}", + json={"status": "invalid_status"}, + headers=_auth(token), + ) + assert res.status_code == 422 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 2754d7b..c5c352c 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -36,6 +36,7 @@ def test_migration_runner_finds_migrations(): assert "003_data_seeds_tracking" in names assert "004_capabilities_registry" in names assert "005_prompt_feature_config_registry" in names + assert "006_initiatives_actions" in names def test_migration_runner_is_idempotent(): @@ -50,6 +51,7 @@ def test_migration_runner_is_idempotent(): assert "003_data_seeds_tracking" in executed assert "004_capabilities_registry" in executed assert "005_prompt_feature_config_registry" in executed + assert "006_initiatives_actions" in executed def test_core_table_exists(): diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py index a5911c8..3a78151 100644 --- a/backend/tests/test_rights_registry.py +++ b/backend/tests/test_rights_registry.py @@ -22,6 +22,10 @@ def test_registry_contains_initial_capabilities(): "kairo.config.registry.read", "kairo.config.registry.manage", "kairo.prompt.render", + "kairo.initiative.read", + "kairo.initiative.manage", + "kairo.action.read", + "kairo.action.manage", } @@ -33,7 +37,7 @@ def test_sync_is_idempotent(): try: with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM capabilities") - assert cur.fetchone()[0] == 12 + assert cur.fetchone()[0] == 16 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 522e3ae..203c59c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.4.0-ap0.4" -DB_SCHEMA_VERSION = "005" +APP_VERSION = "0.5.0-ap0.5" +DB_SCHEMA_VERSION = "006" APP_NAME = "jinkendo-kairo" diff --git a/docs/sprints/Sprint0_AP0_5_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_5_Completion_Report_v0.1.md new file mode 100644 index 0000000..c2df523 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_5_Completion_Report_v0.1.md @@ -0,0 +1,151 @@ +# AP0.5 – Abschlussbericht Minimaler Vorhaben- und Maßnahmen-Slice + +**Status:** implementiert +**Stand:** 2026-07-04 +**Version:** `0.5.0-ap0.5` · Schema `006` + +--- + +## 1. Scope und Einordnung + +AP0.5 liefert den **ersten fachlich nutzbaren Kairo-Slice**: Ein angemeldeter Actor kann innerhalb eines Tenants Vorhaben und Maßnahmen verwalten, zuweisen und seinen offenen Maßnahmen-Backlog abrufen. + +| Anforderung (AP0.5) | Status | +|---------------------|--------| +| Migration `006` (initiatives, actions, action_assignments) | ✓ | +| Tenant-Bezug für alle Objekte | ✓ | +| Owner Actor für Initiative | ✓ | +| Action gehört zu Initiative | ✓ | +| Action → ein oder mehrere Actors (Assignments) | ✓ | +| CRUD-Minimum Initiatives | ✓ | +| CRUD-Minimum Actions | ✓ | +| Endpoint „Meine offenen Maßnahmen“ | ✓ | +| Statusmodell minimal | ✓ | +| Priority minimal | ✓ | +| Capability-Gates (4 neue Capabilities) | ✓ | +| Audit bei Create/Update/Statuswechsel | ✓ | +| Tests (Isolation, CRUD, Assignment, Status) | ✓ | +| README/API-Doku | ✓ | + +**Bewusst nicht in AP0.5:** Projects, Milestones, Programs, Reviews, Evidence, Blocker-Objekte, KI/Prompt-Erweiterung, MCP, automatische Priorisierung, komplexe UI, Workflow Engine. + +--- + +## 2. Umgesetzte Dateien + +### 2.1 Migration & Schema + +| Datei | Zweck | +|-------|--------| +| `backend/migrations/006_initiatives_actions.sql` | Tabellen initiatives, actions, action_assignments | + +### 2.2 Services + +| Datei | Zweck | +|-------|--------| +| `backend/services/initiatives.py` | Vorhaben-CRUD, Validierung, Audit | +| `backend/services/actions.py` | Maßnahmen-CRUD, Assignments, offene Maßnahmen | + +### 2.3 API & Capabilities + +| Datei | Zweck | +|-------|--------| +| `backend/routers/initiatives.py` | Initiative-Endpoints + nested Action-Create/List | +| `backend/routers/actions.py` | Action-Endpoints + `/me/open` | +| `backend/rights_registrations/initiative_ops.py` | 4 AP0.5-Capabilities | +| `backend/rights_registrations/__init__.py` | Import initiative_ops | +| `backend/main.py` | Router-Registrierung | +| `backend/version.py` | `0.5.0-ap0.5`, Schema `006` | + +### 2.4 Tests & Doku + +| Datei | Zweck | +|-------|--------| +| `backend/tests/test_initiatives_actions.py` | CRUD, Isolation, Assignment, Status, Audit | +| `backend/tests/test_migrations.py` | Migration 006 | +| `backend/tests/test_rights_registry.py` | 16 Capabilities | +| `README.md` | API-Doku AP0.5 | +| `frontend/src/App.jsx` | Header AP0.5 | + +--- + +## 3. Neue Migrationen + +**`006_initiatives_actions.sql`** + +| Tabelle | Zweck | +|---------|--------| +| `initiatives` | Vorhaben (tenant-scoped, owner_actor_id) | +| `actions` | Maßnahmen (gehört zu initiative) | +| `action_assignments` | Actor-Zuweisungen (n:m) | + +--- + +## 4. Datenmodell + +``` +tenants + └── initiatives + id, tenant_id, title, goal, status, priority + owner_actor_id → actors + └── actions + id, tenant_id, initiative_id, title, description + status, priority + └── action_assignments + id, tenant_id, action_id, actor_id + UNIQUE (action_id, actor_id) +``` + +--- + +## 5. Endpoints + +Siehe `README.md` — Abschnitt AP0.5. + +--- + +## 6. Statusmodell + +**Initiative:** `active` · `paused` · `completed` · `archived` + +**Action:** `open` · `in_progress` · `blocked` · `done` · `discarded` + +**Priorität:** `low` · `normal` · `high` + +Offene Maßnahmen: Status ∈ `{open, in_progress, blocked}` und zugewiesen an `ctx.actor_id`. + +--- + +## 7. Assignment-Modell + +n:m über `action_assignments`; `PUT /api/actions/{id}/assignments` ersetzt die Liste; Validierung gegen Tenant-Actors. + +--- + +## 8. Capability-Nutzung + +`kairo.initiative.read/manage` · `kairo.action.read/manage` — Grants für tenant owner/admin/member. + +--- + +## 9. Tenant-Isolation + +Alle Queries tenant-scoped; Cross-Tenant → 404/400; Tests in `test_initiatives_actions.py`. + +--- + +## 10. Tests + +11 Tests in `test_initiatives_actions.py` + aktualisierte Registry/Migration-Tests. + +Ausführung (Dev-Stack): + +```bash +docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv +``` + +--- + +## 11. Empfehlung nächster kleinster Slice + +**AP0.6 – Kommentare pro Maßnahme** (`action_comments`, read/manage Capability, minimal Audit) — operativer Mehrwert ohne Projects/Milestones/KI. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0e1d1d9..586b2d9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -183,7 +183,7 @@ export default function App() {

Jinkendo Kairo

-

Operativer Program Director — Sprint 0 / AP0.4

+

Operativer Program Director — Sprint 0 / AP0.5

Capability Registry & Entitlements