From fea8e4b5e6641b0d6a68d3ba94a172f9835b9b56 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 7 Aug 2026 14:53:52 +0200 Subject: [PATCH] feat: project role-bound postboxes --- src/govoplan_portal/backend/manifest.py | 37 +++++++++++++ src/govoplan_portal/backend/router.py | 29 ++++++++++ src/govoplan_portal/backend/schemas.py | 18 +++++++ tests/test_service_directory.py | 46 ++++++++++++++++ webui/src/api/portal.ts | 30 +++++++++++ webui/src/features/portal/PortalPage.tsx | 48 ++++++++++++++++- webui/src/styles/portal.css | 67 ++++++++++++++++++++++++ 7 files changed, 274 insertions(+), 1 deletion(-) diff --git a/src/govoplan_portal/backend/manifest.py b/src/govoplan_portal/backend/manifest.py index a9a9ac6..847ba95 100644 --- a/src/govoplan_portal/backend/manifest.py +++ b/src/govoplan_portal/backend/manifest.py @@ -5,6 +5,7 @@ from govoplan_core.core.institutional import ( CAPABILITY_SERVICE_DEFINITIONS, service_launch_capability, ) +from govoplan_core.core.postbox import CAPABILITY_POSTBOX_PORTAL from govoplan_core.core.modules import ( CapabilityDocumentation, DocumentationLink, @@ -61,11 +62,13 @@ manifest = ModuleManifest( "forms", "forms_runtime", "workflow_engine", + "postbox", ), optional_capabilities=( CAPABILITY_SERVICE_DEFINITIONS, CAPABILITY_SERVICE_AVAILABILITY, *SERVICE_LAUNCH_CAPABILITIES, + CAPABILITY_POSTBOX_PORTAL, ), permissions=( PermissionDefinition( @@ -103,6 +106,12 @@ manifest = ModuleManifest( ) for capability in SERVICE_LAUNCH_CAPABILITIES ), + ModuleInterfaceRequirement( + name=CAPABILITY_POSTBOX_PORTAL, + version_min="0.1.0", + version_max_exclusive="0.2.0", + optional=True, + ), ), capability_factories={ CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory, @@ -162,6 +171,34 @@ manifest = ModuleManifest( ), }, documentation=( + DocumentationTopic( + id="portal.function-postboxes", + title="Portal-facing function Postboxes", + summary="Open explicitly published function Postboxes without moving their access rules into Portal.", + body=( + "When Postbox is installed, Portal can display Postboxes whose exact definition or published template revision is marked portal-visible. " + "Postbox re-evaluates the current function assignment, classification, and read authority for every projection. Portal stores no Postbox ACL, " + "does not expose vacant or inaccessible addresses, and links back to the authoritative Postbox surface." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin"), + translations={ + "de": { + "title": "Portal-sichtbare Funktionspostfächer", + "summary": "Ausdrücklich veröffentlichte Funktionspostfächer öffnen, ohne ihre Zugriffsregeln in das Portal zu verlagern.", + "body": ( + "Ist Postbox installiert, kann das Portal Postfächer anzeigen, deren exakte Definition oder veröffentlichte Vorlagenrevision als portalsichtbar markiert ist. " + "Postbox prüft für jede Projektion die aktuelle Funktionszuordnung, Klassifikation und Leseberechtigung erneut. Das Portal speichert keine Postfach-ACL, " + "zeigt keine unbesetzten oder nicht zugänglichen Adressen und verweist auf die maßgebliche Postbox-Oberfläche." + ), + } + }, + links=(DocumentationLink(label="Portal", href="/portal", kind="runtime"),), + related_modules=("postbox", "idm", "organizations"), + metadata={"kind": "guide", "help_contexts": ["portal.postboxes"]}, + order=20, + ), DocumentationTopic( id="portal.service-directory", title="Service directory", diff --git a/src/govoplan_portal/backend/router.py b/src/govoplan_portal/backend/router.py index 99e06b7..cd47cb4 100644 --- a/src/govoplan_portal/backend/router.py +++ b/src/govoplan_portal/backend/router.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import UTC, datetime +from dataclasses import asdict from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session @@ -10,11 +11,13 @@ from govoplan_core.core.institutional import ( InstitutionalContextError, InstitutionalReference, ) +from govoplan_core.core.postbox import postbox_portal_projection_provider from govoplan_core.db.session import get_session from govoplan_portal.backend.schemas import ( PortalServiceLaunchRequest, PortalServiceLaunchResponse, PortalServiceListResponse, + PortalPostboxListResponse, ) from govoplan_portal.backend.service_directory import ( PortalServiceDirectory, @@ -66,6 +69,32 @@ def api_list_portal_services( ) +@router.get("/postboxes", response_model=PortalPostboxListResponse) +def api_list_portal_postboxes( + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PortalPostboxListResponse: + if not has_scope(principal, READ_SCOPE): + raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}") + provider = postbox_portal_projection_provider(_registry) + if provider is None: + return PortalPostboxListResponse( + provider_available=False, + postboxes=[], + ) + entries = provider.list_portal_entries( + session, + principal, + tenant_id=principal.tenant_id, + limit=limit, + ) + return PortalPostboxListResponse( + provider_available=True, + postboxes=[asdict(entry) for entry in entries], + ) + + @router.post( "/services/{service_id}/launch", response_model=PortalServiceLaunchResponse, diff --git a/src/govoplan_portal/backend/schemas.py b/src/govoplan_portal/backend/schemas.py index f707e7f..fed28a7 100644 --- a/src/govoplan_portal/backend/schemas.py +++ b/src/govoplan_portal/backend/schemas.py @@ -22,6 +22,22 @@ class PortalServiceListResponse(BaseModel): services: list[PortalServiceEntryResponse] +class PortalPostboxEntryResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + postbox: dict[str, Any] + unread_count: int = Field(default=0, ge=0) + latest_message_at: datetime | None = None + route_path: str + + +class PortalPostboxListResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider_available: bool + postboxes: list[PortalPostboxEntryResponse] = Field(default_factory=list) + + class PortalServiceLaunchRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -49,4 +65,6 @@ __all__ = [ "PortalServiceLaunchRequest", "PortalServiceLaunchResponse", "PortalServiceListResponse", + "PortalPostboxEntryResponse", + "PortalPostboxListResponse", ] diff --git a/tests/test_service_directory.py b/tests/test_service_directory.py index 43c556a..dd81cef 100644 --- a/tests/test_service_directory.py +++ b/tests/test_service_directory.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import replace from datetime import UTC, datetime, timedelta import unittest +from types import SimpleNamespace +from unittest.mock import patch from govoplan_core.core.institutional import ( CAPABILITY_SERVICE_AVAILABILITY, @@ -23,7 +25,10 @@ from govoplan_core.core.access import ( FunctionRef, PrincipalRef, ) +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxPortalEntryRef from govoplan_portal.backend.manifest import get_manifest +from govoplan_portal.backend.router import api_list_portal_postboxes from govoplan_portal.backend.service_directory import ( PortalServiceDirectory, principal_audiences, @@ -189,6 +194,47 @@ class SemanticDirectory: class PortalServiceDirectoryTests(unittest.TestCase): + def test_portal_postbox_endpoint_projects_optional_provider_entries(self) -> None: + principal = ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset({"portal:service:read"}), + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="membership-1"), + ) + entry = PostboxPortalEntryRef( + postbox=PostboxDirectoryEntryRef( + id="postbox-1", + tenant_id="tenant-1", + address="clerk.district", + address_key="clerk.district", + name="District / Clerk", + status="active", + classification="internal", + ), + unread_count=3, + ) + provider = SimpleNamespace( + list_portal_entries=lambda *_args, **_kwargs: (entry,) + ) + + with patch( + "govoplan_portal.backend.router.postbox_portal_projection_provider", + return_value=provider, + ): + response = api_list_portal_postboxes( + limit=100, + session=object(), + principal=principal, + ) + + self.assertTrue(response.provider_available) + self.assertEqual("postbox-1", response.postboxes[0].postbox["id"]) + self.assertEqual(3, response.postboxes[0].unread_count) + def test_provider_definition_is_available_when_requirements_exist(self) -> None: entries = PortalServiceDirectory(Registry(intake=True)).list_entries( None, diff --git a/webui/src/api/portal.ts b/webui/src/api/portal.ts index 46a9576..7a67f3b 100644 --- a/webui/src/api/portal.ts +++ b/webui/src/api/portal.ts @@ -35,6 +35,25 @@ export type PortalServiceListResponse = { services: PortalServiceEntry[]; }; +export type PortalPostboxEntry = { + postbox: { + id: string; + name: string; + address: string; + organization_unit_name?: string | null; + function_name?: string | null; + classification: string; + }; + unread_count: number; + latest_message_at?: string | null; + route_path: string; +}; + +export type PortalPostboxListResponse = { + provider_available: boolean; + postboxes: PortalPostboxEntry[]; +}; + export type PortalServiceLaunchResult = { service_ref: PortalServiceDefinition["reference"]; binding: PortalServiceBinding; @@ -66,6 +85,17 @@ export function listPortalServices( ); } +export function listPortalPostboxes( + settings: ApiSettings, + signal?: AbortSignal +): Promise { + return apiFetch( + settings, + "/api/v1/portal/postboxes", + { signal } + ); +} + export function launchPortalService( settings: ApiSettings, serviceId: string, diff --git a/webui/src/features/portal/PortalPage.tsx b/webui/src/features/portal/PortalPage.tsx index f1c6678..38d684f 100644 --- a/webui/src/features/portal/PortalPage.tsx +++ b/webui/src/features/portal/PortalPage.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, Search } from "lucide-react"; +import { ArrowUpRight, Inbox, Search } from "lucide-react"; import { useEffect, useMemo, @@ -20,7 +20,9 @@ import { } from "@govoplan/core-webui"; import { launchPortalService, + listPortalPostboxes, listPortalServices, + type PortalPostboxEntry, type PortalServiceEntry } from "../../api/portal"; @@ -31,6 +33,7 @@ export default function PortalPage({ settings }: PlatformRouteContext) { const [submittedQuery, setSubmittedQuery] = useState(""); const [includeUnavailable, setIncludeUnavailable] = useState(true); const [services, setServices] = useState([]); + const [postboxes, setPostboxes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [launchingId, setLaunchingId] = useState(""); @@ -62,6 +65,18 @@ export default function PortalPage({ settings }: PlatformRouteContext) { return () => controller.abort(); }, [includeUnavailable, settings, submittedQuery]); + useEffect(() => { + const controller = new AbortController(); + listPortalPostboxes(settings, controller.signal) + .then((response) => setPostboxes(response.postboxes)) + .catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Postboxes could not be loaded."); + } + }); + return () => controller.abort(); + }, [settings]); + const counts = useMemo(() => ({ available: services.filter((entry) => entry.state === "available").length, unavailable: services.filter((entry) => entry.state === "unavailable").length @@ -146,6 +161,37 @@ export default function PortalPage({ settings }: PlatformRouteContext) { } {loading && } + {postboxes.length > 0 && ( +
+
+
+
+ {postboxes.map((entry) => ( + + ))} +
+
+ )} {!loading && !error && services.length === 0 &&
No matching services.
} diff --git a/webui/src/styles/portal.css b/webui/src/styles/portal.css index 92c111c..104f77b 100644 --- a/webui/src/styles/portal.css +++ b/webui/src/styles/portal.css @@ -56,6 +56,73 @@ gap: 12px; } +.portal-postboxes { + margin-bottom: 18px; +} + +.portal-section-heading { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.portal-section-heading h2 { + margin: 0; + font-size: 1rem; + letter-spacing: 0; +} + +.portal-postbox-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); + gap: 8px; +} + +.portal-postbox-entry { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + min-height: 56px; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-raised); + color: var(--text); + text-align: left; + cursor: pointer; +} + +.portal-postbox-entry:hover, +.portal-postbox-entry:focus-visible { + border-color: var(--accent); + background: var(--surface-hover); +} + +.portal-postbox-entry > span:first-child { + display: flex; + min-width: 0; + flex-direction: column; +} + +.portal-postbox-entry small { + overflow: hidden; + color: var(--text-soft); + text-overflow: ellipsis; + white-space: nowrap; +} + +.portal-postbox-count { + min-width: 24px; + padding: 2px 5px; + border-radius: 10px; + background: var(--accent); + color: var(--on-accent); + font-size: 0.75rem; + text-align: center; +} + .portal-service-entry { display: flex; flex-direction: column;