AP0.7: Tenant Hardening, Actor Directory und Data Layer Minimum
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 07:25:01 +02:00
parent 36487d2869
commit db06af0489
32 changed files with 1424 additions and 43 deletions

View File

@ -147,7 +147,21 @@ Vorhaben und Maßnahmen (AP0.5 minimaler Slice):
| `/api/actions/{id}` | PATCH | `kairo.action.manage` | Maßnahme bearbeiten / Status | | `/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}/assignments` | PUT | `kairo.action.manage` | Actors zuweisen (ersetzt Liste) |
| `/api/actions/{id}` | DELETE | `kairo.action.manage` | Maßnahme löschen | | `/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 | | `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors (via Data Layer) |
Actor Directory und Workspace Data Layer (AP0.7):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/actors` | GET | `kairo.actor.read` | Actors des aktiven Tenants (Filter: `actor_type`, `q`, `include_inactive`) |
| `/api/actors/{id}` | GET | `kairo.actor.read` | Einzelner Actor (404 cross-tenant) |
| `/api/workspace/summary` | GET | `kairo.workspace.read` | Workspace-Kennzahlen |
| `/api/workspace/actions/open` | GET | `kairo.workspace.read` | Meine offenen Maßnahmen (open/in_progress) |
| `/api/workspace/actions/blocked` | GET | `kairo.workspace.read` | Meine blockierten Maßnahmen |
| `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
**Status Vorhaben:** `active`, `paused`, `completed`, `archived` **Status Vorhaben:** `active`, `paused`, `completed`, `archived`
@ -176,7 +190,7 @@ Nach Login leitet Kairo auf den **Workspace** weiter.
| Route | Beschreibung | | Route | Beschreibung |
|-------|--------------| |-------|--------------|
| `/workspace` | Karten: Kontext, offene Maßnahmen, blockierte Maßnahmen, aktive Vorhaben | | `/workspace` | Karten: Kontext, Überblick, offene/blockierte Maßnahmen, aktive Vorhaben |
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler | | `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung | | `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors | | `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
@ -203,10 +217,9 @@ cd frontend && npm install && npm run test && npm run build
4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“ 4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px 5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar 6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
7. AP0.7: ActorSelect lädt `/api/actors`; Maßnahme einem Agent zuweisen; Workspace-Überblick sichtbar
**Bekannte Lücke:** Kein `GET /api/actors` — Zuweisung nutzt aktuell den Human Actor aus TenantContext (AP0.7). Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md`
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`
### Registries (AP0.4) ### Registries (AP0.4)
@ -260,7 +273,9 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie
|------------|-------|------------------| |------------|-------|------------------|
| `kairo.admin.access` | platform | Portal-Administration | | `kairo.admin.access` | platform | Portal-Administration |
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung | | `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
| `kairo.actor.manage` | tenant | Actor-Verwaltung | | `kairo.actor.read` | tenant | Actors im Tenant lesen (Directory) |
| `kairo.actor.manage` | tenant | Actors verwalten |
| `kairo.workspace.read` | workspace | Workspace Read-Models / Data Layer |
| `kairo.initiative.read` | initiative | Vorhaben lesen | | `kairo.initiative.read` | initiative | Vorhaben lesen |
| `kairo.initiative.manage` | initiative | Vorhaben verwalten | | `kairo.initiative.manage` | initiative | Vorhaben verwalten |
| `kairo.action.read` | action | Maßnahmen lesen | | `kairo.action.read` | action | Maßnahmen lesen |

View File

@ -0,0 +1 @@
"""Kairo Data Layer — read-oriented aggregation for workspace and dashboards."""

View File

@ -0,0 +1,85 @@
"""Action read-models for workspace — tenant-scoped, actor-assignment based."""
from __future__ import annotations
from typing import Any
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.actions import OPEN_ACTION_STATUSES, _attach_assignments, _serialize_row
from tenant_context import TenantContext
def _require_actor(ctx: TenantContext) -> str:
if not ctx.actor_id:
raise ValueError("Kein Actor im TenantContext")
return ctx.actor_id
def _list_assigned_actions(
*,
tenant_id: str,
actor_id: str,
statuses: list[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, statuses),
)
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
return _attach_assignments(actions, tenant_id=tenant_id)
def get_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
"""Open + in_progress actions assigned to current actor (excludes blocked)."""
actor_id = _require_actor(ctx)
return _list_assigned_actions(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
statuses=["open", "in_progress"],
)
def get_my_blocked_actions(ctx: TenantContext) -> list[dict[str, Any]]:
"""Blocked actions assigned to current actor."""
actor_id = _require_actor(ctx)
return _list_assigned_actions(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
statuses=["blocked"],
)
def get_all_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
"""All non-terminal open actions (open, in_progress, blocked) — legacy compat."""
actor_id = _require_actor(ctx)
return _list_assigned_actions(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
statuses=list(OPEN_ACTION_STATUSES),
)

View File

@ -0,0 +1,61 @@
"""Actor workload read-models — tenant-scoped aggregation."""
from __future__ import annotations
from typing import Any
from psycopg2.extras import RealDictCursor
from db import get_connection
from tenant_context import TenantContext
def get_actor_workload(ctx: TenantContext) -> list[dict[str, Any]]:
"""Open/blocked/in_progress action counts per active actor in tenant."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
a.id AS actor_id,
a.name AS display_name,
a.actor_type,
COUNT(*) FILTER (WHERE act.status = 'open') AS open_actions,
COUNT(*) FILTER (WHERE act.status = 'blocked') AS blocked_actions,
COUNT(*) FILTER (WHERE act.status = 'in_progress') AS in_progress_actions
FROM actors a
LEFT JOIN action_assignments aa
ON aa.actor_id = a.id AND aa.tenant_id = a.tenant_id
LEFT JOIN actions act
ON act.id = aa.action_id
AND act.tenant_id = aa.tenant_id
AND act.status IN ('open', 'in_progress', 'blocked')
WHERE a.tenant_id = %s AND a.is_active = TRUE
GROUP BY a.id, a.name, a.actor_type
ORDER BY
CASE a.actor_type
WHEN 'human' THEN 0
WHEN 'working_group' THEN 1
WHEN 'agent' THEN 2
ELSE 3
END,
a.name
""",
(ctx.tenant_id,),
)
result = []
for row in cur.fetchall():
result.append(
{
"actor_id": str(row["actor_id"]),
"display_name": row["display_name"],
"actor_type": row["actor_type"],
"open_actions": int(row["open_actions"] or 0),
"blocked_actions": int(row["blocked_actions"] or 0),
"in_progress_actions": int(row["in_progress_actions"] or 0),
}
)
return result
finally:
conn.close()

