feat: route bounded hierarchy postbox copies

This commit is contained in:
2026-07-30 04:00:31 +02:00
parent d848a9a503
commit 28b60782de
16 changed files with 2715 additions and 17 deletions
+11 -1
View File
@@ -80,7 +80,17 @@ addresses, exact function-bound Postboxes, current IDM assignment access
decisions, vacancy status, idempotent producer delivery, source-preserving
message and attachment references, personal read/acknowledgement receipts,
unified inbox projections, access evidence, an inbox route, and tenant
administration.
administration. Published template revisions can also opt into bounded linked
copies through one explicit organization structure. Classification, producer,
retention, stop, depth, target-template, and target-function gates are frozen
at delivery time and exposed through delivery evidence and the routing dry-run
API.
Vacancy escalation is a separate attention policy. It creates no personal
account grant: a durable route waits for its configured delay and then creates
an independently readable copy in the next frozen function Postbox. The
`govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when
Celery beat and a worker consuming the `postbox` queue are enabled.
The persistence model reserves ciphertext manifests, wrapped keys, key epochs,
expiry, and withdrawal state. The active profile remains `plaintext_v1`; the
+18
View File
@@ -176,6 +176,24 @@ Vacancy is a visible delivery/attention state rather than an automatic grant
to an unrelated personal account. Policy may trigger a bounded escalation
after a delay.
The implemented policy keeps the three semantics separate:
- `linked_copy` can target the nearest matching ancestor or every bounded
matching ancestor in one selected structure.
- `attention` currently supports delayed vacancy escalation over the remaining
delivery-time target snapshot.
- `shared_visibility` remains explicitly disabled until its access and
encryption semantics are implemented.
Routing is off unless an immutable template revision enables it and supplies a
target template, target function type, producer allowlist, classification
allowlist, depth, and structure. Optional relation, stop-unit, stop-unit-type,
expiry, and maximum-retention gates narrow the route further. The dry-run API
returns blocked and unavailable candidates without materializing addresses.
Delivery materializes only frozen candidates, stores path-edge provenance, and
creates source-preserving copies with independent read and acknowledgement
receipts.
## Campaign Distribution
Campaign can use Postbox as an explicit delivery channel through
+20
View File
@@ -641,8 +641,20 @@ class PostboxDelivery(Base, TimestampMixin):
class PostboxRoute(Base, TimestampMixin):
__tablename__ = "postbox_routes"
__table_args__ = (
UniqueConstraint(
"delivery_id",
"target_postbox_id",
"route_kind",
"depth",
name="uq_postbox_routes_delivery_target_kind_depth",
),
Index("ix_postbox_routes_delivery_kind", "delivery_id", "route_kind"),
Index("ix_postbox_routes_target", "tenant_id", "target_postbox_id"),
Index(
"ix_postbox_routes_due",
"status",
"execute_after",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
@@ -676,6 +688,14 @@ class PostboxRoute(Base, TimestampMixin):
ForeignKey("postbox_routes.id", ondelete="SET NULL"),
nullable=True,
)
execute_after: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
policy_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
@@ -0,0 +1,348 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
from govoplan_core.core.idm import (
IdmFunctionAssignmentDirectory,
OrganizationFunctionAssignmentRef,
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationHierarchyDirectory,
OrganizationHierarchyEdgeRef,
OrganizationUnitRef,
)
from govoplan_postbox.backend.schemas import PostboxRoutingPolicyPayload
@dataclass(frozen=True, slots=True)
class HierarchyRouteCandidate:
depth: int
unit: OrganizationUnitRef
function: OrganizationFunctionRef | None
holders: tuple[OrganizationFunctionAssignmentRef, ...]
status: str
path: tuple[Mapping[str, object], ...]
diagnostics: tuple[str, ...] = ()
@property
def holder_count(self) -> int:
return len(
{
holder.identity_id or holder.account_id or holder.id
for holder in self.holders
}
)
@dataclass(frozen=True, slots=True)
class HierarchyRoutePlan:
status: str
policy: Mapping[str, object]
candidates: tuple[HierarchyRouteCandidate, ...] = ()
diagnostics: tuple[str, ...] = ()
def normalized_routing_policy(
value: Mapping[str, object] | None,
) -> dict[str, object]:
return PostboxRoutingPolicyPayload.model_validate(value or {}).model_dump(
mode="json"
)
def plan_hierarchy_routes(
*,
hierarchy: OrganizationHierarchyDirectory | None,
incumbencies: IdmFunctionAssignmentDirectory,
tenant_id: str,
source_unit_id: str | None,
routing_policy: Mapping[str, object] | None,
producer_module: str,
classification: str,
expires_at: datetime | None,
now: datetime,
) -> HierarchyRoutePlan:
policy = normalized_routing_policy(routing_policy)
linked_copy = _mapping(policy.get("linked_copy"))
if not linked_copy.get("enabled"):
return HierarchyRoutePlan(
status="disabled",
policy=policy,
diagnostics=("hierarchy_routing_disabled",),
)
if hierarchy is None:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("organization_hierarchy_unavailable",),
)
if not source_unit_id:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("source_organization_unit_missing",),
)
diagnostics = list(
_policy_gate_diagnostics(
linked_copy,
producer_module=producer_module,
classification=classification,
expires_at=expires_at,
now=now,
)
)
if diagnostics:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(diagnostics),
)
structure_id = str(linked_copy["structure_id"])
relation_type_ids = tuple(
str(value) for value in linked_copy.get("relation_type_ids", ())
)
max_depth = int(linked_copy["max_depth"])
try:
resolutions = hierarchy.resolve_hierarchy_relatives(
tenant_id,
(source_unit_id,),
structure_id=structure_id,
relation_type_ids=relation_type_ids,
direction="ancestors",
max_depth=max_depth,
)
except ValueError as exc:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=(f"hierarchy_request_invalid:{exc}",),
)
if not resolutions:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("hierarchy_resolution_missing",),
)
resolution = resolutions[0]
diagnostics.extend(resolution.diagnostics)
if resolution.cycle_detected:
diagnostics.append("hierarchy_cycle_bounded")
if resolution.depth_limited:
diagnostics.append("hierarchy_depth_limited")
if resolution.status != "active":
diagnostics.append(f"hierarchy_{resolution.status}")
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(dict.fromkeys(diagnostics)),
)
matches = []
stop_unit_id = linked_copy.get("stop_unit_id")
stop_unit_type_id = linked_copy.get("stop_unit_type_id")
for match in sorted(
resolution.matches,
key=lambda item: (item.depth, item.unit.name, item.unit.id),
):
matches.append(match)
if (
stop_unit_id
and match.unit.id == stop_unit_id
or stop_unit_type_id
and match.unit.unit_type_id == stop_unit_type_id
):
diagnostics.append("hierarchy_stop_reached")
break
if not matches:
return HierarchyRoutePlan(
status="no_route",
policy=policy,
diagnostics=tuple(
dict.fromkeys((*diagnostics, "no_hierarchy_ancestor"))
),
)
unit_ids = tuple(dict.fromkeys(match.unit.id for match in matches))
target_function_type_id = str(linked_copy["target_function_type_id"])
try:
function_resolution = hierarchy.resolve_functions_by_type(
tenant_id,
target_function_type_id,
organization_unit_ids=unit_ids,
)
except ValueError as exc:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(
dict.fromkeys(
(*diagnostics, f"function_resolution_invalid:{exc}")
)
),
)
diagnostics.extend(function_resolution.diagnostics)
if function_resolution.status != "active":
diagnostics.append(
f"target_function_type_{function_resolution.status}"
)
functions_by_unit: dict[str, list[OrganizationFunctionRef]] = {}
for function in function_resolution.matches:
if function.status != "active":
continue
functions_by_unit.setdefault(
function.organization_unit_id,
[],
).append(function)
function_ids = tuple(
function.id
for functions in functions_by_unit.values()
if len(functions) == 1
for function in functions
)
try:
holder_map = (
incumbencies.organization_function_incumbencies(
function_ids,
tenant_id=tenant_id,
)
if function_ids
else {}
)
except ValueError:
holder_map = {}
diagnostics.append("target_incumbency_unavailable")
candidates: list[HierarchyRouteCandidate] = []
seen_targets: set[tuple[str, str]] = set()
for match in matches:
functions = functions_by_unit.get(match.unit.id, [])
candidate_diagnostics: list[str] = []
function = functions[0] if len(functions) == 1 else None
if match.unit.status != "active":
status = "unit_inactive"
candidate_diagnostics.append("target_unit_inactive")
elif not functions:
status = "function_missing"
candidate_diagnostics.append("target_function_missing")
elif len(functions) > 1:
status = "function_ambiguous"
candidate_diagnostics.append("target_function_ambiguous")
elif (match.unit.id, function.id) in seen_targets:
status = "duplicate"
candidate_diagnostics.append("duplicate_target_suppressed")
else:
seen_targets.add((match.unit.id, function.id))
holders = tuple(
holder_map.get(function.id).assignments
if function.id in holder_map
else ()
)
status = "available" if holders else "vacant"
candidates.append(
HierarchyRouteCandidate(
depth=match.depth,
unit=match.unit,
function=function,
holders=holders,
status=status,
path=tuple(_edge_snapshot(edge) for edge in match.path),
)
)
continue
candidates.append(
HierarchyRouteCandidate(
depth=match.depth,
unit=match.unit,
function=function,
holders=(),
status=status,
path=tuple(_edge_snapshot(edge) for edge in match.path),
diagnostics=tuple(candidate_diagnostics),
)
)
routable = [
candidate
for candidate in candidates
if candidate.status in {"available", "vacant"}
]
status = "planned" if routable else "no_route"
if not routable:
diagnostics.append("no_available_hierarchy_target")
return HierarchyRoutePlan(
status=status,
policy=policy,
candidates=tuple(candidates),
diagnostics=tuple(dict.fromkeys(diagnostics)),
)
def _policy_gate_diagnostics(
linked_copy: Mapping[str, object],
*,
producer_module: str,
classification: str,
expires_at: datetime | None,
now: datetime,
) -> tuple[str, ...]:
diagnostics: list[str] = []
classifications = {
str(value) for value in linked_copy.get("allowed_classifications", ())
}
if classification not in classifications:
diagnostics.append("classification_not_allowed")
producers = {
str(value) for value in linked_copy.get("allowed_producer_modules", ())
}
if producer_module not in producers and "*" not in producers:
diagnostics.append("producer_not_authorized")
normalized_expiry = _as_utc(expires_at) if expires_at else None
if linked_copy.get("require_expiry") and normalized_expiry is None:
diagnostics.append("expiry_required")
max_retention_days = linked_copy.get("max_retention_days")
if (
normalized_expiry is not None
and max_retention_days is not None
and normalized_expiry
> _as_utc(now) + timedelta(days=int(max_retention_days))
):
diagnostics.append("retention_limit_exceeded")
if normalized_expiry is not None and normalized_expiry <= _as_utc(now):
diagnostics.append("message_already_expired")
return tuple(diagnostics)
def _edge_snapshot(edge: OrganizationHierarchyEdgeRef) -> dict[str, object]:
return {
"edge_id": edge.id,
"structure_id": edge.structure.id,
"structure_slug": edge.structure.slug,
"relation_type_id": edge.relation_type.id,
"relation_type_slug": edge.relation_type.slug,
"source_unit_id": edge.source_unit_id,
"target_unit_id": edge.target_unit_id,
}
def _mapping(value: object) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"HierarchyRouteCandidate",
"HierarchyRoutePlan",
"normalized_routing_policy",
"plan_hierarchy_routes",
]
+13 -1
View File
@@ -29,13 +29,17 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_ROUTING,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
@@ -201,6 +205,7 @@ manifest = ModuleManifest(
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
),
provides_interfaces=tuple(
ModuleInterfaceProvider(name=name, version=MODULE_VERSION)
@@ -210,6 +215,7 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING,
)
),
requires_interfaces=(
@@ -218,6 +224,11 @@ manifest = ModuleManifest(
version_min="0.1.8",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name="organizations.hierarchy_directory",
version_min="0.1.0",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
version_min="0.1.8",
@@ -318,6 +329,7 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_MESSAGES: _configure,
CAPABILITY_POSTBOX_DELIVERY: _configure,
CAPABILITY_POSTBOX_EVIDENCE: _configure,
CAPABILITY_POSTBOX_ROUTING: _configure,
},
documentation=(
DocumentationTopic(
@@ -0,0 +1,54 @@
"""v0.1.1 durable hierarchy routes
Revision ID: e4b7c9d2a6f1
Revises: c7d2e5f8a1b4
Create Date: 2026-07-30 04:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e4b7c9d2a6f1"
down_revision = "c7d2e5f8a1b4"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_routes") as batch_op:
batch_op.add_column(
sa.Column(
"execute_after",
sa.DateTime(timezone=True),
nullable=True,
)
)
batch_op.add_column(
sa.Column(
"processed_at",
sa.DateTime(timezone=True),
nullable=True,
)
)
batch_op.create_unique_constraint(
"uq_postbox_routes_delivery_target_kind_depth",
("delivery_id", "target_postbox_id", "route_kind", "depth"),
)
batch_op.create_index(
"ix_postbox_routes_due",
("status", "execute_after"),
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_routes") as batch_op:
batch_op.drop_index("ix_postbox_routes_due")
batch_op.drop_constraint(
"uq_postbox_routes_delivery_target_kind_depth",
type_="unique",
)
batch_op.drop_column("processed_at")
batch_op.drop_column("execute_after")
+32 -1
View File
@@ -39,6 +39,8 @@ from govoplan_postbox.backend.schemas import (
PostboxMessageListResponse,
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
@@ -395,6 +397,30 @@ def api_deliver_to_postbox(
return PostboxDeliveryResponse.model_validate(asdict(result))
@router.post(
"/routing/dry-run",
response_model=PostboxRouteDryRunResponse,
)
def api_preview_postbox_routing(
payload: PostboxRouteDryRunRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxRouteDryRunResponse:
_require_any(principal, DELIVERY_SCOPE, TEMPLATE_ADMIN_SCOPE)
try:
result = get_service().preview_hierarchy_routes(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(**payload.target.model_dump()),
producer_module=payload.producer_module,
classification=payload.classification,
expires_at=payload.expires_at,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxRouteDryRunResponse.model_validate(result)
@router.get("/groupings", response_model=PostboxGroupingListResponse)
def api_list_postbox_groupings(
session: Session = Depends(get_session),
@@ -502,7 +528,12 @@ def api_postbox_organization_targets(
return PostboxOrganizationTargetsResponse(
units=list(
get_service().organization_targets(tenant_id=principal.tenant_id)
)
),
structures=list(
get_service().organization_hierarchy_targets(
tenant_id=principal.tenant_id
)
),
)
+164 -1
View File
@@ -150,6 +150,144 @@ class PostboxDeliveryResponse(BaseModel):
evidence: dict[str, Any] = Field(default_factory=dict)
class PostboxLinkedCopyPolicyPayload(BaseModel):
enabled: bool = False
structure_id: str | None = Field(default=None, max_length=36)
relation_type_ids: list[str] = Field(default_factory=list, max_length=20)
max_depth: int = Field(default=1, ge=1, le=20)
stop_unit_id: str | None = Field(default=None, max_length=36)
stop_unit_type_id: str | None = Field(default=None, max_length=36)
target_function_type_id: str | None = Field(default=None, max_length=36)
target_template_id: str | None = Field(default=None, max_length=36)
fanout: Literal["nearest", "all"] = "nearest"
allowed_classifications: list[str] = Field(
default_factory=lambda: ["internal"],
max_length=20,
)
allowed_producer_modules: list[str] = Field(
default_factory=list,
max_length=50,
)
require_expiry: bool = False
max_retention_days: int | None = Field(default=None, ge=1, le=36500)
@model_validator(mode="after")
def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload":
self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids))
self.allowed_classifications = list(
dict.fromkeys(
value.strip() for value in self.allowed_classifications
)
)
self.allowed_producer_modules = list(
dict.fromkeys(
value.strip() for value in self.allowed_producer_modules
)
)
if any(not value for value in self.relation_type_ids):
raise ValueError("Relation type IDs must not be empty.")
if any(not value for value in self.allowed_classifications):
raise ValueError("Allowed classifications must not be empty.")
if any(not value for value in self.allowed_producer_modules):
raise ValueError("Allowed producer modules must not be empty.")
if self.enabled and not all(
(
self.structure_id,
self.target_function_type_id,
self.target_template_id,
self.allowed_classifications,
self.allowed_producer_modules,
)
):
raise ValueError(
"Enabled hierarchy copy requires a structure, target function "
"type, target template, classification gate, and producer allowlist."
)
return self
class PostboxAttentionPolicyPayload(BaseModel):
mode: Literal["none", "vacancy_escalation"] = "none"
delay_minutes: int | None = Field(default=None, ge=1, le=43200)
@model_validator(mode="after")
def validate_delay(self) -> "PostboxAttentionPolicyPayload":
if self.mode == "vacancy_escalation" and self.delay_minutes is None:
raise ValueError("Vacancy escalation requires a delay.")
if self.mode == "none":
self.delay_minutes = None
return self
class PostboxSharedVisibilityPolicyPayload(BaseModel):
mode: Literal["none"] = "none"
class PostboxRoutingPolicyPayload(BaseModel):
linked_copy: PostboxLinkedCopyPolicyPayload = Field(
default_factory=PostboxLinkedCopyPolicyPayload
)
attention: PostboxAttentionPolicyPayload = Field(
default_factory=PostboxAttentionPolicyPayload
)
shared_visibility: PostboxSharedVisibilityPolicyPayload = Field(
default_factory=PostboxSharedVisibilityPolicyPayload
)
@model_validator(mode="before")
@classmethod
def normalize_legacy_policy(cls, value: Any) -> Any:
if value in (None, {}, {"mode": "none"}):
return {}
return value
@model_validator(mode="after")
def validate_semantics(self) -> "PostboxRoutingPolicyPayload":
if (
self.attention.mode == "vacancy_escalation"
and (
not self.linked_copy.enabled
or self.linked_copy.fanout != "nearest"
)
):
raise ValueError(
"Vacancy escalation requires nearest linked-copy routing."
)
return self
class PostboxRoutePreviewTarget(BaseModel):
depth: int
organization_unit_id: str
organization_unit_name: str
function_id: str | None = None
function_name: str | None = None
target_postbox_id: str | None = None
target_address: str | None = None
status: str
vacant: bool = True
holder_count: int = 0
path: list[dict[str, Any]] = Field(default_factory=list)
diagnostics: list[str] = Field(default_factory=list)
class PostboxRouteDryRunRequest(BaseModel):
target: PostboxTargetPayload
producer_module: str = Field(min_length=1, max_length=100)
classification: str = Field(default="internal", min_length=1, max_length=50)
expires_at: datetime | None = None
class PostboxRouteDryRunResponse(BaseModel):
status: str
source_postbox_id: str | None = None
policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
routes: list[PostboxRoutePreviewTarget] = Field(default_factory=list)
diagnostics: list[str] = Field(default_factory=list)
class PostboxExactCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=500)
description: str | None = None
@@ -175,6 +313,9 @@ class PostboxTemplateRevisionPayload(BaseModel):
)
classification: str = Field(default="internal", min_length=1, max_length=50)
allow_vacant_delivery: bool = True
routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
@@ -188,7 +329,6 @@ class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
revision: int
encryption_profile: str
history_policy: dict[str, Any] = Field(default_factory=dict)
routing_policy: dict[str, Any] = Field(default_factory=dict)
retention_policy: dict[str, Any] = Field(default_factory=dict)
published_at: datetime | None = None
created_at: datetime
@@ -240,8 +380,31 @@ class PostboxOrganizationUnitItem(BaseModel):
functions: list[PostboxOrganizationFunctionItem] = Field(default_factory=list)
class PostboxOrganizationRelationTypeItem(BaseModel):
id: str
slug: str
name: str
structure_id: str | None = None
is_hierarchical: bool = True
status: str = "active"
class PostboxOrganizationStructureItem(BaseModel):
id: str
slug: str
name: str
structure_kind: str
status: str = "active"
relation_types: list[PostboxOrganizationRelationTypeItem] = Field(
default_factory=list
)
class PostboxOrganizationTargetsResponse(BaseModel):
units: list[PostboxOrganizationUnitItem]
structures: list[PostboxOrganizationStructureItem] = Field(
default_factory=list
)
class PostboxGroupingPayload(BaseModel):
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,6 +8,7 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_ROUTING,
)
from govoplan_postbox.backend.manifest import get_manifest
@@ -28,6 +29,7 @@ class PostboxManifestTests(unittest.TestCase):
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING,
},
set(manifest.capability_factories),
)
+20
View File
@@ -14,14 +14,21 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions."
"c7d2e5f8a1b4_v010_postbox_baseline"
)
route_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"e4b7c9d2a6f1_v011_hierarchy_routes"
)
engine = create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
operations = Operations(MigrationContext.configure(connection))
original = migration.op
route_original = route_migration.op
migration.op = operations
route_migration.op = operations
try:
migration.upgrade()
route_migration.upgrade()
tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables)
@@ -43,6 +50,18 @@ class PostboxMigrationTests(unittest.TestCase):
"withdrawn_at",
}.issubset(message_columns)
)
route_columns = {
column["name"]
for column in inspect(connection).get_columns(
"postbox_routes"
)
}
self.assertTrue(
{"execute_after", "processed_at"}.issubset(
route_columns
)
)
route_migration.downgrade()
migration.downgrade()
self.assertFalse(
{
@@ -53,6 +72,7 @@ class PostboxMigrationTests(unittest.TestCase):
)
finally:
migration.op = original
route_migration.op = route_original
finally:
engine.dispose()
+17
View File
@@ -297,6 +297,23 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(200, unread.status_code, unread.text)
self.assertEqual(0, unread.json()["total"])
def test_routing_dry_run_explains_default_disabled_state(self) -> None:
response = self.client.post(
"/api/v1/postbox/routing/dry-run",
json={
"target": {"postbox_id": self.postbox_id},
"producer_module": "campaigns",
"classification": "internal",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("disabled", response.json()["status"])
self.assertEqual(
["hierarchy_routing_disabled"],
response.json()["diagnostics"],
)
if __name__ == "__main__":
unittest.main()
+630
View File
@@ -14,7 +14,17 @@ from govoplan_core.core.idm import (
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationFunctionTypeRef,
OrganizationFunctionTypeResolution,
OrganizationHierarchyCatalogRef,
OrganizationHierarchyEdgeRef,
OrganizationHierarchyMatchRef,
OrganizationHierarchyPathResolution,
OrganizationHierarchyResolution,
OrganizationRelationTypeRef,
OrganizationStructureRef,
OrganizationUnitRef,
OrganizationUnitTypeResolution,
)
from govoplan_core.core.postbox import (
PostboxActorRef,
@@ -173,6 +183,13 @@ class FakeOrganizationDirectory:
unit_type_id="desk",
parent_id="unit-1",
),
"unit-top": OrganizationUnitRef(
id="unit-top",
tenant_id="tenant-1",
slug="central-office",
name="Central Office",
unit_type_id="office",
),
}
self.functions = {
"function-1": OrganizationFunctionRef(
@@ -193,7 +210,47 @@ class FakeOrganizationDirectory:
function_type_id="case-clerk-type",
delegable=True,
),
"function-top": OrganizationFunctionRef(
id="function-top",
tenant_id="tenant-1",
organization_unit_id="unit-top",
slug="case-clerk",
name="Case Clerk",
function_type_id="case-clerk-type",
delegable=True,
),
}
self.structure = OrganizationStructureRef(
id="structure-1",
tenant_id="tenant-1",
slug="administrative",
name="Administrative hierarchy",
structure_kind="administrative",
)
self.parallel_structure = OrganizationStructureRef(
id="structure-2",
tenant_id="tenant-1",
slug="reporting",
name="Reporting hierarchy",
structure_kind="reporting",
)
self.relation_type = OrganizationRelationTypeRef(
id="relation-type-1",
tenant_id="tenant-1",
slug="reports-to",
name="Reports to",
structure_id=self.structure.id,
)
self.parallel_relation_type = OrganizationRelationTypeRef(
id="relation-type-2",
tenant_id="tenant-1",
slug="reports-to",
name="Reports to",
structure_id=self.parallel_structure.id,
)
self.last_hierarchy_request: dict[str, object] = {}
self.duplicate_parent_match = False
self.cycle_detected = False
def get_organization_unit(self, organization_unit_id: str):
return self.units.get(organization_unit_id)
@@ -218,6 +275,214 @@ class FakeOrganizationDirectory:
if function.organization_unit_id == organization_unit_id
)
def hierarchy_catalog(self, tenant_id: str):
if tenant_id != "tenant-1":
return OrganizationHierarchyCatalogRef(tenant_id=tenant_id)
return OrganizationHierarchyCatalogRef(
tenant_id=tenant_id,
structures=(self.structure, self.parallel_structure),
relation_types=(
self.relation_type,
self.parallel_relation_type,
),
)
def get_unit_type(self, tenant_id: str, unit_type_id: str):
del tenant_id, unit_type_id
return None
def get_function_type(self, tenant_id: str, function_type_id: str):
if tenant_id != "tenant-1" or function_type_id != "case-clerk-type":
return None
return OrganizationFunctionTypeRef(
id=function_type_id,
tenant_id=tenant_id,
slug="case-clerk",
name="Case Clerk",
)
def resolve_functions_by_type(
self,
tenant_id: str,
function_type_id: str,
*,
organization_unit_ids=(),
):
if tenant_id != "tenant-1":
return OrganizationFunctionTypeResolution(
tenant_id=tenant_id,
function_type_id=function_type_id,
requested_unit_ids=tuple(organization_unit_ids),
status="missing",
)
return OrganizationFunctionTypeResolution(
tenant_id=tenant_id,
function_type_id=function_type_id,
requested_unit_ids=tuple(organization_unit_ids),
status="active",
function_type=self.get_function_type(
tenant_id,
function_type_id,
),
matches=tuple(
function
for function in self.functions.values()
if function.function_type_id == function_type_id
and (
not organization_unit_ids
or function.organization_unit_id
in organization_unit_ids
)
),
)
def resolve_units_by_type(
self,
tenant_id: str,
unit_type_id: str,
**kwargs,
):
del kwargs
return OrganizationUnitTypeResolution(
tenant_id=tenant_id,
unit_type_id=unit_type_id,
status="active",
matches=tuple(
unit
for unit in self.units.values()
if unit.tenant_id == tenant_id
and unit.unit_type_id == unit_type_id
),
)
def resolve_hierarchy_relatives(
self,
tenant_id: str,
organization_unit_ids,
*,
structure_id: str,
relation_type_ids=(),
direction="ancestors",
max_depth=10,
):
self.last_hierarchy_request = {
"tenant_id": tenant_id,
"structure_id": structure_id,
"relation_type_ids": tuple(relation_type_ids),
"direction": direction,
"max_depth": max_depth,
}
results = []
for root_id in organization_unit_ids:
root = self.units.get(root_id)
if root is None or root.tenant_id != tenant_id:
results.append(
OrganizationHierarchyResolution(
tenant_id=tenant_id,
root_unit_id=root_id,
direction=direction,
structure_id=structure_id,
relation_type_ids=tuple(relation_type_ids),
max_depth=max_depth,
status="missing",
)
)
continue
if structure_id == self.parallel_structure.id:
matches = ()
else:
first_edge = OrganizationHierarchyEdgeRef(
id="edge-child-parent",
tenant_id=tenant_id,
structure=self.structure,
relation_type=self.relation_type,
source_unit_id=root_id,
target_unit_id="unit-1",
)
second_edge = OrganizationHierarchyEdgeRef(
id="edge-parent-top",
tenant_id=tenant_id,
structure=self.structure,
relation_type=self.relation_type,
source_unit_id="unit-1",
target_unit_id="unit-top",
)
available = (
OrganizationHierarchyMatchRef(
unit=self.units["unit-1"],
depth=1,
path=(first_edge,),
),
OrganizationHierarchyMatchRef(
unit=self.units["unit-top"],
depth=2,
path=(first_edge, second_edge),
),
)
if self.duplicate_parent_match:
duplicate_edge = OrganizationHierarchyEdgeRef(
id="edge-child-parent-duplicate",
tenant_id=tenant_id,
structure=self.structure,
relation_type=self.relation_type,
source_unit_id=root_id,
target_unit_id="unit-1",
)
available = (
available[0],
OrganizationHierarchyMatchRef(
unit=self.units["unit-1"],
depth=1,
path=(duplicate_edge,),
),
available[1],
)
matches = tuple(
match for match in available if match.depth <= max_depth
)
results.append(
OrganizationHierarchyResolution(
tenant_id=tenant_id,
root_unit_id=root_id,
direction=direction,
structure_id=structure_id,
relation_type_ids=tuple(relation_type_ids),
max_depth=max_depth,
status="active",
root=root,
matches=matches,
cycle_detected=self.cycle_detected,
depth_limited=(
structure_id == self.structure.id and max_depth < 2
),
)
)
return tuple(results)
def resolve_hierarchy_paths(
self,
tenant_id: str,
unit_pairs,
*,
structure_id: str,
relation_type_ids=(),
direction="descendants",
max_depth=10,
):
return tuple(
OrganizationHierarchyPathResolution(
tenant_id=tenant_id,
source_unit_id=source_id,
target_unit_id=target_id,
direction=direction,
structure_id=structure_id,
relation_type_ids=tuple(relation_type_ids),
max_depth=max_depth,
status="unreachable",
)
for source_id, target_id in unit_pairs
)
class FakeNotificationDispatch:
def __init__(self) -> None:
@@ -280,6 +545,117 @@ class PostboxServiceTests(unittest.TestCase):
actor_id="admin-1",
)
def _routing_policy(
self,
target_template_id: str,
*,
structure_id: str = "structure-1",
max_depth: int = 2,
producer_modules: tuple[str, ...] = ("campaigns",),
classifications: tuple[str, ...] = ("internal",),
vacancy_escalation: bool = False,
) -> dict[str, object]:
return {
"linked_copy": {
"enabled": True,
"structure_id": structure_id,
"relation_type_ids": ["relation-type-1"],
"max_depth": max_depth,
"target_function_type_id": "case-clerk-type",
"target_template_id": target_template_id,
"fanout": "nearest",
"allowed_classifications": list(classifications),
"allowed_producer_modules": list(producer_modules),
"require_expiry": True,
"max_retention_days": 30,
},
"attention": {
"mode": (
"vacancy_escalation"
if vacancy_escalation
else "none"
),
"delay_minutes": 1 if vacancy_escalation else None,
},
"shared_visibility": {"mode": "none"},
}
def _create_routing_source(
self,
session: Session,
*,
vacancy_escalation: bool = False,
structure_id: str = "structure-1",
max_depth: int = 2,
) -> tuple[PostboxService, Postbox, PostboxTemplate]:
service = PostboxService(
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
idm=self.idm, # type: ignore[arg-type]
incumbencies=self.idm, # type: ignore[arg-type]
organizations=self.organizations, # type: ignore[arg-type]
hierarchy=self.organizations, # type: ignore[arg-type]
)
target_template = service.create_template(
session,
tenant_id="tenant-1",
slug="hierarchy-target",
name="Hierarchy target",
description=None,
function_type_id="case-clerk-type",
scope_kind="tenant",
scope_id=None,
name_pattern="{unit_name} / {function_name} Routed",
address_pattern="routed.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
actor_id="admin-1",
)
service.publish_template(
session,
tenant_id="tenant-1",
template_id=target_template.id,
revision_number=None,
actor_id="admin-1",
)
source_template = service.create_template(
session,
tenant_id="tenant-1",
slug="hierarchy-source",
name="Hierarchy source",
description=None,
function_type_id="case-clerk-type",
scope_kind="tenant",
scope_id=None,
name_pattern="{unit_name} / {function_name} Source",
address_pattern="source.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
actor_id="admin-1",
routing_policy=self._routing_policy(
target_template.id,
vacancy_escalation=vacancy_escalation,
structure_id=structure_id,
max_depth=max_depth,
),
)
service.publish_template(
session,
tenant_id="tenant-1",
template_id=source_template.id,
revision_number=None,
actor_id="admin-1",
)
source = service.materialize_template(
session,
tenant_id="tenant-1",
template_id=source_template.id,
organization_unit_id="unit-child",
function_id="function-child",
context_key="case-42",
actor_id="admin-1",
)
return service, source, target_template
def test_access_follows_current_assignment_and_reports_vacancy(self) -> None:
with Session(self.engine) as session:
postbox = self._create_exact(session)
@@ -595,6 +971,260 @@ class PostboxServiceTests(unittest.TestCase):
[event.type for event in events],
)
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
self,
) -> None:
self.idm.assignments.append(self.assignment)
with Session(self.engine) as session:
service, source, _target_template = (
self._create_routing_source(session)
)
result = service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-1",
idempotency_key="routing-copy-1",
subject="Routed decision",
body_text="The decision is available.",
classification="internal",
expires_at=utc_now() + timedelta(days=7),
),
)
session.commit()
routes = session.query(PostboxRoute).all()
self.assertEqual(1, len(routes))
route = routes[0]
self.assertEqual("linked_copy", route.route_kind)
self.assertEqual("accepted", route.status)
self.assertNotEqual(result.message_id, route.target_message_id)
self.assertEqual(
["edge-child-parent"],
[
edge["edge_id"]
for edge in route.policy_snapshot["target"]["path"]
],
)
self.assertEqual(
route.id,
result.evidence["hierarchy_routes"][0]["route_id"],
)
service.mark_message(
session,
tenant_id="tenant-1",
message_id=route.target_message_id,
actor=self.actor,
state="read",
)
session.commit()
receipts = session.query(PostboxMessageReceipt).all()
self.assertEqual([route.target_message_id], [item.message_id for item in receipts])
def test_hierarchy_dry_run_explains_gates_depth_and_parallel_structure(
self,
) -> None:
with Session(self.engine) as session:
service, source, _target_template = (
self._create_routing_source(session, max_depth=1)
)
blocked = service.preview_hierarchy_routes(
session,
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="mail",
classification="restricted",
expires_at=None,
)
self.assertEqual("blocked", blocked["status"])
self.assertTrue(
{
"classification_not_allowed",
"producer_not_authorized",
"expiry_required",
}.issubset(set(blocked["diagnostics"]))
)
planned = service.preview_hierarchy_routes(
session,
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="campaigns",
classification="internal",
expires_at=utc_now() + timedelta(days=7),
)
self.assertEqual("planned", planned["status"])
self.assertEqual(1, len(planned["routes"]))
self.assertEqual(
1,
self.organizations.last_hierarchy_request["max_depth"],
)
self.assertIn(
"hierarchy_depth_limited",
planned["diagnostics"],
)
with Session(self.engine) as session:
service, source, _target_template = self._create_routing_source(
session,
structure_id="structure-2",
)
no_route = service.preview_hierarchy_routes(
session,
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="campaigns",
classification="internal",
expires_at=utc_now() + timedelta(days=7),
)
self.assertEqual("no_route", no_route["status"])
self.assertIn("no_hierarchy_ancestor", no_route["diagnostics"])
def test_hierarchy_preview_bounds_cycles_deduplicates_and_is_tenant_safe(
self,
) -> None:
self.organizations.duplicate_parent_match = True
self.organizations.cycle_detected = True
with Session(self.engine) as session:
service, source, _target_template = (
self._create_routing_source(session)
)
preview = service.preview_hierarchy_routes(
session,
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="campaigns",
classification="internal",
expires_at=utc_now() + timedelta(days=7),
)
self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"])
self.assertEqual(
1,
sum(
route["status"] == "duplicate"
for route in preview["routes"]
),
)
with self.assertRaisesRegex(
ValueError,
"existing source Postbox",
):
service.preview_hierarchy_routes(
session,
tenant_id="tenant-2",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="campaigns",
classification="internal",
expires_at=utc_now() + timedelta(days=7),
)
def test_vacancy_escalation_is_delayed_and_uses_frozen_targets(
self,
) -> None:
top_assignment = OrganizationFunctionAssignmentRef(
id="assignment-top",
tenant_id="tenant-1",
identity_id="identity-top",
account_id="account-top",
function_id="function-top",
organization_unit_id="unit-top",
source="direct",
)
self.idm.assignments.append(top_assignment)
with Session(self.engine) as session:
service, source, _target_template = self._create_routing_source(
session,
vacancy_escalation=True,
)
service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-2",
idempotency_key="routing-vacancy-1",
subject="Escalated decision",
classification="internal",
expires_at=utc_now() + timedelta(days=7),
),
)
session.commit()
routes = (
session.query(PostboxRoute)
.order_by(PostboxRoute.depth)
.all()
)
self.assertEqual(
["accepted_vacant", "pending_vacancy_escalation"],
[route.status for route in routes],
)
pending = routes[1]
frozen_target_id = pending.target_postbox_id
pending.execute_after = utc_now() - timedelta(seconds=1)
session.commit()
result = service.dispatch_due_routes(session)
session.commit()
session.refresh(pending)
self.assertEqual(1, result["delivered"])
self.assertEqual("accepted", pending.status)
self.assertEqual(frozen_target_id, pending.target_postbox_id)
self.assertIsNotNone(pending.target_message_id)
self.assertEqual(3, session.query(PostboxMessage).count())
def test_vacancy_escalation_stops_when_previous_function_is_filled(
self,
) -> None:
with Session(self.engine) as session:
service, source, _target_template = self._create_routing_source(
session,
vacancy_escalation=True,
)
service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=source.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-3",
idempotency_key="routing-vacancy-resolved-1",
subject="No longer escalated",
classification="internal",
expires_at=utc_now() + timedelta(days=7),
),
)
session.commit()
pending = (
session.query(PostboxRoute)
.filter(
PostboxRoute.status
== "pending_vacancy_escalation"
)
.one()
)
pending.execute_after = utc_now() - timedelta(seconds=1)
self.idm.assignments.append(self.assignment)
session.commit()
result = service.dispatch_due_routes(session)
session.commit()
session.refresh(pending)
self.assertEqual(1, result["cancelled"])
self.assertEqual(
"cancelled_vacancy_resolved",
pending.status,
)
self.assertIsNone(pending.target_message_id)
self.assertEqual(2, session.query(PostboxMessage).count())
def test_grouping_update_retains_temporarily_hidden_sources(self) -> None:
child_assignment = OrganizationFunctionAssignmentRef(
id="assignment-child",
+52 -4
View File
@@ -110,6 +110,54 @@ export type PostboxOrganizationUnit = {
functions: PostboxOrganizationFunction[];
};
export type PostboxOrganizationRelationType = {
id: string;
slug: string;
name: string;
structure_id?: string | null;
is_hierarchical: boolean;
status: string;
};
export type PostboxOrganizationStructure = {
id: string;
slug: string;
name: string;
structure_kind: string;
status: string;
relation_types: PostboxOrganizationRelationType[];
};
export type PostboxOrganizationTargets = {
units: PostboxOrganizationUnit[];
structures: PostboxOrganizationStructure[];
};
export type PostboxRoutingPolicy = {
linked_copy: {
enabled: boolean;
structure_id?: string | null;
relation_type_ids: string[];
max_depth: number;
stop_unit_id?: string | null;
stop_unit_type_id?: string | null;
target_function_type_id?: string | null;
target_template_id?: string | null;
fanout: "nearest" | "all";
allowed_classifications: string[];
allowed_producer_modules: string[];
require_expiry: boolean;
max_retention_days?: number | null;
};
attention: {
mode: "none" | "vacancy_escalation";
delay_minutes?: number | null;
};
shared_visibility: {
mode: "none";
};
};
export type PostboxTemplateRevision = {
id: string;
revision: number;
@@ -122,7 +170,7 @@ export type PostboxTemplateRevision = {
allow_vacant_delivery: boolean;
encryption_profile: string;
history_policy: Record<string, unknown>;
routing_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy;
retention_policy: Record<string, unknown>;
published_at?: string | null;
created_at: string;
@@ -151,6 +199,7 @@ export type PostboxTemplateRevisionPayload = Pick<
| "address_pattern"
| "classification"
| "allow_vacant_delivery"
| "routing_policy"
>;
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
@@ -269,12 +318,11 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
export async function listPostboxOrganizationTargets(
settings: ApiSettings
): Promise<PostboxOrganizationUnit[]> {
const response = await apiFetch<{ units: PostboxOrganizationUnit[] }>(
): Promise<PostboxOrganizationTargets> {
return apiFetch<PostboxOrganizationTargets>(
settings,
"/api/v1/postbox/admin/organization-targets"
);
return response.units;
}
export function createExactPostbox(
@@ -39,7 +39,9 @@ import {
type PostboxDirectoryItem,
type PostboxExactCreatePayload,
type PostboxOrganizationFunction,
type PostboxOrganizationStructure,
type PostboxOrganizationUnit,
type PostboxRoutingPolicy,
type PostboxTemplate,
type PostboxTemplateCreatePayload,
type PostboxTemplateRevisionPayload
@@ -56,6 +58,31 @@ type MaterializeDraft = {
context_key: string;
};
const routingDefaults = (): PostboxRoutingPolicy => ({
linked_copy: {
enabled: false,
structure_id: null,
relation_type_ids: [],
max_depth: 1,
stop_unit_id: null,
stop_unit_type_id: null,
target_function_type_id: null,
target_template_id: null,
fanout: "nearest",
allowed_classifications: ["internal"],
allowed_producer_modules: [],
require_expiry: false,
max_retention_days: null
},
attention: {
mode: "none",
delay_minutes: null
},
shared_visibility: {
mode: "none"
}
});
const templateDefaults = (): TemplateDraft => ({
templateId: "",
slug: "",
@@ -67,7 +94,8 @@ const templateDefaults = (): TemplateDraft => ({
name_pattern: "{unit_name} / {function_name}",
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal",
allow_vacant_delivery: true
allow_vacant_delivery: true,
routing_policy: routingDefaults()
});
const exactDefaults = (): ExactDraft => ({
@@ -94,6 +122,7 @@ export default function PostboxAdminPanel({
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
const [structures, setStructures] = useState<PostboxOrganizationStructure[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [selectedPostboxId, setSelectedPostboxId] = useState("");
const [loading, setLoading] = useState(true);
@@ -146,14 +175,15 @@ export default function PostboxAdminPanel({
setLoading(true);
setError("");
try {
const [nextTemplates, nextPostboxes, nextUnits] = await Promise.all([
const [nextTemplates, nextPostboxes, organizationTargets] = await Promise.all([
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
listPostboxOrganizationTargets(settings)
]);
setTemplates(nextTemplates);
setPostboxes(nextPostboxes);
setUnits(nextUnits);
setUnits(organizationTargets.units);
setStructures(organizationTargets.structures);
setSelectedTemplateId((current) =>
current && nextTemplates.some((template) => template.id === current)
? current
@@ -194,7 +224,8 @@ export default function PostboxAdminPanel({
name_pattern: revision.name_pattern,
address_pattern: revision.address_pattern,
classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery
allow_vacant_delivery: revision.allow_vacant_delivery,
routing_policy: revision.routing_policy ?? routingDefaults()
});
setTemplateDialogOpen(true);
}
@@ -409,6 +440,8 @@ export default function PostboxAdminPanel({
open={templateDialogOpen}
draft={templateDraft}
units={units}
structures={structures}
templates={templates}
functionTypes={functionTypes}
unitTypes={unitTypes}
busy={busy}
@@ -531,6 +564,22 @@ function TemplateWorkspace({
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
<div>
<dt>Hierarchy copies</dt>
<dd>
{revision.routing_policy.linked_copy.enabled
? `${revision.routing_policy.linked_copy.fanout} · depth ${revision.routing_policy.linked_copy.max_depth}`
: "Disabled"}
</dd>
</div>
<div>
<dt>Vacancy escalation</dt>
<dd>
{revision.routing_policy.attention.mode === "vacancy_escalation"
? `${revision.routing_policy.attention.delay_minutes} minutes`
: "Disabled"}
</dd>
</div>
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
@@ -642,6 +691,8 @@ function TemplateDialog({
open,
draft,
units,
structures,
templates,
functionTypes,
unitTypes,
busy,
@@ -652,6 +703,8 @@ function TemplateDialog({
open: boolean;
draft: TemplateDraft;
units: PostboxOrganizationUnit[];
structures: PostboxOrganizationStructure[];
templates: PostboxTemplate[];
functionTypes: Array<{ id: string; name: string }>;
unitTypes: Array<{ id: string; example: string }>;
busy: boolean;
@@ -663,12 +716,41 @@ function TemplateDialog({
const scopeOptions = draft.scope_kind === "unit_type"
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
: units.map((unit) => ({ id: unit.id, label: unit.name }));
const linkedCopy = draft.routing_policy.linked_copy;
const attention = draft.routing_policy.attention;
const selectedStructure = structures.find(
(item) => item.id === linkedCopy.structure_id
);
const updateRouting = (routing_policy: PostboxRoutingPolicy) => {
onChange({ ...draft, routing_policy });
};
const updateLinkedCopy = (
next: Partial<PostboxRoutingPolicy["linked_copy"]>
) => {
updateRouting({
...draft.routing_policy,
linked_copy: {
...linkedCopy,
...next
}
});
};
const valid =
draft.name.trim() &&
draft.slug.trim() &&
draft.name_pattern.trim() &&
draft.address_pattern.trim() &&
(draft.scope_kind === "tenant" || Boolean(draft.scope_id));
(draft.scope_kind === "tenant" || Boolean(draft.scope_id)) &&
(
!linkedCopy.enabled
|| Boolean(
linkedCopy.structure_id
&& linkedCopy.target_function_type_id
&& linkedCopy.target_template_id
&& linkedCopy.allowed_classifications.length
&& linkedCopy.allowed_producer_modules.length
)
);
return (
<Dialog
open={open}
@@ -777,6 +859,212 @@ function TemplateDialog({
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
/>
</div>
<div className="postbox-routing-section">
<div className="postbox-routing-heading">
<div>
<strong>Hierarchy linked copies</strong>
<span>Copy to explicitly bounded function Postboxes in one selected structure.</span>
</div>
<ToggleSwitch
label="Enable hierarchy linked copies"
checked={linkedCopy.enabled}
onChange={(enabled) => updateLinkedCopy({
enabled,
allowed_classifications: linkedCopy.allowed_classifications.length
? linkedCopy.allowed_classifications
: [draft.classification]
})}
/>
</div>
{linkedCopy.enabled ? (
<div className="postbox-form-grid two-columns">
<FormField label="Organization structure">
<select
value={linkedCopy.structure_id || ""}
onChange={(event) => updateLinkedCopy({
structure_id: event.target.value || null,
relation_type_ids: []
})}
>
<option value="">Select structure</option>
{structures.filter((item) => item.status === "active").map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Relation type">
<select
value={linkedCopy.relation_type_ids[0] || ""}
onChange={(event) => updateLinkedCopy({
relation_type_ids: event.target.value ? [event.target.value] : []
})}
>
<option value="">All hierarchical relations</option>
{(selectedStructure?.relation_types || [])
.filter((item) => item.status === "active" && item.is_hierarchical)
.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Target function type">
<select
value={linkedCopy.target_function_type_id || ""}
onChange={(event) => updateLinkedCopy({
target_function_type_id: event.target.value || null
})}
>
<option value="">Select function type</option>
{functionTypes.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Target Postbox template">
<select
value={linkedCopy.target_template_id || ""}
onChange={(event) => updateLinkedCopy({
target_template_id: event.target.value || null
})}
>
<option value="">Select published template</option>
{templates
.filter((item) => item.status === "published")
.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Maximum hierarchy depth">
<input
type="number"
min={1}
max={20}
value={linkedCopy.max_depth}
onChange={(event) => updateLinkedCopy({
max_depth: Math.max(1, Math.min(20, Number(event.target.value) || 1))
})}
/>
</FormField>
<FormField label="Copy behavior">
<select
value={linkedCopy.fanout}
onChange={(event) => {
const fanout = event.target.value as "nearest" | "all";
updateRouting({
...draft.routing_policy,
linked_copy: { ...linkedCopy, fanout },
attention: fanout === "all"
? { mode: "none", delay_minutes: null }
: attention
});
}}
>
<option value="nearest">Nearest matching ancestor</option>
<option value="all">All matching ancestors</option>
</select>
</FormField>
<FormField label="Optional stop unit">
<select
value={linkedCopy.stop_unit_id || ""}
onChange={(event) => updateLinkedCopy({
stop_unit_id: event.target.value || null
})}
>
<option value="">No unit stop</option>
{units.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Optional stop unit type">
<select
value={linkedCopy.stop_unit_type_id || ""}
onChange={(event) => updateLinkedCopy({
stop_unit_type_id: event.target.value || null
})}
>
<option value="">No unit-type stop</option>
{unitTypes.map((item) => (
<option key={item.id} value={item.id}>{item.id}</option>
))}
</select>
</FormField>
<FormField label="Allowed classifications">
<input
value={linkedCopy.allowed_classifications.join(", ")}
onChange={(event) => updateLinkedCopy({
allowed_classifications: commaSeparated(event.target.value)
})}
/>
</FormField>
<FormField label="Authorized producer modules">
<input
placeholder="campaigns, workflow"
value={linkedCopy.allowed_producer_modules.join(", ")}
onChange={(event) => updateLinkedCopy({
allowed_producer_modules: commaSeparated(event.target.value)
})}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Require message expiry"
checked={linkedCopy.require_expiry}
onChange={(require_expiry) => updateLinkedCopy({ require_expiry })}
/>
</div>
<FormField label="Maximum retention days">
<input
type="number"
min={1}
max={36500}
value={linkedCopy.max_retention_days ?? ""}
onChange={(event) => updateLinkedCopy({
max_retention_days: event.target.value
? Math.max(1, Math.min(36500, Number(event.target.value)))
: null
})}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Escalate when the nearest target remains vacant"
checked={attention.mode === "vacancy_escalation"}
onChange={(checked) => updateRouting({
...draft.routing_policy,
attention: checked
? {
mode: "vacancy_escalation",
delay_minutes: attention.delay_minutes || 1440
}
: { mode: "none", delay_minutes: null }
})}
/>
</div>
{attention.mode === "vacancy_escalation" ? (
<FormField label="Vacancy escalation delay (minutes)">
<input
type="number"
min={1}
max={43200}
value={attention.delay_minutes ?? 1440}
onChange={(event) => updateRouting({
...draft.routing_policy,
attention: {
mode: "vacancy_escalation",
delay_minutes: Math.max(
1,
Math.min(43200, Number(event.target.value) || 1)
)
}
})}
/>
</FormField>
) : <div />}
</div>
) : null}
</div>
</div>
<p className="postbox-form-note">
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
@@ -954,7 +1242,8 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
name_pattern: draft.name_pattern,
address_pattern: draft.address_pattern,
classification: draft.classification,
allow_vacant_delivery: draft.allow_vacant_delivery
allow_vacant_delivery: draft.allow_vacant_delivery,
routing_policy: draft.routing_policy
};
}
@@ -975,3 +1264,7 @@ function compatibleTargets(
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}
function commaSeparated(value: string): string[] {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
}
+30
View File
@@ -441,6 +441,31 @@
padding-bottom: 7px;
}
.postbox-routing-section {
grid-column: 1 / -1;
display: grid;
gap: 14px;
border-top: var(--border-line);
padding-top: 16px;
}
.postbox-routing-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.postbox-routing-heading > div:first-child {
display: grid;
gap: 3px;
}
.postbox-routing-heading span {
color: var(--muted);
font-size: 12px;
}
.postbox-form-note {
margin: 15px 0 0;
color: var(--muted);
@@ -637,4 +662,9 @@
.postbox-message-detail {
padding: 16px;
}
.postbox-routing-heading {
align-items: flex-start;
flex-direction: column;
}
}