feat: project role-bound postboxes

This commit is contained in:
2026-08-07 14:53:52 +02:00
parent 1bc367a454
commit fea8e4b5e6
7 changed files with 274 additions and 1 deletions
+37
View File
@@ -5,6 +5,7 @@ from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_DEFINITIONS, CAPABILITY_SERVICE_DEFINITIONS,
service_launch_capability, service_launch_capability,
) )
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_PORTAL
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationLink, DocumentationLink,
@@ -61,11 +62,13 @@ manifest = ModuleManifest(
"forms", "forms",
"forms_runtime", "forms_runtime",
"workflow_engine", "workflow_engine",
"postbox",
), ),
optional_capabilities=( optional_capabilities=(
CAPABILITY_SERVICE_DEFINITIONS, CAPABILITY_SERVICE_DEFINITIONS,
CAPABILITY_SERVICE_AVAILABILITY, CAPABILITY_SERVICE_AVAILABILITY,
*SERVICE_LAUNCH_CAPABILITIES, *SERVICE_LAUNCH_CAPABILITIES,
CAPABILITY_POSTBOX_PORTAL,
), ),
permissions=( permissions=(
PermissionDefinition( PermissionDefinition(
@@ -103,6 +106,12 @@ manifest = ModuleManifest(
) )
for capability in SERVICE_LAUNCH_CAPABILITIES 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_factories={
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory, CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
@@ -162,6 +171,34 @@ manifest = ModuleManifest(
), ),
}, },
documentation=( 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( DocumentationTopic(
id="portal.service-directory", id="portal.service-directory",
title="Service directory", title="Service directory",
+29
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,11 +11,13 @@ from govoplan_core.core.institutional import (
InstitutionalContextError, InstitutionalContextError,
InstitutionalReference, InstitutionalReference,
) )
from govoplan_core.core.postbox import postbox_portal_projection_provider
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_portal.backend.schemas import ( from govoplan_portal.backend.schemas import (
PortalServiceLaunchRequest, PortalServiceLaunchRequest,
PortalServiceLaunchResponse, PortalServiceLaunchResponse,
PortalServiceListResponse, PortalServiceListResponse,
PortalPostboxListResponse,
) )
from govoplan_portal.backend.service_directory import ( from govoplan_portal.backend.service_directory import (
PortalServiceDirectory, 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( @router.post(
"/services/{service_id}/launch", "/services/{service_id}/launch",
response_model=PortalServiceLaunchResponse, response_model=PortalServiceLaunchResponse,
+18
View File
@@ -22,6 +22,22 @@ class PortalServiceListResponse(BaseModel):
services: list[PortalServiceEntryResponse] 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): class PortalServiceLaunchRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -49,4 +65,6 @@ __all__ = [
"PortalServiceLaunchRequest", "PortalServiceLaunchRequest",
"PortalServiceLaunchResponse", "PortalServiceLaunchResponse",
"PortalServiceListResponse", "PortalServiceListResponse",
"PortalPostboxEntryResponse",
"PortalPostboxListResponse",
] ]
+46
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import replace from dataclasses import replace
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.core.institutional import ( from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_AVAILABILITY, CAPABILITY_SERVICE_AVAILABILITY,
@@ -23,7 +25,10 @@ from govoplan_core.core.access import (
FunctionRef, FunctionRef,
PrincipalRef, 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.manifest import get_manifest
from govoplan_portal.backend.router import api_list_portal_postboxes
from govoplan_portal.backend.service_directory import ( from govoplan_portal.backend.service_directory import (
PortalServiceDirectory, PortalServiceDirectory,
principal_audiences, principal_audiences,
@@ -189,6 +194,47 @@ class SemanticDirectory:
class PortalServiceDirectoryTests(unittest.TestCase): 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: def test_provider_definition_is_available_when_requirements_exist(self) -> None:
entries = PortalServiceDirectory(Registry(intake=True)).list_entries( entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
None, None,
+30
View File
@@ -35,6 +35,25 @@ export type PortalServiceListResponse = {
services: PortalServiceEntry[]; 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 = { export type PortalServiceLaunchResult = {
service_ref: PortalServiceDefinition["reference"]; service_ref: PortalServiceDefinition["reference"];
binding: PortalServiceBinding; binding: PortalServiceBinding;
@@ -66,6 +85,17 @@ export function listPortalServices(
); );
} }
export function listPortalPostboxes(
settings: ApiSettings,
signal?: AbortSignal
): Promise<PortalPostboxListResponse> {
return apiFetch<PortalPostboxListResponse>(
settings,
"/api/v1/portal/postboxes",
{ signal }
);
}
export function launchPortalService( export function launchPortalService(
settings: ApiSettings, settings: ApiSettings,
serviceId: string, serviceId: string,
+47 -1
View File
@@ -1,4 +1,4 @@
import { ArrowUpRight, Search } from "lucide-react"; import { ArrowUpRight, Inbox, Search } from "lucide-react";
import { import {
useEffect, useEffect,
useMemo, useMemo,
@@ -20,7 +20,9 @@ import {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
launchPortalService, launchPortalService,
listPortalPostboxes,
listPortalServices, listPortalServices,
type PortalPostboxEntry,
type PortalServiceEntry type PortalServiceEntry
} from "../../api/portal"; } from "../../api/portal";
@@ -31,6 +33,7 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
const [submittedQuery, setSubmittedQuery] = useState(""); const [submittedQuery, setSubmittedQuery] = useState("");
const [includeUnavailable, setIncludeUnavailable] = useState(true); const [includeUnavailable, setIncludeUnavailable] = useState(true);
const [services, setServices] = useState<PortalServiceEntry[]>([]); const [services, setServices] = useState<PortalServiceEntry[]>([]);
const [postboxes, setPostboxes] = useState<PortalPostboxEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [launchingId, setLaunchingId] = useState(""); const [launchingId, setLaunchingId] = useState("");
@@ -62,6 +65,18 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
return () => controller.abort(); return () => controller.abort();
}, [includeUnavailable, settings, submittedQuery]); }, [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(() => ({ const counts = useMemo(() => ({
available: services.filter((entry) => entry.state === "available").length, available: services.filter((entry) => entry.state === "available").length,
unavailable: services.filter((entry) => entry.state === "unavailable").length unavailable: services.filter((entry) => entry.state === "unavailable").length
@@ -146,6 +161,37 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
</DismissibleAlert> </DismissibleAlert>
} }
{loading && <LoadingIndicator label="Loading services" />} {loading && <LoadingIndicator label="Loading services" />}
{postboxes.length > 0 && (
<section className="portal-postboxes" aria-labelledby="portal-postboxes-heading">
<div className="portal-section-heading">
<Inbox size={18} aria-hidden="true" />
<h2 id="portal-postboxes-heading">My function postboxes</h2>
</div>
<div className="portal-postbox-list">
{postboxes.map((entry) => (
<button
key={entry.postbox.id}
type="button"
className="portal-postbox-entry"
onClick={() => navigate(entry.route_path)}
>
<span>
<strong>{entry.postbox.name}</strong>
<small>
{[entry.postbox.organization_unit_name, entry.postbox.function_name]
.filter(Boolean)
.join(" / ") || entry.postbox.address}
</small>
</span>
<span className="portal-postbox-count" aria-label={`${entry.unread_count} unread messages`}>
{entry.unread_count > 99 ? "99+" : entry.unread_count}
</span>
<ArrowUpRight size={15} aria-hidden="true" />
</button>
))}
</div>
</section>
)}
{!loading && !error && services.length === 0 && {!loading && !error && services.length === 0 &&
<div className="portal-empty">No matching services.</div> <div className="portal-empty">No matching services.</div>
} }
+67
View File
@@ -56,6 +56,73 @@
gap: 12px; 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 { .portal-service-entry {
display: flex; display: flex;
flex-direction: column; flex-direction: column;