View File

@ -0,0 +1,37 @@
"""Initiative read-models for workspace."""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.initiatives import _serialize_row
from tenant_context import TenantContext
def get_active_initiatives(
ctx: TenantContext,
*,
limit: Optional[int] = None,
) -> list[dict[str, Any]]:
"""Active and paused initiatives in the current tenant."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
sql = """
SELECT id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
FROM initiatives
WHERE tenant_id = %s AND status IN ('active', 'paused')
ORDER BY updated_at DESC, title
"""
params: list[Any] = [ctx.tenant_id]
if limit is not None:
sql += " LIMIT %s"
params.append(limit)
cur.execute(sql, params)
return [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()

View File

@ -0,0 +1,70 @@
"""Workspace summary read-model — tenant-scoped KPIs for current actor."""
from __future__ import annotations
from typing import Any
from psycopg2.extras import RealDictCursor
from db import get_connection
from tenant_context import TenantContext
def get_workspace_summary(ctx: TenantContext) -> dict[str, Any]:
"""
Summary for workspace dashboard.
Personal counts (current actor): open, blocked, recently done.
Tenant-wide: active initiatives count.
"""
if not ctx.actor_id:
return {
"open_actions_count": 0,
"blocked_actions_count": 0,
"active_initiatives_count": 0,
"done_actions_recent_count": 0,
}
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
COUNT(*) FILTER (
WHERE a.status IN ('open', 'in_progress')
) AS open_actions_count,
COUNT(*) FILTER (
WHERE a.status = 'blocked'
) AS blocked_actions_count,
COUNT(*) FILTER (
WHERE a.status = 'done'
AND a.updated_at >= NOW() - INTERVAL '7 days'
) AS done_actions_recent_count
FROM actions a
JOIN action_assignments aa
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
WHERE a.tenant_id = %s AND aa.actor_id = %s
""",
(ctx.tenant_id, ctx.actor_id),
)
row = dict(cur.fetchone())
cur.execute(
"""
SELECT COUNT(*) AS active_initiatives_count
FROM initiatives
WHERE tenant_id = %s AND status IN ('active', 'paused')
""",
(ctx.tenant_id,),
)
initiatives_row = dict(cur.fetchone())
finally:
conn.close()
return {
"open_actions_count": int(row["open_actions_count"] or 0),
"blocked_actions_count": int(row["blocked_actions_count"] or 0),
"active_initiatives_count": int(initiatives_row["active_initiatives_count"] or 0),
"done_actions_recent_count": int(row["done_actions_recent_count"] or 0),
}

View File

@ -53,7 +53,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
from routers import actions, auth, config, features, initiatives, me, prompts # noqa: E402 from routers import actions, actors, auth, config, features, initiatives, me, prompts, workspace # noqa: E402
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(me.router) app.include_router(me.router)
@ -62,6 +62,8 @@ app.include_router(prompts.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(initiatives.router) app.include_router(initiatives.router)
app.include_router(actions.router) app.include_router(actions.router)
app.include_router(actors.router)
app.include_router(workspace.router)
@app.get("/api/health") @app.get("/api/health")

View File

@ -1,5 +1,5 @@
"""Import all module registrations — side effect registers capabilities.""" """Import all module registrations — side effect registers capabilities."""
from . import initiative_ops, platform, registry_ops, tenant_ops # noqa: F401 from . import initiative_ops, platform, registry_ops, tenant_ops, workspace_ops # noqa: F401
__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops"] __all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops", "workspace_ops"]

View File

