diff --git a/README.md b/README.md index f86fa28..274c175 100644 --- a/README.md +++ b/README.md @@ -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}/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 | +| `/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` @@ -176,7 +190,7 @@ Nach Login leitet Kairo auf den **Workspace** weiter. | 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/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung | | `/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“ 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 +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` +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` ### Registries (AP0.4) @@ -260,7 +273,9 @@ 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.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.manage` | initiative | Vorhaben verwalten | | `kairo.action.read` | action | Maßnahmen lesen | diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py new file mode 100644 index 0000000..0c97dcc --- /dev/null +++ b/backend/data_layer/__init__.py @@ -0,0 +1 @@ +"""Kairo Data Layer — read-oriented aggregation for workspace and dashboards.""" diff --git a/backend/data_layer/actions.py b/backend/data_layer/actions.py new file mode 100644 index 0000000..bb88833 --- /dev/null +++ b/backend/data_layer/actions.py @@ -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), + ) diff --git a/backend/data_layer/actors.py b/backend/data_layer/actors.py new file mode 100644 index 0000000..fac2657 --- /dev/null +++ b/backend/data_layer/actors.py @@ -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() diff --git a/backend/data_layer/initiatives.py b/backend/data_layer/initiatives.py new file mode 100644 index 0000000..319b98d --- /dev/null +++ b/backend/data_layer/initiatives.py @@ -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() diff --git a/backend/data_layer/workspace.py b/backend/data_layer/workspace.py new file mode 100644 index 0000000..dfba52c --- /dev/null +++ b/backend/data_layer/workspace.py @@ -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), + } diff --git a/backend/main.py b/backend/main.py index b84ad8b..9d2f036 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,7 +53,7 @@ app.add_middleware( 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(me.router) @@ -62,6 +62,8 @@ app.include_router(prompts.router) app.include_router(config.router) app.include_router(initiatives.router) app.include_router(actions.router) +app.include_router(actors.router) +app.include_router(workspace.router) @app.get("/api/health") diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py index be2dd99..a8aca60 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 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"] diff --git a/backend/rights_registrations/workspace_ops.py b/backend/rights_registrations/workspace_ops.py new file mode 100644 index 0000000..85aa93c --- /dev/null +++ b/backend/rights_registrations/workspace_ops.py @@ -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"), + ), + ) +) diff --git a/backend/routers/actions.py b/backend/routers/actions.py index 8140914..a29d436 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -1,10 +1,11 @@ -"""Action (Maßnahme) API — AP0.5.""" +"""Action (Maßnahme) API — AP0.5 / AP0.7.""" from __future__ import annotations from typing import Literal, Optional from capabilities import require_capability +from data_layer import actions as dl_actions from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from services import actions as action_service @@ -28,12 +29,10 @@ class ActionAssignmentsRequest(BaseModel): 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, - ) + try: + return dl_actions.get_all_my_open_actions(ctx) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/{action_id}") diff --git a/backend/routers/actors.py b/backend/routers/actors.py new file mode 100644 index 0000000..5c26b30 --- /dev/null +++ b/backend/routers/actors.py @@ -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 diff --git a/backend/routers/workspace.py b/backend/routers/workspace.py new file mode 100644 index 0000000..1b18754 --- /dev/null +++ b/backend/routers/workspace.py @@ -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) diff --git a/backend/services/actors.py b/backend/services/actors.py index e2cee3d..7423131 100644 --- a/backend/services/actors.py +++ b/backend/services/actors.py @@ -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 @@ -10,6 +10,20 @@ from db import get_connection 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( *, @@ -66,3 +80,70 @@ def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]: return result finally: 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() diff --git a/backend/tests/test_ap07.py b/backend/tests/test_ap07.py new file mode 100644 index 0000000..f9bb5c6 --- /dev/null +++ b/backend/tests/test_ap07.py @@ -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 diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py index 3a78151..038119c 100644 --- a/backend/tests/test_rights_registry.py +++ b/backend/tests/test_rights_registry.py @@ -13,6 +13,7 @@ def test_registry_contains_initial_capabilities(): "kairo.admin.access", "kairo.tenant.manage", "kairo.actor.manage", + "kairo.actor.read", "kairo.context.read", "kairo.entitlements.read", "kairo.feature.registry.read", @@ -26,6 +27,7 @@ def test_registry_contains_initial_capabilities(): "kairo.initiative.manage", "kairo.action.read", "kairo.action.manage", + "kairo.workspace.read", } @@ -37,7 +39,7 @@ def test_sync_is_idempotent(): try: with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM capabilities") - assert cur.fetchone()[0] == 16 + assert cur.fetchone()[0] == 18 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 8071c24..ea240b6 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.6.0-ap0.6" +APP_VERSION = "0.7.0-ap0.7" DB_SCHEMA_VERSION = "006" APP_NAME = "jinkendo-kairo" diff --git a/docs/architecture/Kairo_Tenant_Invariants_v0.1.md b/docs/architecture/Kairo_Tenant_Invariants_v0.1.md new file mode 100644 index 0000000..25f1740 --- /dev/null +++ b/docs/architecture/Kairo_Tenant_Invariants_v0.1.md @@ -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` diff --git a/docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md new file mode 100644 index 0000000..88f4d55 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md @@ -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 | ✓ | diff --git a/frontend/package.json b/frontend/package.json index 7cd5b3f..15629de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.6.1-ap0.6b", + "version": "0.7.0-ap0.7", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/actors.js b/frontend/src/api/actors.js index 723a196..7b18f1b 100644 --- a/frontend/src/api/actors.js +++ b/frontend/src/api/actors.js @@ -1,9 +1,22 @@ -/** - * AP0.6: Kein GET /api/actors im Backend. - * Actor-Zuweisung nutzt TenantContext (aktueller Human Actor). - * Siehe Abschlussbericht AP0.6 §9. - */ +import { apiFetch } from './client.js' +/** + * @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) { if (!context?.actor?.id) return [] return [ diff --git a/frontend/src/api/workspace.js b/frontend/src/api/workspace.js new file mode 100644 index 0000000..b699e53 --- /dev/null +++ b/frontend/src/api/workspace.js @@ -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') +} diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx index 06dc360..2e79faf 100644 --- a/frontend/src/components/ActionForm.jsx +++ b/frontend/src/components/ActionForm.jsx @@ -5,6 +5,10 @@ import { ActorSelect } from './ActorSelect.jsx' export function ActionForm({ initial = {}, actors = [], + actorsLoading = false, + actorsError = null, + actorsUsedFallback = false, + onReloadActors, onSubmit, onCancel, busy = false, @@ -55,7 +59,14 @@ export function ActionForm({ ))} - +
+ )} +
+ ) + } + if (!actors.length) { return (

@@ -18,12 +50,17 @@ export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewi defaultChecked={defaultSelected.includes(actor.id)} /> {actor.name} - {actor.actor_type && ({actor.actor_type})} + {actor.actor_type && ( + + {' '} + ({ACTOR_TYPE_LABELS[actor.actor_type] || actor.actor_type}) + + )} ))} - {actors.length === 1 && ( + {usedFallback && (

- AP0.6: Nur der aktuelle Actor — Tenant-Actor-Liste folgt in AP0.7. + Actor Directory nicht erreichbar — Fallback auf aktuellen Actor.

)} diff --git a/frontend/src/hooks/useActors.js b/frontend/src/hooks/useActors.js new file mode 100644 index 0000000..52bfed2 --- /dev/null +++ b/frontend/src/hooks/useActors.js @@ -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 } +} diff --git a/frontend/src/pages/InitiativeDetailPage.jsx b/frontend/src/pages/InitiativeDetailPage.jsx index 8e9c2cc..7c89ca1 100644 --- a/frontend/src/pages/InitiativeDetailPage.jsx +++ b/frontend/src/pages/InitiativeDetailPage.jsx @@ -6,7 +6,6 @@ import { createInitiativeAction, } from '../api/initiatives.js' import { updateAction, setActionAssignments } from '../api/actions.js' -import { actorsFromContext } from '../api/actors.js' import { ACTION_STATUS_LABELS } from '../constants/status.js' import { StatusBadge } from '../components/StatusBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx' @@ -15,13 +14,20 @@ import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' import { useCapabilities } from '../hooks/useCapabilities.js' +import { useActors } from '../hooks/useActors.js' import { useSession } from '../context/SessionContext.jsx' export function InitiativeDetailPage() { const { id } = useParams() const { context } = useSession() const { hasCapability } = useCapabilities() - const actors = actorsFromContext(context) + const { + actors, + loading: actorsLoading, + error: actorsError, + usedFallback: actorsUsedFallback, + reload: reloadActors, + } = useActors() const [initiative, setInitiative] = useState(null) const [actions, setActions] = useState([]) @@ -178,6 +184,10 @@ export function InitiativeDetailPage() {
setShowActionForm(false)} busy={formBusy} @@ -197,6 +207,10 @@ export function InitiativeDetailPage() { handleUpdateAction(action.id, payload)} onCancel={() => setEditingAction(null)} busy={formBusy} diff --git a/frontend/src/registry/registry.test.js b/frontend/src/registry/registry.test.js index cb8de87..16e0456 100644 --- a/frontend/src/registry/registry.test.js +++ b/frontend/src/registry/registry.test.js @@ -10,6 +10,7 @@ describe('widgetRegistry', () => { expect(keys).toContain('kairo.active_initiatives') expect(keys).toContain('kairo.blocked_actions') expect(keys).toContain('kairo.tenant_context') + expect(keys).toContain('kairo.workspace_summary') }) it('filters widgets by capability', () => { diff --git a/frontend/src/registry/widgetRegistry.js b/frontend/src/registry/widgetRegistry.js index 372d12b..84fdc71 100644 --- a/frontend/src/registry/widgetRegistry.js +++ b/frontend/src/registry/widgetRegistry.js @@ -2,6 +2,7 @@ import { MyOpenActionsWidget } from '../widgets/MyOpenActionsWidget.jsx' import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx' import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx' import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx' +import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx' /** @typedef {import('react').ComponentType} WidgetComponent */ @@ -26,6 +27,15 @@ export const WIDGETS = [ defaultOrder: 0, 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', title: 'Meine offenen Maßnahmen', diff --git a/frontend/src/styles/pages.css b/frontend/src/styles/pages.css index 2ae3e63..3738ff5 100644 --- a/frontend/src/styles/pages.css +++ b/frontend/src/styles/pages.css @@ -24,3 +24,36 @@ .mt-sm { 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); +} diff --git a/frontend/src/widgets/BlockedActionsWidget.jsx b/frontend/src/widgets/BlockedActionsWidget.jsx index ca8cead..06da749 100644 --- a/frontend/src/widgets/BlockedActionsWidget.jsx +++ b/frontend/src/widgets/BlockedActionsWidget.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' 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 { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' @@ -16,8 +16,8 @@ export function BlockedActionsWidget() { setLoading(true) setError(null) try { - const data = await listMyOpenActions() - setActions(data.filter((a) => a.status === 'blocked')) + const data = await listWorkspaceBlockedActions() + setActions(data) } catch (err) { setError(err.message) } finally { diff --git a/frontend/src/widgets/InitiativesWidget.jsx b/frontend/src/widgets/InitiativesWidget.jsx index 2b825e6..68e4925 100644 --- a/frontend/src/widgets/InitiativesWidget.jsx +++ b/frontend/src/widgets/InitiativesWidget.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' 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 { PriorityBadge } from '../components/PriorityBadge.jsx' import { EmptyState } from '../components/EmptyState.jsx' @@ -17,10 +17,8 @@ export function InitiativesWidget() { setLoading(true) setError(null) try { - const data = await listInitiatives() - setInitiatives( - data.filter((i) => i.status === 'active' || i.status === 'paused').slice(0, 5), - ) + const data = await listWorkspaceActiveInitiatives(5) + setInitiatives(data) } catch (err) { setError(err.message) } finally { diff --git a/frontend/src/widgets/MyOpenActionsWidget.jsx b/frontend/src/widgets/MyOpenActionsWidget.jsx index 3044ba5..3270ebc 100644 --- a/frontend/src/widgets/MyOpenActionsWidget.jsx +++ b/frontend/src/widgets/MyOpenActionsWidget.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' 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 { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' @@ -16,8 +16,8 @@ export function MyOpenActionsWidget() { setLoading(true) setError(null) try { - const data = await listMyOpenActions() - setActions(data.filter((a) => a.status !== 'blocked')) + const data = await listWorkspaceOpenActions() + setActions(data) } catch (err) { setError(err.message) } finally { diff --git a/frontend/src/widgets/WorkspaceSummaryWidget.jsx b/frontend/src/widgets/WorkspaceSummaryWidget.jsx new file mode 100644 index 0000000..c5097c1 --- /dev/null +++ b/frontend/src/widgets/WorkspaceSummaryWidget.jsx @@ -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 ( + + Aktualisieren + + } + > + {loading && } + {!loading && error && } + {!loading && !error && summary && ( +
+
+
Offene Maßnahmen
+
{summary.open_actions_count}
+
+
+
Blockiert
+
{summary.blocked_actions_count}
+
+
+
Aktive Vorhaben
+
{summary.active_initiatives_count}
+
+
+
Erledigt (7 Tage)
+
{summary.done_actions_recent_count}
+
+
+ )} +
+ ) +}