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
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:
parent
36487d2869
commit
db06af0489
27
README.md
27
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 |
|
||||
|
|
|
|||
1
backend/data_layer/__init__.py
Normal file
1
backend/data_layer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Kairo Data Layer — read-oriented aggregation for workspace and dashboards."""
|
||||
85
backend/data_layer/actions.py
Normal file
85
backend/data_layer/actions.py
Normal 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),
|
||||
)
|
||||
61
backend/data_layer/actors.py
Normal file
61
backend/data_layer/actors.py
Normal 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()
|
||||
37
backend/data_layer/initiatives.py
Normal file
37
backend/data_layer/initiatives.py
Normal 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()
|
||||
70
backend/data_layer/workspace.py
Normal file
70
backend/data_layer/workspace.py
Normal 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),
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
35
backend/rights_registrations/workspace_ops.py
Normal file
35
backend/rights_registrations/workspace_ops.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
41
backend/routers/actors.py
Normal file
41
backend/routers/actors.py
Normal 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
|
||||
63
backend/routers/workspace.py
Normal file
63
backend/routers/workspace.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
247
backend/tests/test_ap07.py
Normal file
247
backend/tests/test_ap07.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
62
docs/architecture/Kairo_Tenant_Invariants_v0.1.md
Normal file
62
docs/architecture/Kairo_Tenant_Invariants_v0.1.md
Normal 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`
|
||||
342
docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md
Normal file
342
docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md
Normal 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 | ✓ |
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
21
frontend/src/api/workspace.js
Normal file
21
frontend/src/api/workspace.js
Normal 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')
|
||||
}
|
||||
|
|
@ -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({
|
|||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ActorSelect actors={actors} defaultSelected={defaultActors} />
|
||||
<ActorSelect
|
||||
actors={actors}
|
||||
defaultSelected={defaultActors}
|
||||
loading={actorsLoading}
|
||||
error={actorsError}
|
||||
usedFallback={actorsUsedFallback}
|
||||
onRetry={onReloadActors}
|
||||
/>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Speichern …' : submitLabel}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
return (
|
||||
<p className="muted actor-select-hint">
|
||||
|
|
@ -18,12 +50,17 @@ export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewi
|
|||
defaultChecked={defaultSelected.includes(actor.id)}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
{actors.length === 1 && (
|
||||
{usedFallback && (
|
||||
<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>
|
||||
)}
|
||||
</fieldset>
|
||||
|
|
|
|||
37
frontend/src/hooks/useActors.js
Normal file
37
frontend/src/hooks/useActors.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<div className="inline-form-block">
|
||||
<ActionForm
|
||||
actors={actors}
|
||||
actorsLoading={actorsLoading}
|
||||
actorsError={actorsError}
|
||||
actorsUsedFallback={actorsUsedFallback}
|
||||
onReloadActors={reloadActors}
|
||||
onSubmit={handleCreateAction}
|
||||
onCancel={() => setShowActionForm(false)}
|
||||
busy={formBusy}
|
||||
|
|
@ -197,6 +207,10 @@ export function InitiativeDetailPage() {
|
|||
<ActionForm
|
||||
initial={action}
|
||||
actors={actors}
|
||||
actorsLoading={actorsLoading}
|
||||
actorsError={actorsError}
|
||||
actorsUsedFallback={actorsUsedFallback}
|
||||
onReloadActors={reloadActors}
|
||||
onSubmit={(payload) => handleUpdateAction(action.id, payload)}
|
||||
onCancel={() => setEditingAction(null)}
|
||||
busy={formBusy}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<any>} 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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
63
frontend/src/widgets/WorkspaceSummaryWidget.jsx
Normal file
63
frontend/src/widgets/WorkspaceSummaryWidget.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user