@ -0,0 +1,35 @@
"""Workspace read capabilities (AP0.7)."""
from __future__ import annotations
from rights_registry import CapabilityRegistration, register_capability
register_capability(
CapabilityRegistration(
key="kairo.actor.read",
module="tenant",
description="Actors im aktiven Tenant lesen (Directory)",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.workspace.read",
module="workspace",
description="Workspace-Übersichten und aggregierte Read-Models lesen",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)

View File

@ -1,10 +1,11 @@
"""Action (Maßnahme) API — AP0.5.""" """Action (Maßnahme) API — AP0.5 / AP0.7."""
from __future__ import annotations from __future__ import annotations
from typing import Literal, Optional from typing import Literal, Optional
from capabilities import require_capability from capabilities import require_capability
from data_layer import actions as dl_actions
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from services import actions as action_service from services import actions as action_service
@ -28,12 +29,10 @@ class ActionAssignmentsRequest(BaseModel):
def list_my_open_actions( def list_my_open_actions(
ctx: TenantContext = Depends(require_capability("kairo.action.read")), ctx: TenantContext = Depends(require_capability("kairo.action.read")),
): ):
if not ctx.actor_id: try:
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext") return dl_actions.get_all_my_open_actions(ctx)
return action_service.list_open_actions_for_actor( except ValueError as exc:
tenant_id=ctx.tenant_id, raise HTTPException(status_code=400, detail=str(exc)) from exc
actor_id=ctx.actor_id,
)
@router.get("/{action_id}") @router.get("/{action_id}")

41
backend/routers/actors.py Normal file
View File

@ -0,0 +1,41 @@
"""Actor directory API — AP0.7."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException, Query
from services import actors as actor_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/actors", tags=["actors"])
@router.get("")
def list_actors(
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
include_inactive: bool = Query(default=False),
q: Optional[str] = Query(default=None, max_length=100),
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
):
try:
return actor_service.list_actors(
tenant_id=ctx.tenant_id,
actor_type=actor_type,
include_inactive=include_inactive,
q=q,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{actor_id}")
def get_actor(
actor_id: str,
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
):
item = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
if not item:
raise HTTPException(status_code=404, detail="Actor nicht gefunden")
return item

View File

@ -0,0 +1,63 @@
"""Workspace read API — delegates to Data Layer (AP0.7)."""
from __future__ import annotations
from typing import Optional
from capabilities import require_capability
from data_layer import actions as dl_actions
from data_layer import actors as dl_actors
from data_layer import initiatives as dl_initiatives
from data_layer import workspace as dl_workspace
from fastapi import APIRouter, Depends, HTTPException, Query
from tenant_context import TenantContext
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
if not ctx.actor_id:
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
return ctx
@router.get("/summary")
def workspace_summary(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_workspace.get_workspace_summary(ctx)
@router.get("/actions/open")
def workspace_open_actions(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
try:
return dl_actions.get_my_open_actions(_require_actor_ctx(ctx))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/actions/blocked")
def workspace_blocked_actions(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
try:
return dl_actions.get_my_blocked_actions(_require_actor_ctx(ctx))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/initiatives/active")
def workspace_active_initiatives(
limit: Optional[int] = Query(default=None, ge=1, le=100),
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_initiatives.get_active_initiatives(ctx, limit=limit)
@router.get("/actors/workload")
def workspace_actor_workload(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_actors.get_actor_workload(ctx)

View File

@ -1,4 +1,4 @@
"""Actor creation helpers — User and Actor remain separate concepts.""" """Actor creation and directory helpers — User and Actor remain separate concepts."""
from __future__ import annotations from __future__ import annotations
@ -10,6 +10,20 @@ from db import get_connection
ActorType = Literal["human", "agent", "working_group", "external_system"] ActorType = Literal["human", "agent", "working_group", "external_system"]
ACTOR_TYPES = frozenset({"human", "agent", "working_group", "external_system"})
def _serialize_actor(row: dict[str, Any], *, include_user_id: bool = False) -> dict[str, Any]:
result = {
"id": str(row["id"]),
"name": row["name"],
"actor_type": row["actor_type"],
"is_active": bool(row["is_active"]),
}
if include_user_id and row.get("user_id"):
result["user_id"] = str(row["user_id"])
return result
def create_actor( def create_actor(
*, *,
@ -66,3 +80,70 @@ def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
return result return result
finally: finally:
conn.close() conn.close()
def list_actors(
*,
tenant_id: str,
actor_type: Optional[str] = None,
include_inactive: bool = False,
q: Optional[str] = None,
) -> list[dict[str, Any]]:
if actor_type is not None and actor_type not in ACTOR_TYPES:
raise ValueError(f"Ungültiger actor_type: {actor_type}")
conditions = ["tenant_id = %s"]
params: list[Any] = [tenant_id]
if not include_inactive:
conditions.append("is_active = TRUE")
if actor_type:
conditions.append("actor_type = %s")
params.append(actor_type)
if q:
conditions.append("name ILIKE %s")
params.append(f"%{q.strip()}%")
where = " AND ".join(conditions)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT id, actor_type, name, user_id, is_active
FROM actors
WHERE {where}
ORDER BY
CASE actor_type
WHEN 'human' THEN 0
WHEN 'working_group' THEN 1
WHEN 'agent' THEN 2
ELSE 3
END,
name
""",
params,
)
return [_serialize_actor(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def get_actor(*, tenant_id: str, actor_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, actor_type, name, user_id, is_active
FROM actors
WHERE id = %s AND tenant_id = %s
""",
(actor_id, tenant_id),
)
row = cur.fetchone()
if not row:
return None
return _serialize_actor(dict(row))
finally:
conn.close()

247
backend/tests/test_ap07.py Normal file
View File

@ -0,0 +1,247 @@
"""AP0.7 — Actor directory, workspace data layer, tenant hardening tests."""
from __future__ import annotations
from auth import AUTH_HEADER
from services.actors import create_actor
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_action,
_create_initiative,
_login,
)
def test_list_actors_tenant_scoped(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Bot Alpha")
res = client.get("/api/actors", headers=_auth(token))
assert res.status_code == 200
body = res.json()
ids = {a["id"] for a in body}
assert user["actor_id"] in ids
assert agent["id"] in ids
types = {a["actor_type"] for a in body}
assert "human" in types
assert "agent" in types
for actor in body:
assert "user_id" not in actor
def test_list_actors_filter_by_type(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Filter Agent")
res = client.get("/api/actors?actor_type=agent", headers=_auth(token))
assert res.status_code == 200
assert all(a["actor_type"] == "agent" for a in res.json())
def test_list_actors_excludes_inactive_by_default(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
inactive = create_actor(
tenant_id=user["tenant_id"], actor_type="external_system", name="Legacy"
)
conn = __import__("db").get_connection()
try:
with conn.cursor() as cur:
cur.execute("UPDATE actors SET is_active = FALSE WHERE id = %s", (inactive["id"],))
conn.commit()
finally:
conn.close()
res = client.get("/api/actors", headers=_auth(token))
assert inactive["id"] not in {a["id"] for a in res.json()}
res_active = client.get("/api/actors?include_inactive=true", headers=_auth(token))
assert inactive["id"] in {a["id"] for a in res_active.json()}
def test_get_actor_detail(client):
user = provision_user_in_tenant()
token = _login(client, user)
agent = create_actor(tenant_id=user["tenant_id"], actor_type="working_group", name="Team A")
res = client.get(f"/api/actors/{agent['id']}", headers=_auth(token))
assert res.status_code == 200
assert res.json()["name"] == "Team A"
assert res.json()["actor_type"] == "working_group"
def test_cross_tenant_actor_directory_isolation(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)
agent_a = create_actor(tenant_id=user_a["tenant_id"], actor_type="agent", name="Tenant A Agent")
list_b = client.get("/api/actors", headers=_auth(token_b)).json()
assert agent_a["id"] not in {a["id"] for a in list_b}
assert user_a["actor_id"] not in {a["id"] for a in list_b}
detail = client.get(f"/api/actors/{agent_a['id']}", headers=_auth(token_b))
assert detail.status_code == 404
def test_member_has_actor_and_workspace_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.actor.read" in ctx["capabilities"]
assert "kairo.workspace.read" in ctx["capabilities"]
def test_workspace_summary(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,
assigned_actor_ids=[user["actor_id"]],
).json()
blocked = _create_action(
client,
token,
initiative_id,
title="Blocked",
assigned_actor_ids=[user["actor_id"]],
).json()
client.patch(
f"/api/actions/{blocked['id']}",
json={"status": "blocked"},
headers=_auth(token),
)
done = _create_action(
client,
token,
initiative_id,
title="Done",
assigned_actor_ids=[user["actor_id"]],
).json()
client.patch(
f"/api/actions/{done['id']}",
json={"status": "done"},
headers=_auth(token),
)
res = client.get("/api/workspace/summary", headers=_auth(token))
assert res.status_code == 200
summary = res.json()
assert summary["open_actions_count"] >= 1
assert summary["blocked_actions_count"] >= 1
assert summary["active_initiatives_count"] >= 1
assert summary["done_actions_recent_count"] >= 1
assert open_action["id"] # used
def test_workspace_open_and_blocked_actions(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
open_id = _create_action(
client,
token,
initiative_id,
title="Open",
assigned_actor_ids=[user["actor_id"]],
).json()["id"]
blocked_id = _create_action(
client,
token,
initiative_id,
title="Blocked",
assigned_actor_ids=[user["actor_id"]],
).json()["id"]
client.patch(
f"/api/actions/{blocked_id}",
json={"status": "blocked"},
headers=_auth(token),
)
open_res = client.get("/api/workspace/actions/open", headers=_auth(token))
blocked_res = client.get("/api/workspace/actions/blocked", headers=_auth(token))
assert open_res.status_code == 200
assert blocked_res.status_code == 200
assert open_id in {a["id"] for a in open_res.json()}
assert blocked_id in {a["id"] for a in blocked_res.json()}
assert blocked_id not in {a["id"] for a in open_res.json()}
def test_workspace_active_initiatives(client):
user = provision_user_in_tenant()
token = _login(client, user)
initiative_id = _create_initiative(client, token, title="Active One").json()["id"]
res = client.get("/api/workspace/initiatives/active?limit=5", headers=_auth(token))
assert res.status_code == 200
ids = {i["id"] for i in res.json()}
assert initiative_id in ids
def test_workspace_actor_workload(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="Workload Bot")
initiative_id = _create_initiative(client, token).json()["id"]
_create_action(
client,
token,
initiative_id,
assigned_actor_ids=[agent["id"]],
)
res = client.get("/api/workspace/actors/workload", headers=_auth(token))
assert res.status_code == 200
workload = {row["actor_id"]: row for row in res.json()}
assert agent["id"] in workload
assert workload[agent["id"]]["open_actions"] >= 1
def test_cross_tenant_workspace_summary_isolation(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"]
_create_action(
client,
token_a,
initiative_id,
assigned_actor_ids=[user_a["actor_id"]],
)
summary_b = client.get("/api/workspace/summary", headers=_auth(token_b)).json()
assert summary_b["open_actions_count"] == 0
open_b = client.get("/api/workspace/actions/open", headers=_auth(token_b)).json()
assert open_b == []
def test_assign_action_to_tenant_actor(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="Assignee")
initiative_id = _create_initiative(client, token).json()["id"]
action_id = _create_action(client, token, initiative_id).json()["id"]
res = client.put(
f"/api/actions/{action_id}/assignments",
json={"actor_ids": [agent["id"]]},
headers=_auth(token),
)
assert res.status_code == 200
assert res.json()["assigned_actor_ids"] == [agent["id"]]
def test_unauthenticated_workspace_rejected(client):
assert client.get("/api/workspace/summary").status_code == 401
assert client.get("/api/actors").status_code == 401

View File

@ -13,6 +13,7 @@ def test_registry_contains_initial_capabilities():
"kairo.admin.access", "kairo.admin.access",
"kairo.tenant.manage", "kairo.tenant.manage",
"kairo.actor.manage", "kairo.actor.manage",
"kairo.actor.read",
"kairo.context.read", "kairo.context.read",
"kairo.entitlements.read", "kairo.entitlements.read",
"kairo.feature.registry.read", "kairo.feature.registry.read",
@ -26,6 +27,7 @@ def test_registry_contains_initial_capabilities():
"kairo.initiative.manage", "kairo.initiative.manage",
"kairo.action.read", "kairo.action.read",
"kairo.action.manage", "kairo.action.manage",
"kairo.workspace.read",
} }
@ -37,7 +39,7 @@ def test_sync_is_idempotent():
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities") cur.execute("SELECT COUNT(*) FROM capabilities")
assert cur.fetchone()[0] == 16 assert cur.fetchone()[0] == 18
cur.execute("SELECT COUNT(*) FROM role_capability_grants") cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5 assert cur.fetchone()[0] >= 5
finally: finally:

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.6.0-ap0.6" APP_VERSION = "0.7.0-ap0.7"
DB_SCHEMA_VERSION = "006" DB_SCHEMA_VERSION = "006"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,62 @@
# Kairo Tenant-Invarianten v0.1
**Status:** verbindlich für Sprint 0 / AP0.7+
**Stand:** 2026-07-05
---
## Zweck
Dieses Dokument legt Mandanten-Invarianten für Kairo fest, damit fachliche Module tenant-sicher bleiben und Cross-Tenant-Leaks vermieden werden.
Bei Abweichungen: Architecture Decision Proposal.
---
## Invarianten
| # | Invariante | Umsetzung in Kairo |
|---|------------|-------------------|
| 1 | Jede fachliche Tabelle hat `tenant_id`, sofern nicht global | `initiatives`, `actions`, `action_assignments`, `actors` — alle mit `tenant_id` |
| 2 | `tenant_id` kommt aus TenantContext, nicht aus Client-Body | Router nutzen `ctx.tenant_id` aus `require_tenant_context` / `require_capability` |
| 3 | Jede fachliche Query filtert nach `tenant_id` | Services und Data Layer: `WHERE tenant_id = %s` |
| 4 | Fremde Objekt-IDs → `404`, nicht `403` | Initiatives, Actions, Actors: Cross-Tenant-Zugriff liefert 404 |
| 5 | Actor-Zuweisungen nur im selben Tenant | `_actor_in_tenant()` in Services; Assignment-Tests |
| 6 | User ≠ Actor | Human Actor verknüpft via `actors.user_id`; Assignments an `actor_id` |
| 7 | Agenten/Arbeitsgruppen/externe Systeme sind tenant-scoped Actors | `actors.tenant_id` + `actor_type` |
| 8 | Audit Events enthalten mindestens `tenant_id` | `log_audit(..., tenant_id=...)` |
| 9 | Neue fachliche Tabellen: explizite Tenant-Entscheidung | Nummerierte Migration + Doku-Ergänzung |
| 10 | Router ohne eigene Mandantenlogik | Delegation an Services (Write) und Data Layer (Read-Aggregation) |
---
## Geprüfte Bereiche (AP0.7)
| Bereich | Ergebnis |
|---------|----------|
| `initiatives` CRUD | ✓ tenant_id aus Context |
| `actions` CRUD | ✓ tenant_id aus Context |
| `action_assignments` | ✓ tenant_id + Actor-Validierung |
| `actors` Directory | ✓ nur aktiver Tenant |
| `/api/actions/me/open` | ✓ via Data Layer, actor-scoped |
| `/api/workspace/*` | ✓ Data Layer, tenant-scoped |
| Cross-Tenant Tests | ✓ `test_initiatives_actions`, `test_ap07` |
---
## Anti-Patterns (verboten)
- `tenant_id` aus Request-Body übernehmen ohne Membership-Prüfung
- Listen ohne `tenant_id`-Filter
- Cross-Tenant-Existenz via 403 verraten
- User-ID direkt in Assignments statt Actor-ID
- Aggregations-SQL in Routern
- Frontend-Widgets mit fachlicher KPI-Berechnung statt Data-Layer-Endpoints
---
## Referenzen
- `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md` (G-01, G-02, G-03)
- Shinkan: Access Layer / tenant-sichere Queries
- AP0.7 Abschlussbericht: `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md`

View File

@ -0,0 +1,342 @@
# AP0.7 Abschlussbericht Tenant Hardening, Actor Directory & Data Layer Minimum
**Status:** abgeschlossen
**Stand:** 2026-07-05
**Branch:** `develop`
**Version:** Backend `0.7.0-ap0.7` · Frontend `0.7.0-ap0.7` · Schema `006` (unverändert)
---
## 1. Scope und Einordnung
AP0.7 ist ein **Basis-Härtungsauftrag** — keine neuen großen Fachfeatures.
| Teil | Ziel | Status |
|------|------|--------|
| A | Tenant Hardening | ✓ Invarianten dokumentiert, bestehende Endpoints geprüft, Tests ergänzt |
| B | Actor Directory | ✓ `GET /api/actors`, `GET /api/actors/{id}`, ActorSelect mit echter Liste |
| C | Data Layer Minimum | ✓ `backend/data_layer/`, Workspace-Endpoints, Widget-Integration |
| D | Audit-Härtung | ✓ Bewertung dokumentiert — `actor_id`-Spalte bewusst zurückgestellt |
**Keine neue Migration** — Read-Models nutzen bestehendes Schema `006`.
---
## 2. Umgesetzte Dateien
### Backend (neu)
| Datei | Zweck |
|-------|--------|
| `backend/data_layer/__init__.py` | Data-Layer-Paket |
| `backend/data_layer/actions.py` | Open/blocked Actions (Read) |
| `backend/data_layer/initiatives.py` | Active Initiatives (Read) |
| `backend/data_layer/actors.py` | Actor Workload (Read) |
| `backend/data_layer/workspace.py` | Workspace Summary |
| `backend/routers/actors.py` | Actor Directory API |
| `backend/routers/workspace.py` | Workspace Read API |
| `backend/rights_registrations/workspace_ops.py` | `kairo.actor.read`, `kairo.workspace.read` |
| `backend/tests/test_ap07.py` | AP0.7 Tests (14 Tests) |
### Backend (geändert)
| Datei | Änderung |
|-------|----------|
| `backend/services/actors.py` | `list_actors`, `get_actor` |
| `backend/routers/actions.py` | `/me/open` delegiert an Data Layer |
| `backend/main.py` | Router actors + workspace |
| `backend/version.py` | `0.7.0-ap0.7` |
| `backend/tests/test_rights_registry.py` | 18 Capabilities |
### Frontend (neu/geändert)
| Datei | Zweck |
|-------|--------|
| `frontend/src/api/workspace.js` | Workspace API-Client |
| `frontend/src/hooks/useActors.js` | Actor Directory Hook + Fallback |
| `frontend/src/widgets/WorkspaceSummaryWidget.jsx` | Überblick-Karte |
| `frontend/src/api/actors.js` | `listActors`, Fallback beibehalten |
| Widgets, `ActorSelect`, `ActionForm`, `InitiativeDetailPage` | Data-Layer-Endpoints, echte Actors |
| `frontend/src/registry/widgetRegistry.js` | + `kairo.workspace_summary` |
### Doku
| Datei | Zweck |
|-------|--------|
| `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` | 10 Tenant-Invarianten |
| `README.md` | AP0.7 API, Capabilities |
---
## 3. Tenant Hardening
**Ergebnis der Code-Prüfung:** Bestehende fachliche Services (AP0.5) waren bereits tenant-sicher aufgebaut.
| Prüfpunkt | Ergebnis |
|-----------|----------|
| `tenant_id` aus Context, nicht Body | ✓ alle Router |
| Queries mit `tenant_id`-Filter | ✓ Services + Data Layer |
| Cross-Tenant → 404 | ✓ Initiatives, Actions, Actors |
| Assignment tenant-sicher | ✓ `_actor_in_tenant()` |
| Keine Client-`tenant_id` | ✓ |
**Gefundene Mandantenrisiken:** Keine neuen Lücken in AP0.5-Code. AP0.7 schließt die fehlende Actor-Directory-Isolation und Workspace-Aggregations-Tests ab.
---
## 4. Tenant-Invarianten
Verbindlich dokumentiert in `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` (10 Invarianten + Anti-Patterns).
---
## 5. Actor Directory
### Endpoints
| Endpoint | Capability | Verhalten |
|----------|------------|-----------|
| `GET /api/actors` | `kairo.actor.read` | Aktive Actors des Tenants; optional `actor_type`, `q`, `include_inactive` |
| `GET /api/actors/{id}` | `kairo.actor.read` | Detail; Cross-Tenant → 404 |
### Response-Shape
```json
{ "id": "...", "name": "...", "actor_type": "human|agent|working_group|external_system", "is_active": true }
```
`user_id` wird **nicht** exponiert (Datenschutz).
### Default Grants `kairo.actor.read`
Portal Admin/User, Tenant Owner/Admin/Member — Begründung: Assignment-UI und Workspace-Workload für alle operativen Tenant-Mitglieder.
---
## 6. ActorSelect / Assignment UX
- `useActors()` lädt `/api/actors`
- `ActorSelect` zeigt Name + Typ (deutsche Labels)
- Mehrfachauswahl für Assignments
- Fallback auf Human Actor aus Context bei API-Fehler
- Loading/Error/Empty States
- Maßnahmen können Agents/Working Groups aus dem Tenant zugewiesen werden
---
## 7. Data Layer Struktur
```text
backend/data_layer/
actions.py — get_my_open_actions, get_my_blocked_actions, get_all_my_open_actions
initiatives.py — get_active_initiatives
actors.py — get_actor_workload
workspace.py — get_workspace_summary
```
### Abgrenzung
| Schicht | Verantwortung |
|---------|---------------|
| Router | HTTP, Capability-Gates, Serialisierung |
| Data Layer | Read-Aggregation, tenant-scoped Queries, DTOs |
| Services | Write/CRUD, Domänenvalidierung, Audit |
`/api/actions/me/open` bleibt für Abwärtskompatibilität; delegiert intern an Data Layer.
---
## 8. Workspace Summary
`GET /api/workspace/summary``kairo.workspace.read`
```json
{
"open_actions_count": 0,
"blocked_actions_count": 0,
"active_initiatives_count": 0,
"done_actions_recent_count": 0
}
```
- Personal (current actor): open, blocked, done (7 Tage via `updated_at`)
- Tenant-weit: `active_initiatives_count` (status active/paused)
Frontend: `WorkspaceSummaryWidget` im Workspace.
---
## 9. Actor Workload Basic
`GET /api/workspace/actors/workload` — pro aktivem Actor im Tenant:
```json
{
"actor_id": "...",
"display_name": "...",
"actor_type": "human",
"open_actions": 3,
"blocked_actions": 1,
"in_progress_actions": 2
}
```
Noch **kein dediziertes UI-Widget** — Endpoint für spätere Dashboards; API + Tests vorhanden.
---
## 10. Neue/geänderte API-Endpunkte
Siehe README AP0.7-Abschnitt. Keine Write-Endpoints hinzugefügt.
---
## 11. Capability-Nutzung
| Capability | Neu? | Grants | Verwendung |
|------------|------|--------|------------|
| `kairo.actor.read` | ✓ | Member+ | Actor Directory |
| `kairo.workspace.read` | ✓ | Member+ | Workspace Data Layer |
| `kairo.actor.manage` | — | unverändert | Actor-Verwaltung (AP0.2) |
| `kairo.action.read` | — | unverändert | CRUD-Read, `/me/open` |
| `kairo.initiative.read` | — | unverändert | Initiatives |
**Entscheidung:** Kein separates `kairo.workspace.metrics.read` — ein Read-Key für alle Workspace-Read-Models.
Gesamt: **18 Capabilities** (vorher 16).
---
## 12. Audit-Härtung / actor_id-Bewertung
**Ist-Zustand:** `audit_log` hat `user_id`, `tenant_id`, `details` (JSONB). Kein `actor_id`-Spalte.
**Bewertung:**
| Option | Empfehlung |
|--------|------------|
| `actor_id` in `details` | ✓ bereits bei Assignments (`actor_ids`) |
| `actor_id` als Spalte | **Zurückgestellt** — Migration 007 + Backfill; sinnvoll in AP0.8 (Audit/Admin) |
| Risiko ohne Spalte | Gering für Sprint 0 — Actor aus Context + details ausreichend für Nachvollziehbarkeit |
Keine Migration in AP0.7 (Basis-Härtung ohne Schema-Change).
---
## 13. Frontend-Integration
| Widget/Komponente | Vor AP0.7 | Nach AP0.7 |
|-------------------|-----------|------------|
| MyOpenActionsWidget | `/api/actions/me/open` + Client-Filter | `/api/workspace/actions/open` |
| BlockedActionsWidget | `/me/open` + Filter | `/api/workspace/actions/blocked` |
| InitiativesWidget | `/api/initiatives` + Client-Filter | `/api/workspace/initiatives/active?limit=5` |
| WorkspaceSummaryWidget | — | `/api/workspace/summary` |
| ActorSelect | Context-Fallback only | `/api/actors` + Fallback |
**Keine fachliche KPI-Berechnung mehr in Widgets** (Filter/Slice entfernt).
AP0.6b Look & Feel unverändert.
---
## 14. Tests und Verifikation
### Backend (`test_ap07.py`)
- Actor Directory: List, Filter, Inactive, Detail, Cross-Tenant
- Workspace: Summary, Open, Blocked, Initiatives, Workload, Cross-Tenant
- Capabilities: Member hat `kairo.actor.read`, `kairo.workspace.read`
- Assignment an Tenant-Actor
### Frontend
- Vitest: 9/9 grün (Registry inkl. `workspace_summary`)
- Build: OK
### CI
pytest im Backend-Container (wie bisher) — erwartet grün mit +14 Tests.
---
## 15. Übernommene Muster aus Mitai
- Zentrale Data-Layer-Schicht für Read/Aggregation
- Widgets konsumieren vorbereitete Daten
- Trennung Rohdaten / Kennzahlen / UI
**Nicht übernommen:** Mitai Tracking/Gesundheits-KPIs, Tier/Billing.
---
## 16. Übernommene Muster aus Shinkan
- Tenant-sichere Queries mit Context-`tenant_id`
- Capability Gates pro Endpoint
- Cross-Tenant → 404
- Access-Layer-Denken (Data Layer als Read-Access)
**Nicht übernommen:** Vereins-/Trainingslogik.
---
## 17. Bewusst nicht übernommene Muster
- Analytics-Plattform, Zeitreihen, Forecasting
- Actor-Admin-UI, Actor-Erstellung in GUI
- `audit_log.actor_id` Migration
- Backend-Widget-Registry
- Program-Director-Algorithmus
---
## 18. Abweichungen von der Spezifikation
| Thema | Abweichung | Begründung |
|-------|------------|------------|
| Actor Workload UI | nur API, kein Widget | Endpoint für später; kein UI-Scope |
| Breakpoint Shell | unverändert 1024px | AP0.6b unverändert |
| `/api/actions/me/open` | beibehalten | Abwärtskompatibilität |
| `done_actions_recent_count` | 7-Tage-Fenster via `updated_at` | pragmatisch, dokumentiert |
---
## 19. Offene Entscheidungen
1. **`audit_log.actor_id`** — Migration in AP0.8?
2. **Actor Workload Widget** — wann in Workspace anzeigen?
3. **Initiative bearbeiten in UI** — weiter offen aus AP0.6
4. **Foundation AP0.5 Audit/Admin-UI** — backlog
---
## 20. Empfehlung für AP0.8
Kleinster Nutzwert (Priorität):
1. **Audit/Admin minimal** — Audit-Log lesbar, optional `actor_id`-Spalte
2. **Initiative bearbeiten in UI** — PATCH existiert
3. **Actor Workload Widget** — kleine Karte im Workspace
4. **Kommentare pro Maßnahme** — Kollaboration ohne Domänen-Explosion
Danach Sprint-1-Fachscope gemäß Product Spec.
---
## Abnahme-Checkliste AP0.7
| Kriterium | Erfüllt |
|-----------|---------|
| Tenant-Invarianten dokumentiert | ✓ |
| Fachliche Endpoints tenant-geprüft | ✓ |
| Cross-Tenant-Tests | ✓ |
| `GET /api/actors` | ✓ |
| ActorSelect echte Liste | ✓ |
| Tenant-Actor-Zuweisung | ✓ |
| Data Layer Minimum | ✓ |
| Workspace Summary | ✓ |
| Blocked/Active zentral | ✓ |
| Actor Workload Basic | ✓ (API) |
| Frontend nutzt Data Layer | ✓ |
| Keine großen Fachfeatures | ✓ |
| AP0.6b UX erhalten | ✓ |

View File

@ -1,6 +1,6 @@
{ {
"name": "kairo-jinkendo-frontend", "name": "kairo-jinkendo-frontend",
"version": "0.6.1-ap0.6b", "version": "0.7.0-ap0.7",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,9 +1,22 @@
/** import { apiFetch } from './client.js'
* AP0.6: Kein GET /api/actors im Backend.
* Actor-Zuweisung nutzt TenantContext (aktueller Human Actor).
* Siehe Abschlussbericht AP0.6 §9.
*/
/**
* @param {{ actor_type?: string, include_inactive?: boolean, q?: string }} [params]
*/
export function listActors(params = {}) {
const search = new URLSearchParams()
if (params.actor_type) search.set('actor_type', params.actor_type)
if (params.include_inactive) search.set('include_inactive', 'true')
if (params.q) search.set('q', params.q)
const qs = search.toString()
return apiFetch(`/api/actors${qs ? `?${qs}` : ''}`)
}
export function getActor(id) {
return apiFetch(`/api/actors/${id}`)
}
/** Fallback wenn Actor Directory nicht erreichbar (AP0.6-Kompatibilität). */
export function actorsFromContext(context) { export function actorsFromContext(context) {
if (!context?.actor?.id) return [] if (!context?.actor?.id) return []
return [ return [

View File

@ -0,0 +1,21 @@
import { apiFetch } from './client.js'
export function getWorkspaceSummary() {
return apiFetch('/api/workspace/summary')
}
export function listWorkspaceOpenActions() {
return apiFetch('/api/workspace/actions/open')
}
export function listWorkspaceBlockedActions() {
return apiFetch('/api/workspace/actions/blocked')
}
export function listWorkspaceActiveInitiatives(limit = 5) {
return apiFetch(`/api/workspace/initiatives/active?limit=${limit}`)
}
export function getActorWorkload() {
return apiFetch('/api/workspace/actors/workload')
}

View File

@ -5,6 +5,10 @@ import { ActorSelect } from './ActorSelect.jsx'
export function ActionForm({ export function ActionForm({
initial = {}, initial = {},
actors = [], actors = [],
actorsLoading = false,
actorsError = null,
actorsUsedFallback = false,
onReloadActors,
onSubmit, onSubmit,
onCancel, onCancel,
busy = false, busy = false,
@ -55,7 +59,14 @@ export function ActionForm({
))} ))}
</select> </select>
</label> </label>
<ActorSelect actors={actors} defaultSelected={defaultActors} /> <ActorSelect
actors={actors}
defaultSelected={defaultActors}
loading={actorsLoading}
error={actorsError}
usedFallback={actorsUsedFallback}
onRetry={onReloadActors}
/>
<div className="form-actions"> <div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}> <button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichern …' : submitLabel} {busy ? 'Speichern …' : submitLabel}

View File

@ -1,4 +1,36 @@
export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewiesene Actors' }) { const ACTOR_TYPE_LABELS = {
human: 'Mensch',
agent: 'Agent',
working_group: 'Arbeitsgruppe',
external_system: 'Externes System',
}
export function ActorSelect({
actors = [],
defaultSelected = [],
label = 'Zugewiesene Actors',
loading = false,
error = null,
usedFallback = false,
onRetry,
}) {
if (loading) {
return <p className="muted actor-select-hint">Actors werden geladen </p>
}
if (error && !actors.length) {
return (
<div className="actor-select actor-select--error">
<p className="error">{error}</p>
{onRetry && (
<button type="button" className="btn btn-secondary" onClick={onRetry}>
Erneut laden
</button>
)}
</div>
)
}
if (!actors.length) { if (!actors.length) {
return ( return (
<p className="muted actor-select-hint"> <p className="muted actor-select-hint">
@ -18,12 +50,17 @@ export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewi
defaultChecked={defaultSelected.includes(actor.id)} defaultChecked={defaultSelected.includes(actor.id)}
/> />
{actor.name} {actor.name}
{actor.actor_type && <span className="muted"> ({actor.actor_type})</span>} {actor.actor_type && (
<span className="muted">
{' '}
({ACTOR_TYPE_LABELS[actor.actor_type] || actor.actor_type})
</span>
)}
</label> </label>
))} ))}
{actors.length === 1 && ( {usedFallback && (
<p className="muted actor-select-hint"> <p className="muted actor-select-hint">
AP0.6: Nur der aktuelle Actor Tenant-Actor-Liste folgt in AP0.7. Actor Directory nicht erreichbar Fallback auf aktuellen Actor.
</p> </p>
)} )}
</fieldset> </fieldset>

View File

@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from 'react'
import { listActors, actorsFromContext } from '../api/actors.js'
import { useSession } from '../context/SessionContext.jsx'
/**
* Lädt Tenant-Actor-Directory; Fallback auf Human Actor aus Context.
*/
export function useActors() {
const { context } = useSession()
const [actors, setActors] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [usedFallback, setUsedFallback] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
setUsedFallback(false)
try {
const data = await listActors()
setActors(data)
} catch (err) {
setError(err.message)
const fallback = actorsFromContext(context)
setActors(fallback)
setUsedFallback(fallback.length > 0)
} finally {
setLoading(false)
}
}, [context])
useEffect(() => {
load()
}, [load])
return { actors, loading, error, usedFallback, reload: load }
}

View File

@ -6,7 +6,6 @@ import {
createInitiativeAction, createInitiativeAction,
} from '../api/initiatives.js' } from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js' import { updateAction, setActionAssignments } from '../api/actions.js'
import { actorsFromContext } from '../api/actors.js'
import { ACTION_STATUS_LABELS } from '../constants/status.js' import { ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from '../components/StatusBadge.jsx' import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx'
@ -15,13 +14,20 @@ import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js' import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { useSession } from '../context/SessionContext.jsx' import { useSession } from '../context/SessionContext.jsx'
export function InitiativeDetailPage() { export function InitiativeDetailPage() {
const { id } = useParams() const { id } = useParams()
const { context } = useSession() const { context } = useSession()
const { hasCapability } = useCapabilities() const { hasCapability } = useCapabilities()
const actors = actorsFromContext(context) const {
actors,
loading: actorsLoading,
error: actorsError,
usedFallback: actorsUsedFallback,
reload: reloadActors,
} = useActors()
const [initiative, setInitiative] = useState(null) const [initiative, setInitiative] = useState(null)
const [actions, setActions] = useState([]) const [actions, setActions] = useState([])
@ -178,6 +184,10 @@ export function InitiativeDetailPage() {
<div className="inline-form-block"> <div className="inline-form-block">
<ActionForm <ActionForm
actors={actors} actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
onSubmit={handleCreateAction} onSubmit={handleCreateAction}
onCancel={() => setShowActionForm(false)} onCancel={() => setShowActionForm(false)}
busy={formBusy} busy={formBusy}
@ -197,6 +207,10 @@ export function InitiativeDetailPage() {
<ActionForm <ActionForm
initial={action} initial={action}
actors={actors} actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
onSubmit={(payload) => handleUpdateAction(action.id, payload)} onSubmit={(payload) => handleUpdateAction(action.id, payload)}
onCancel={() => setEditingAction(null)} onCancel={() => setEditingAction(null)}
busy={formBusy} busy={formBusy}

View File

@ -10,6 +10,7 @@ describe('widgetRegistry', () => {
expect(keys).toContain('kairo.active_initiatives') expect(keys).toContain('kairo.active_initiatives')
expect(keys).toContain('kairo.blocked_actions') expect(keys).toContain('kairo.blocked_actions')
expect(keys).toContain('kairo.tenant_context') expect(keys).toContain('kairo.tenant_context')
expect(keys).toContain('kairo.workspace_summary')
}) })
it('filters widgets by capability', () => { it('filters widgets by capability', () => {

View File

@ -2,6 +2,7 @@ import { MyOpenActionsWidget } from '../widgets/MyOpenActionsWidget.jsx'
import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx' import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx' import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx' import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
/** @typedef {import('react').ComponentType<any>} WidgetComponent */ /** @typedef {import('react').ComponentType<any>} WidgetComponent */
@ -26,6 +27,15 @@ export const WIDGETS = [
defaultOrder: 0, defaultOrder: 0,
component: TenantContextWidget, component: TenantContextWidget,
}, },
{
key: 'kairo.workspace_summary',
title: 'Überblick',
description: 'Workspace-Kennzahlen aus Data Layer',
area: 'workspace',
requiredCapability: 'kairo.workspace.read',
defaultOrder: 5,
component: WorkspaceSummaryWidget,
},
{ {
key: 'kairo.my_open_actions', key: 'kairo.my_open_actions',
title: 'Meine offenen Maßnahmen', title: 'Meine offenen Maßnahmen',

View File

@ -24,3 +24,36 @@
.mt-sm { .mt-sm {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.summary-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem 1rem;
margin: 0;
}
@media (min-width: 769px) {
.summary-metrics {
grid-template-columns: repeat(4, 1fr);
}
}
.summary-metric {
padding: 0.75rem;
background: var(--jk-surface-raised);
border-radius: 8px;
border: 1px solid var(--jk-border);
}
.summary-metric dt {
margin: 0;
font-size: 0.8125rem;
color: var(--jk-text-muted);
}
.summary-metric dd {
margin: 0.25rem 0 0;
font-size: 1.375rem;
font-weight: 600;
color: var(--jk-text);
}

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { listMyOpenActions } from '../api/actions.js' import { listWorkspaceBlockedActions } from '../api/workspace.js'
import { ActionList } from '../components/ActionList.jsx' import { ActionList } from '../components/ActionList.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
@ -16,8 +16,8 @@ export function BlockedActionsWidget() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const data = await listMyOpenActions() const data = await listWorkspaceBlockedActions()
setActions(data.filter((a) => a.status === 'blocked')) setActions(data)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { listInitiatives } from '../api/initiatives.js' import { listWorkspaceActiveInitiatives } from '../api/workspace.js'
import { StatusBadge } from '../components/StatusBadge.jsx' import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
@ -17,10 +17,8 @@ export function InitiativesWidget() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const data = await listInitiatives() const data = await listWorkspaceActiveInitiatives(5)
setInitiatives( setInitiatives(data)
data.filter((i) => i.status === 'active' || i.status === 'paused').slice(0, 5),
)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { listMyOpenActions } from '../api/actions.js' import { listWorkspaceOpenActions } from '../api/workspace.js'
import { ActionList } from '../components/ActionList.jsx' import { ActionList } from '../components/ActionList.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
@ -16,8 +16,8 @@ export function MyOpenActionsWidget() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const data = await listMyOpenActions() const data = await listWorkspaceOpenActions()
setActions(data.filter((a) => a.status !== 'blocked')) setActions(data)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {

View File

@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from 'react'
import { getWorkspaceSummary } from '../api/workspace.js'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx'
export function WorkspaceSummaryWidget() {
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await getWorkspaceSummary()
setSummary(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
return (
<WidgetCard
title="Überblick"
subtitle="Kennzahlen für deinen Workspace"
actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
Aktualisieren
</button>
}
>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && summary && (
<dl className="summary-metrics">
<div className="summary-metric">
<dt>Offene Maßnahmen</dt>
<dd>{summary.open_actions_count}</dd>
</div>
<div className="summary-metric">
<dt>Blockiert</dt>
<dd>{summary.blocked_actions_count}</dd>
</div>
<div className="summary-metric">
<dt>Aktive Vorhaben</dt>
<dd>{summary.active_initiatives_count}</dd>
</div>
<div className="summary-metric">
<dt>Erledigt (7 Tage)</dt>
<dd>{summary.done_actions_recent_count}</dd>
</div>
</dl>
)}
</WidgetCard>
)
}