From 03dd477899f93776bd7b502db2e79fb799723163 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 1 Sep 2026 23:23:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(N4):=20Restliche=20Module=20=E2=80=94=20Ta?= =?UTF-8?q?sks/Kommunikation/Wiki/Reports/Agents/Tags/Search=20+=20Navigat?= =?UTF-8?q?ion=20+=20Dashboard-Schnittstelle=20(#368)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry - Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M) - Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard - apply_entity_type_scope-Helper (requested ∧ scope) - Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder() - Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK - Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen) --- PROGRESS.md | 20 +- .../builtins/automation/agent_routes.py | 17 +- app/plugins/builtins/automation/contracts.py | 25 + .../builtins/kommunikation/contracts.py | 27 + app/plugins/builtins/kommunikation/routes.py | 16 +- .../builtins/report_generator/contracts.py | 25 + .../builtins/report_generator/documents.py | 15 +- app/plugins/builtins/tags/contracts.py | 25 + app/plugins/builtins/tags/routes.py | 11 +- app/plugins/builtins/tasks/contracts.py | 21 + app/plugins/builtins/tasks/routes.py | 10 +- app/plugins/builtins/tasks/services.py | 20 +- .../builtins/unified_search/contracts.py | 62 +++ app/plugins/builtins/unified_search/routes.py | 28 +- app/plugins/builtins/wiki/contracts.py | 55 ++ app/plugins/builtins/wiki/routes.py | 42 +- app/routes/miniapps.py | 11 +- app/services/workspace_scope_service.py | 85 ++++ docs/api-documentation.md | 2 + .../WorkspaceSwitcher.navigation.test.tsx | 82 +++ frontend/src/components/layout/Sidebar.tsx | 13 +- .../components/layout/WorkspaceSwitcher.tsx | 22 +- .../store/__tests__/workspaceStore.test.ts | 21 + frontend/src/store/workspaceStore.ts | 11 + tests/test_n4_scope_declarations.py | 217 ++++++++ tests/test_n4_workspace_filtering.py | 476 ++++++++++++++++++ 26 files changed, 1335 insertions(+), 24 deletions(-) create mode 100644 app/plugins/builtins/wiki/contracts.py create mode 100644 frontend/src/__tests__/layout/WorkspaceSwitcher.navigation.test.tsx create mode 100644 tests/test_n4_scope_declarations.py create mode 100644 tests/test_n4_workspace_filtering.py diff --git a/PROGRESS.md b/PROGRESS.md index 866e7c5..8f91e81 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -8,7 +8,7 @@ **Offene Roadmap-Phasen (user-abgestimmt, startklar):** - **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓** (Dashboard-Builder: Edit-Modus, Drag&Drop, Palette, Tabs), **M4 ✓** (System-Rückbau, Core = reiner Host), **M5 ✓** (Plugin-MiniApps), **M6 ✓ erledigt — PHASE M KOMPLETT** (Windows-Host + AI-Agenten-Tool send_miniapp — siehe Phase-M6-Section). -- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung: contacts Ordner-Subtree/Typen + Cache-Bypass, dms Ordner/Datei-Typen, mail Postfächer, calendar Kalender; Frontend-Defaults Saved-View/Kalender-Ansicht — siehe Phase-N3-Section). **Nächster Schritt:** N4 Restliche Module (Tasks, Kommunikation, Wiki, Reports/Dokumente, Automation, Tags, Suche, Navigation; Dashboard-Schnittstelle workspace_widgets). +- **Phase N** — Workspace-Scopes (N1-N4). **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung contacts/dms/mail/calendar + Frontend-Defaults), **N4 ✓ erledigt — PHASE N KOMPLETT** (7 weitere Module: Tasks nur-meine, Kommunikation-Räume, Wiki-Kategorien-Subtree, Reports-Vorlagen, Agents, Tags, Search-Entity-Types + Navigation Startseite/Menü-Reihenfolge + Dashboard-Schnittstelle — siehe Phase-N4-Section). **Nächster Schritt:** Phase O UI-Overhaul (offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) oder Phase P Notizen-App (P1-P5). - **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) **Vorbestands-Findings (nicht blockierend, dokumentiert):** @@ -104,6 +104,24 @@ **Offen in Phase M:** — (Phase M abgeschlossen). +## Phase N4 — Restliche Module (2026-09-01) ✅ — PHASE N KOMPLETT + +**Spec:** [#368](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/368) | **Roadmap:** Phase N, N4 (letzter Task) | **Milestone:** Phase N — Workspace-Scopes (#14) + +**Umgesetzt:** +- **Scope-Deklarationen (7 Plugins):** tasks only_mine-Toggle („nur meine"), kommunikation conversation_ids (Räume), wiki category_ids (Subtree — NEUE contracts.py, wiki hatte zuvor keinen Contract), report_generator template_ids (Vorlagen), automation agent_ids (module_key agents — page route ohne Menüeintrag), tags tag_ids (Root-Array), unified_search entity_types DYNAMISCH aus Provider-Registry (13 Entity-Types, Live-Set + deterministischer Klassen-Fallback). +- **Core-Beiträge (Aggregator):** navigation default_route (Startseite pro Workspace, Optionen aus CORE_PERMISSIONS + bekannten Frontend-Routen) + dashboard widget_app_ids (begrenzt das Widget-TYP-Angebot — workspace_widgets-Boundary; persönliches Layout bleibt Phase M). +- **Backend-Filter (additive UND, kein Umbau):** GET /tasks (assigned_to OR created_by), GET /comm/conversations (Subset), GET /wiki/articles + /categories (expand_folder_scope-Subtree), GET /reports/print-templates (Subset), GET /agents (Subset), GET /tags (Subset), GET+POST /search (apply_entity_type_scope: requested ∧ scope), GET /miniapps?host=dashboard (widget_app_ids begrenzt NUR Dashboard-Angebot, chat/window unberührt). +- **Frontend-Navigation:** WorkspaceSwitcher navigiert nach default_route beim Wechsel (Validierung: muss mit / beginnen); Sidebar sortiert nach workspace menu_order als Admin-Default (persönliche savedOrder bleibt Override); workspaceStore moduleMenuOrder()-Helper. + +**Verifiziert (2026-09-01):** +- TDD: Deklarationen **18/18** (rot: 18 failed → Implementation → grün), Filter **11/11** (rot: 8 failed + 1 error → grün; inkl. Dashboard-Boundary: scoped {w1} vs. unscoped Superset, chat unberührt) +- ✅ Frontend: Vitest Switcher-Navigation 2/2, Store 18/18 (moduleMenuOrder +2), tsc clean, Build OK +- ✅ Kombi-Regression (N1+N3+N4-Dateien): 64 passed / 4 failed — alle 4 per Solo-Lauf als Suite-Isolation bewiesen (N1 solo 18/18, N3-Test solo grün — bekannter Vorbestand, unterschiedliche Plugin-Fixtures in einem Prozess) +- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: 7 Fehler = exakt Vorbestand (Stash-Beweis: clean HEAD identisch 7) + +**Phase N Gesamtbilanz:** Workspace-Scopes komplett — Registry via Contract (N1), dynamischer Editor (N2), Backend-Filterung für alle 11 Module (N3: contacts/dms/mail/calendar + N4: tasks/communication/wiki/reports/agents/tags/search) + Navigation (Startseite, Menü-Reihenfolge) + Dashboard-Schnittstelle (widget_app_ids). Security-Invariante durchgehend: Scope = reine UND-Einschränkung, Exemptions nur System-Admin + configure_modules-Inhaber (Editor-Deadlock). Issues #365-#368 alle geschlossen. + ## Phase N3 — Erste vier Module integrieren (2026-09-01) ✅ **Spec:** [#367](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/367) | **Roadmap:** Phase N, N3 | **Milestone:** Phase N — Workspace-Scopes (#14) diff --git a/app/plugins/builtins/automation/agent_routes.py b/app/plugins/builtins/automation/agent_routes.py index fef713a..6f0291c 100644 --- a/app/plugins/builtins/automation/agent_routes.py +++ b/app/plugins/builtins/automation/agent_routes.py @@ -15,7 +15,7 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db -from app.deps import get_current_user, require_permission +from app.deps import get_current_user, require_permission, require_workspace_scope from app.plugins.builtins.automation.models import ( AgentDefinition, AgentRun, @@ -118,8 +118,13 @@ async def list_agents( offset: int = Query(0, ge=0), current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), + workspace_scope: dict | None = Depends(require_workspace_scope("agents")), ): - """List agent definitions with optional filters.""" + """List agent definitions with optional filters. + + Phase N4: an active workspace scope restricts the list to the + configured agent subset (pure AND — never a grant). + """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("is_system_admin", False) @@ -127,6 +132,14 @@ async def list_agents( db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset, user_id=user_id, is_system_admin=is_system_admin, ) + # Phase N4: workspace scope — agent subset (pure AND) + if workspace_scope: + from app.services.workspace_scope_service import scope_uuid_set + + agent_scope = scope_uuid_set(workspace_scope.get("agent_ids")) + if agent_scope is not None: + items = [a for a in items if a.id in agent_scope] + total = len(items) return AgentDefinitionListResponse( items=[_agent_to_response(a) for a in items], total=total, diff --git a/app/plugins/builtins/automation/contracts.py b/app/plugins/builtins/automation/contracts.py index d5517b1..4286ec3 100644 --- a/app/plugins/builtins/automation/contracts.py +++ b/app/plugins/builtins/automation/contracts.py @@ -63,6 +63,31 @@ class AutomationContract: # ─── agent_comm ─── send_agent_message = staticmethod(send_agent_message) + # ─── Workspace Scopes contribution (Phase N4) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des agents-Moduls: Agenten-Teilmengen (N4).""" + return [ + { + "module_key": "agents", + "dimensions": [ + { + "key": "agent_ids", + "label": "Agenten", + "control": "multiselect", + "options": [], + "value_source": { + "endpoint": "/api/v1/agents", + "items_path": "items", + "value_key": "id", + "label_key": "name", + }, + }, + ], + } + ] + @classmethod def get_function(cls, name: str): """Return a callable exposed by this contract, or None if absent.""" diff --git a/app/plugins/builtins/kommunikation/contracts.py b/app/plugins/builtins/kommunikation/contracts.py index 1995245..f79cf8a 100644 --- a/app/plugins/builtins/kommunikation/contracts.py +++ b/app/plugins/builtins/kommunikation/contracts.py @@ -105,6 +105,33 @@ class KommunikationContract: } + + # ─── Workspace Scopes contribution (Phase N4) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des communication-Moduls: Räume-Teilmengen (N4).""" + return [ + { + "module_key": "communication", + "dimensions": [ + { + "key": "conversation_ids", + "label": "Räume", + "control": "multiselect", + "options": [], + "value_source": { + "endpoint": "/api/v1/comm/conversations", + "items_path": "items", + "value_key": "id", + "label_key": "title", + }, + }, + ], + } + ] + + # ─── self-registration ─── _contract = KommunikationContract() diff --git a/app/plugins/builtins/kommunikation/routes.py b/app/plugins/builtins/kommunikation/routes.py index e94972c..e7f7c0e 100644 --- a/app/plugins/builtins/kommunikation/routes.py +++ b/app/plugins/builtins/kommunikation/routes.py @@ -19,7 +19,7 @@ from fastapi import ( from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db -from app.deps import get_current_user, require_permission +from app.deps import get_current_user, require_permission, require_workspace_scope from app.plugins.builtins.kommunikation.content_types import list_block_types from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge from app.plugins.builtins.kommunikation.rbac import CommRBAC @@ -74,11 +74,23 @@ async def list_user_conversations( archived: bool = Query(False, description="Include archived conversations"), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), + workspace_scope: dict | None = Depends(require_workspace_scope("communication")), ): - """List all conversations for the current user.""" + """List all conversations for the current user. + + Phase N4: an active workspace scope (X-Workspace-ID) restricts the list + to the configured conversation subset (pure AND — never a grant). + """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) convs = await list_conversations(db, tenant_id, user_id, include_archived=archived) + # Phase N4: conversation_ids scope — keep only scoped rooms + if workspace_scope: + from app.services.workspace_scope_service import scope_uuid_set + + conv_scope = scope_uuid_set(workspace_scope.get("conversation_ids")) + if conv_scope is not None: + convs = [c for c in convs if uuid.UUID(c["id"]) in conv_scope] return {"items": convs, "total": len(convs)} diff --git a/app/plugins/builtins/report_generator/contracts.py b/app/plugins/builtins/report_generator/contracts.py index f82bb99..48e3d89 100644 --- a/app/plugins/builtins/report_generator/contracts.py +++ b/app/plugins/builtins/report_generator/contracts.py @@ -43,6 +43,31 @@ class ReportGeneratorContract: PRESET_META = PRESET_META PRESET_TEMPLATES = PRESET_TEMPLATES + # ─── Workspace Scopes contribution (Phase N4) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des reports-Moduls: Vorlagen-Teilmengen (N4).""" + return [ + { + "module_key": "reports", + "dimensions": [ + { + "key": "template_ids", + "label": "Vorlagen", + "control": "multiselect", + "options": [], + "value_source": { + "endpoint": "/api/v1/reports/print-templates", + "items_path": "items", + "value_key": "id", + "label_key": "name", + }, + }, + ], + } + ] + # ─── self-registration ─── diff --git a/app/plugins/builtins/report_generator/documents.py b/app/plugins/builtins/report_generator/documents.py index 53eca35..8d8ccb9 100644 --- a/app/plugins/builtins/report_generator/documents.py +++ b/app/plugins/builtins/report_generator/documents.py @@ -21,7 +21,7 @@ from app.ai.llm_client import llm_complete from app.core.audit import log_audit from app.core.db import get_db, set_tenant_context from app.core.storage import get_storage_backend -from app.deps import require_permission +from app.deps import require_permission, require_workspace_scope from app.plugins.builtins.report_generator.document_blocks import ( BlockValidationError, get_document_blocks, @@ -423,8 +423,13 @@ async def list_letterhead_assets( async def list_print_templates( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), + workspace_scope: dict | None = Depends(require_workspace_scope("reports")), ): - """List print templates for the current tenant.""" + """List print templates for the current tenant. + + Phase N4: an active workspace scope restricts the template list to the + configured subset (pure AND — never a grant). + """ tenant_id = uuid_mod.UUID(current_user["tenant_id"]) q = ( select(PrintTemplate) @@ -435,6 +440,12 @@ async def list_print_templates( .order_by(PrintTemplate.name) ) items = (await db.execute(q)).scalars().all() + if workspace_scope: + from app.services.workspace_scope_service import scope_uuid_set + + template_scope = scope_uuid_set(workspace_scope.get("template_ids")) + if template_scope is not None: + items = [t for t in items if t.id in template_scope] return { "items": [_template_to_response(t).model_dump() for t in items], "total": len(items), diff --git a/app/plugins/builtins/tags/contracts.py b/app/plugins/builtins/tags/contracts.py index 6cc1c63..f6072f3 100644 --- a/app/plugins/builtins/tags/contracts.py +++ b/app/plugins/builtins/tags/contracts.py @@ -26,6 +26,31 @@ class TagsContract: Tag = Tag TagAssignment = TagAssignment + # ─── Workspace Scopes contribution (Phase N4) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des tags-Moduls: Tag-Teilmengen (N4).""" + return [ + { + "module_key": "tags", + "dimensions": [ + { + "key": "tag_ids", + "label": "Tags", + "control": "multiselect", + "options": [], + "value_source": { + "endpoint": "/api/v1/tags", + "items_path": "", + "value_key": "id", + "label_key": "name", + }, + }, + ], + } + ] + # ─── self-registration ─── diff --git a/app/plugins/builtins/tags/routes.py b/app/plugins/builtins/tags/routes.py index ed8e52f..a551163 100644 --- a/app/plugins/builtins/tags/routes.py +++ b/app/plugins/builtins/tags/routes.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.db import get_db from app.core.visibility import apply_visibility_filter -from app.deps import get_current_user, require_permission +from app.deps import get_current_user, require_permission, require_workspace_scope from app.plugins.builtins.tags.models import Tag, TagAssignment from app.plugins.builtins.tags.schemas import ( TagAssignRequest, @@ -44,6 +44,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID: async def list_tags( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), + workspace_scope: dict | None = Depends(require_workspace_scope("tags")), ): """List all tags with entity counts.""" tenant_id = uuid.UUID(current_user["tenant_id"]) @@ -73,6 +74,14 @@ async def list_tags( result = await db.execute(query) rows = result.all() + # Phase N4: workspace scope — tag subset (pure AND, never a grant) + if workspace_scope: + from app.services.workspace_scope_service import scope_uuid_set + + tag_scope = scope_uuid_set(workspace_scope.get("tag_ids")) + if tag_scope is not None: + rows = [(tag, count) for tag, count in rows if tag.id in tag_scope] + return [ { "id": str(tag.id), diff --git a/app/plugins/builtins/tasks/contracts.py b/app/plugins/builtins/tasks/contracts.py index 1efc7e0..1869cc7 100644 --- a/app/plugins/builtins/tasks/contracts.py +++ b/app/plugins/builtins/tasks/contracts.py @@ -47,6 +47,27 @@ class TasksContract: update_task_status = staticmethod(update_task_status) get_due_tasks = staticmethod(get_due_tasks) + # ─── Workspace Scopes contribution (Phase N4) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des tasks-Moduls: „nur meine" (Roadmap N4).""" + return [ + { + "module_key": "tasks", + "dimensions": [ + { + "key": "only_mine", + "label": "Nur meine Aufgaben", + "control": "toggle", + "options": [], + "value_source": None, + "default": False, + }, + ], + } + ] + # ─── models (read-only for queries) ─── Task = Task diff --git a/app/plugins/builtins/tasks/routes.py b/app/plugins/builtins/tasks/routes.py index 31f09cf..4240a6c 100644 --- a/app/plugins/builtins/tasks/routes.py +++ b/app/plugins/builtins/tasks/routes.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.db import get_db -from app.deps import get_current_user, require_permission +from app.deps import get_current_user, require_permission, require_workspace_scope from app.plugins.builtins.tasks import services from app.plugins.builtins.tasks.schemas import ( TaskAssignRequest, @@ -53,8 +53,13 @@ async def list_tasks( search: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), + workspace_scope: dict | None = Depends(require_workspace_scope("tasks")), ): - """List tasks with filtering and pagination.""" + """List tasks with filtering and pagination. + + Phase N4: an active workspace scope with only_mine=true restricts the + list to tasks assigned to or created by the current user (pure AND). + """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("is_system_admin", False) @@ -68,6 +73,7 @@ async def list_tasks( parent_task_id=parent_task_id, task_type=task_type, search=search, user_id=user_id, is_system_admin=is_system_admin, + workspace_scope=workspace_scope, ) diff --git a/app/plugins/builtins/tasks/services.py b/app/plugins/builtins/tasks/services.py index 7e0a110..d75cc9a 100644 --- a/app/plugins/builtins/tasks/services.py +++ b/app/plugins/builtins/tasks/services.py @@ -16,7 +16,7 @@ def _to_uuid(val: str | UUID | None) -> UUID | None: return val return uuid.UUID(str(val)) -from sqlalchemy import func, select # noqa: E402 — after helper defs by design +from sqlalchemy import func, or_, select # noqa: E402 — after helper defs by design from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402 from app.core.visibility import apply_visibility_filter # noqa: E402 @@ -215,10 +215,26 @@ async def list_tasks( task_type: str | None = None, user_id: uuid.UUID | None = None, is_system_admin: bool = False, + workspace_scope: dict | None = None, ) -> dict[str, Any]: - """List tasks with filtering and pagination.""" + """List tasks with filtering and pagination. + + Phase N4: ``workspace_scope`` with only_mine=true restricts the list to + tasks assigned to or created by the current user (pure AND on top of + all other filters — never a grant). + """ query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None)) + # Phase N4: workspace scope — only_mine restricts to own tasks (assigned + # to OR created by the current user). Pure AND, never a grant. + if workspace_scope and workspace_scope.get("only_mine") is True and user_id: + query = query.where( + or_( + Task.assigned_to == user_id, + Task.created_by == user_id, + ) + ) + if user_id and not is_system_admin: query = await apply_visibility_filter( db, query, "task", Task, user_id, tenant_id, is_system_admin diff --git a/app/plugins/builtins/unified_search/contracts.py b/app/plugins/builtins/unified_search/contracts.py index 26a0066..ffe23a7 100644 --- a/app/plugins/builtins/unified_search/contracts.py +++ b/app/plugins/builtins/unified_search/contracts.py @@ -61,6 +61,68 @@ class UnifiedSearchContract: await _auto_register(db) + # ─── Workspace Scopes contribution (Phase N4) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des search-Moduls: Suchbereiche (N4). + + Options come from the live search provider registry; when it has + not been initialized yet (sync context before activation), the + built-in provider classes are the deterministic fallback source + (same classes auto_register_providers registers at activation). + """ + entity_types = list(get_search_registry().get_entity_types()) + if not entity_types: + from app.plugins.builtins.unified_search.providers import ( + agent_memory_provider, + ai_chat_provider, + company_provider, + contact_provider, + contactperson_provider, + conversation_provider, + event_provider, + file_provider, + mail_provider, + tag_provider, + task_provider, + user_provider, + workflow_provider, + ) + + for module in ( + agent_memory_provider, ai_chat_provider, company_provider, + contact_provider, contactperson_provider, conversation_provider, + event_provider, file_provider, mail_provider, tag_provider, + task_provider, user_provider, workflow_provider, + ): + for attr in dir(module): + obj = getattr(module, attr) + if ( + isinstance(obj, type) + and attr.endswith("Provider") + and attr != "BaseSearchProvider" + and getattr(obj, "entity_type", "") + ): + entity_types.append(obj.entity_type) + entity_types = sorted(set(entity_types)) + return [ + { + "module_key": "search", + "dimensions": [ + { + "key": "entity_types", + "label": "Suchbereiche", + "control": "multiselect", + "options": [ + {"value": et, "label": et.replace("_", " ").title()} + for et in entity_types + ], + }, + ], + } + ] + @classmethod def get_function(cls, name: str): """Return a callable exposed by this contract, or None if absent.""" diff --git a/app/plugins/builtins/unified_search/routes.py b/app/plugins/builtins/unified_search/routes.py index 86197fe..ede420a 100644 --- a/app/plugins/builtins/unified_search/routes.py +++ b/app/plugins/builtins/unified_search/routes.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.core.jobs import enqueue_job from app.core.permissions import filter_fields_by_permission, resolve_permissions -from app.deps import get_current_user, require_permission +from app.deps import get_current_user, require_permission, require_workspace_scope from app.plugins.builtins.unified_search.provider_registry import get_search_registry from app.plugins.builtins.unified_search.query_understanding import ( llm_aggregate_results, @@ -56,8 +56,13 @@ async def search_get( sort: str = Query(default="relevance", description="Sort order: relevance, date, name"), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), + workspace_scope: dict | None = Depends(require_workspace_scope("search")), ) -> SearchResponse: - """Perform hybrid search via GET (same as POST but with query params).""" + """Perform hybrid search via GET (same as POST but with query params). + + Phase N4: an active workspace scope intersects the requested entity + types with the configured search areas (pure AND — never a grant). + """ types_list = entity_types.split(",") if entity_types else None tags_list = tags.split(",") if tags else None req = SearchRequest( @@ -70,6 +75,12 @@ async def search_get( tags=tags_list, sort=sort, ) + if workspace_scope: + from app.services.workspace_scope_service import apply_entity_type_scope + + req.entity_types = apply_entity_type_scope( + req.entity_types, workspace_scope.get("entity_types") + ) return await _do_search(req, current_user, db) @@ -219,8 +230,19 @@ async def search( req: SearchRequest, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), + workspace_scope: dict | None = Depends(require_workspace_scope("search")), ) -> SearchResponse: - """Perform hybrid search with KI query understanding.""" + """Perform hybrid search with KI query understanding. + + Phase N4: an active workspace scope intersects the requested entity + types with the configured search areas (pure AND — never a grant). + """ + if workspace_scope: + from app.services.workspace_scope_service import apply_entity_type_scope + + req.entity_types = apply_entity_type_scope( + req.entity_types, workspace_scope.get("entity_types") + ) return await _do_search(req, current_user, db) diff --git a/app/plugins/builtins/wiki/contracts.py b/app/plugins/builtins/wiki/contracts.py new file mode 100644 index 0000000..aa80544 --- /dev/null +++ b/app/plugins/builtins/wiki/contracts.py @@ -0,0 +1,55 @@ +"""Wiki plugin contract — public interface for cross-plugin access (N4). + +Created for the Phase N workspace_scopes contribution (the wiki previously +had no contract module — N4 needs one for the scope registry, mirroring the +contacts/dms/mail/calendar pattern from N1). +""" + +from __future__ import annotations + +from app.plugins.builtins.contracts import get_contract_registry + + +class WikiContract: + """Public contract for the wiki plugin.""" + + contract_name = "wiki" + + # ─── Workspace Scopes contribution (Phase N4) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des wiki-Moduls: Kategorien-Teilmengen (N4).""" + return [ + { + "module_key": "wiki", + "dimensions": [ + { + "key": "category_ids", + "label": "Wiki-Kategorien", + "control": "multiselect", + "options": [], + "value_source": { + "endpoint": "/api/v1/wiki/categories", + "items_path": "items", + "value_key": "id", + "label_key": "name", + }, + }, + ], + } + ] + + @classmethod + def get_function(cls, name: str): + """Return a callable exposed by this contract, or None if absent.""" + return getattr(cls, name, None) + + +# ─── self-registration ─── + +_contract = WikiContract() +get_contract_registry().register("wiki", _contract) + + +__all__ = ["WikiContract"] diff --git a/app/plugins/builtins/wiki/routes.py b/app/plugins/builtins/wiki/routes.py index 05bb7fb..86fc8d0 100644 --- a/app/plugins/builtins/wiki/routes.py +++ b/app/plugins/builtins/wiki/routes.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.db import get_db -from app.deps import require_permission +from app.deps import require_permission, require_workspace_scope from app.plugins.builtins.wiki import services from app.plugins.builtins.wiki.schemas import ( ArticleCreate, @@ -28,11 +28,35 @@ async def list_articles( search: str | None = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read")), + workspace_scope: dict | None = Depends(require_workspace_scope("wiki")), ): - return await services.list_articles( + """List wiki articles. + + Phase N4: an active workspace scope (X-Workspace-ID) restricts articles + to the category subtree (category_ids incl. children — pure AND). + """ + scoped_category_ids: set | None = None + if workspace_scope: + from app.plugins.builtins.wiki.models import WikiCategory + from app.services.workspace_scope_service import expand_folder_scope + + raw_ids = workspace_scope.get("category_ids") + if isinstance(raw_ids, list) and raw_ids: + scoped_category_ids = await expand_folder_scope(db, WikiCategory, raw_ids) + + result = await services.list_articles( db, uuid.UUID(current_user["tenant_id"]), page=page, page_size=page_size, category_id=category_id, status=status, search=search, ) + # Phase N4: filter to the scoped category subtree (post-fetch AND filter) + if scoped_category_ids is not None: + items = [ + a for a in result["items"] + if a.get("category_id") and uuid.UUID(a["category_id"]) in scoped_category_ids + ] + result["items"] = items + result["total"] = len(items) + return result @router.post("/articles", status_code=status.HTTP_201_CREATED) @@ -120,8 +144,20 @@ async def restore_version( async def list_categories( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read")), + workspace_scope: dict | None = Depends(require_workspace_scope("wiki")), ): - return {"items": await services.list_categories(db, uuid.UUID(current_user["tenant_id"]))} + """List wiki categories (Phase N4: scope reduces to the category subtree).""" + items = await services.list_categories(db, uuid.UUID(current_user["tenant_id"])) + if workspace_scope: + from app.plugins.builtins.wiki.models import WikiCategory + from app.services.workspace_scope_service import expand_folder_scope + + raw_ids = workspace_scope.get("category_ids") + if isinstance(raw_ids, list) and raw_ids: + subtree = await expand_folder_scope(db, WikiCategory, raw_ids) + allowed = subtree or set() + items = [c for c in items if uuid.UUID(c["id"]) in allowed] + return {"items": items} @router.post("/categories", status_code=status.HTTP_201_CREATED) diff --git a/app/routes/miniapps.py b/app/routes/miniapps.py index 1ae8f96..289e518 100644 --- a/app/routes/miniapps.py +++ b/app/routes/miniapps.py @@ -9,7 +9,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException -from app.deps import get_current_user +from app.deps import get_current_user, require_workspace_scope from app.plugins.miniapp_registry import get_miniapp_registry, user_permits router = APIRouter(prefix="/api/v1/miniapps", tags=["miniapps"]) @@ -23,14 +23,23 @@ _user_permits = user_permits async def list_miniapps( host: str | None = None, current_user: dict = Depends(get_current_user), + workspace_scope: dict | None = Depends(require_workspace_scope("dashboard")), ): """List MiniApps visible to the current user (permission-filtered). ``?host=chat|dashboard|window`` filters by the hosts declared on the MiniApp definition. + Phase N4: a workspace scope with widget_app_ids limits the OFFERED + widget types — only for host=dashboard (admin boundary). Personal + layouts stay user-owned (Phase M split). """ registry = get_miniapp_registry() items = [a for a in registry.list_apps(host=host) if _user_permits(current_user, a)] + if workspace_scope and host == "dashboard": + widget_ids = workspace_scope.get("widget_app_ids") + if isinstance(widget_ids, list) and widget_ids: + allowed = set(widget_ids) + items = [a for a in items if a.get("app_id") in allowed] items.sort(key=lambda a: a.get("order", 100)) return {"items": items, "total": len(items)} diff --git a/app/services/workspace_scope_service.py b/app/services/workspace_scope_service.py index 42b4f1d..4fb1e79 100644 --- a/app/services/workspace_scope_service.py +++ b/app/services/workspace_scope_service.py @@ -155,6 +155,23 @@ async def expand_folder_scope( return result +def apply_entity_type_scope( + requested: list[str] | None, + scope_entity_types: Any, +) -> list[str] | None: + """Intersect requested search entity types with the workspace scope (N4). + + Pure AND: the effective set is requested ∧ scope. ``None`` means "no + restriction" on either side (search all). An empty result list means the + search legitimately yields nothing (scope excludes every requested type). + """ + if not isinstance(scope_entity_types, list) or not scope_entity_types: + return requested + if requested is None: + return list(scope_entity_types) + return [et for et in requested if et in set(scope_entity_types)] + + def scope_uuid_set(raw: Any) -> set[uuid.UUID] | None: """Convert a scope dimension value into a set of UUIDs (Phase N3). @@ -244,4 +261,72 @@ def get_scope_definitions() -> dict[str, list[dict[str, Any]]]: modules.setdefault(parsed.module_key, []).extend( dimension.model_dump() for dimension in parsed.dimensions ) + + # ─── Core contributions (Phase N4) ────────────────────────── + # Core-owned modules (no plugin owns them) contribute through the same + # registry so the N2 editor renders them automatically. + for core_contribution in _core_scope_contributions(): + parsed = _parse_contribution("core", core_contribution) + if parsed is None: + continue + modules.setdefault(parsed.module_key, []).extend( + dimension.model_dump() for dimension in parsed.dimensions + ) return modules + + +def _core_scope_contributions() -> list[dict[str, Any]]: + """Scope contributions for core-owned modules (Phase N4). + + - navigation: default_route per workspace ("Startseite") — where the + workspace switcher navigates to. + - dashboard: widget_app_ids — the workspace limits the OFFERED widget + types (admin context, workspace_widgets boundary). The personal + layout stays user-owned (Phase M boundary, user-corrected split). + """ + from app.core.permission_registry import CORE_PERMISSIONS + + route_options = [ + {"value": "/", "label": "Dashboard"}, + {"value": "/contacts", "label": "Kontakte"}, + ] + # Every core permission module with a matching frontend route contributes + # a navigation option (dynamic, registry-derived — no hardcoded list). + known_routes = {"/", "/contacts", "/tasks", "/calendar", "/mail", "/dms", "/wiki", "/communication", "/reports", "/tags", "/search", "/agents", "/workflows"} + for perm in CORE_PERMISSIONS: + module = perm.get("module", "") + route = f"/{module}" + if route in known_routes and all(o["value"] != route for o in route_options): + route_options.append({"value": route, "label": module.title()}) + + return [ + { + "module_key": "navigation", + "dimensions": [ + { + "key": "default_route", + "label": "Startseite", + "control": "select", + "options": route_options, + "default": "/", + }, + ], + }, + { + "module_key": "dashboard", + "dimensions": [ + { + "key": "widget_app_ids", + "label": "Verfügbare Widgets", + "control": "multiselect", + "options": [], + "value_source": { + "endpoint": "/api/v1/miniapps?host=dashboard", + "items_path": "items", + "value_key": "app_id", + "label_key": "name", + }, + }, + ], + }, + ] diff --git a/docs/api-documentation.md b/docs/api-documentation.md index f7ed87e..c330a7e 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -300,6 +300,8 @@ Personal per-user dashboards (Phase M2). Owner-only: every query is scoped to te Workspaces are UI/navigation context only — they never affect permissions or data access (Phase N). Visibility is always Workspace ∧ RLS ∧ ABAC ∧ Permissions. Tab-local workspace selection via `X-Workspace-ID` header; the API client interceptor sets it per tab. Requires `workspaces:*` core permissions. +**Phase N3/N4 list scoping:** with an active `X-Workspace-ID`, list endpoints apply the workspace module scope as a pure AND-restriction (never a grant): contacts (folder subtree + types), dms (folder subtree + file types), mail (accounts), calendar (calendars), tasks (only_mine), communication (conversation subsets), wiki (category subtree), reports (template subsets), agents (agent subsets), tags (tag subsets), search (entity-type intersection on GET/POST). System admins and holders of `workspaces:configure_modules` are exempt (the scope editor needs unrestricted value lists). `GET /miniapps?host=dashboard` additionally limits the offered widget types to the workspace's `widget_app_ids` (chat/window hosts unaffected; personal dashboard layouts stay user-owned, Phase M). + | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/workspaces` | List all workspaces for the tenant. | diff --git a/frontend/src/__tests__/layout/WorkspaceSwitcher.navigation.test.tsx b/frontend/src/__tests__/layout/WorkspaceSwitcher.navigation.test.tsx new file mode 100644 index 0000000..060ae96 --- /dev/null +++ b/frontend/src/__tests__/layout/WorkspaceSwitcher.navigation.test.tsx @@ -0,0 +1,82 @@ +/** + * N4 — Navigation defaults (Phase N, final task). + * + * Workspace navigation config (admin-defined per workspace): + * - default_route: where the workspace switcher navigates to after switching + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher'; + +const mockSwitch = vi.fn(); +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const myWorkspaces = [ + { id: 'ws-1', name: 'Vertrieb', description: null, icon: 'LayoutGrid', is_default: false, is_active: true, role: 'member', is_user_default: false, modules: [] }, + { + id: 'ws-2', + name: 'Support', + description: null, + icon: 'LayoutGrid', + is_default: false, + is_active: true, + role: 'member', + is_user_default: false, + modules: [ + { module_key: 'navigation', menu_order: 0, config: { default_route: '/contacts' } }, + ], + }, +]; + +vi.mock('@/hooks/useWorkspace', () => ({ + useWorkspace: () => ({ + myWorkspaces, + activeWorkspaceId: 'ws-1', + switchWorkspace: mockSwitch, + hasWorkspaces: true, + }), +})); + +function renderSwitcher() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('WorkspaceSwitcher navigation (N4)', () => { + it('switches workspace and navigates to its default_route', () => { + renderSwitcher(); + fireEvent.click(screen.getByRole('button', { name: /Vertrieb/i })); + fireEvent.click(screen.getByText('Support')); + + expect(mockSwitch).toHaveBeenCalledWith('ws-2'); + expect(mockNavigate).toHaveBeenCalledWith('/contacts'); + }); + + it('switches without navigation config (backward compatible, no navigate call)', () => { + renderSwitcher(); + fireEvent.click(screen.getByRole('button', { name: /Vertrieb/i })); + // switch to ws-1 (no navigation config) — the dropdown entry (not the + // trigger button, which also shows the active workspace name) + fireEvent.click(screen.getAllByText('Vertrieb')[1].closest('button')!); + + expect(mockSwitch).toHaveBeenCalledWith('ws-1'); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index e7483ab..0a4caa6 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -30,6 +30,7 @@ const ICON_MAP: Record> = { import { useMenuOrder } from '@/api/users'; import { usePermission } from '@/hooks/usePermission'; import { useWorkspace } from '@/hooks/useWorkspace'; +import { useWorkspaceStore } from '@/store/workspaceStore'; import { useAuthStore } from '@/store/authStore'; interface NavSingleItem { @@ -68,6 +69,7 @@ export function Sidebar() { const { hasPermission } = usePermission(); const user = useAuthStore((state) => state.user); const { isModuleVisible } = useWorkspace(); + const moduleMenuOrder = useWorkspaceStore(s => s.moduleMenuOrder()); // Use hasPermission directly — permissions are loaded via useUserPermissions hook const canAccess = (perm?: string): boolean => { @@ -136,10 +138,19 @@ export function Sidebar() { } return workspaceFiltered.sort((a, b) => { + // N4: workspace menu_order is the admin-defined fallback order — + // the personal savedOrder above stays the user override. + const wsOrder = moduleMenuOrder; + const aWs = wsOrder.get(a.path.replace(/^\//, '').split('/')[0]); + const bWs = wsOrder.get(b.path.replace(/^\//, '').split('/')[0]); + if (aWs !== undefined && bWs !== undefined && aWs !== bWs) return aWs - bWs; + if (aWs !== undefined && bWs === undefined) return -1; + if (aWs === undefined && bWs !== undefined) return 1; + // Fall back to order field + label (backward compatible) if (a.order !== b.order) return a.order - b.order; return a.label.localeCompare(b.label); }); - }, [manifests, menuOrderData, user, isModuleVisible]); + }, [manifests, menuOrderData, user, isModuleVisible, moduleMenuOrder]); const [expandedItems, setExpandedItems] = useState>(new Set()); diff --git a/frontend/src/components/layout/WorkspaceSwitcher.tsx b/frontend/src/components/layout/WorkspaceSwitcher.tsx index ab2ae62..bef1837 100644 --- a/frontend/src/components/layout/WorkspaceSwitcher.tsx +++ b/frontend/src/components/layout/WorkspaceSwitcher.tsx @@ -1,10 +1,12 @@ import { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { useWorkspace } from '@/hooks/useWorkspace'; import { LayoutGrid, ChevronDown, Check } from 'lucide-react'; export function WorkspaceSwitcher() { const { t } = useTranslation(); + const navigate = useNavigate(); const { myWorkspaces, activeWorkspaceId, switchWorkspace, hasWorkspaces } = useWorkspace(); const [open, setOpen] = useState(false); const ref = useRef(null); @@ -23,6 +25,21 @@ export function WorkspaceSwitcher() { const activeWs = myWorkspaces.find(w => w.id === activeWorkspaceId); + const handleSwitch = (wsId: string) => { + switchWorkspace(wsId); + setOpen(false); + // N4: navigate to the workspace default_route (admin-defined per + // workspace). The route comes from the target workspace's context — + // read via the workspace store so the fresh context applies after the + // switch (the store resolves config synchronously from myWorkspaces). + const target = myWorkspaces.find(w => w.id === wsId); + const navConfig = target?.modules?.find((m: any) => m.module_key === 'navigation'); + const route = navConfig?.config?.default_route; + if (typeof route === 'string' && route.startsWith('/')) { + navigate(route); + } + }; + return (