diff --git a/README.md b/README.md index 2fe96dd..89c69f3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,12 @@ Unit-specific addresses are resolved lazily and remain stable through vacancy and reassignment. Exact postboxes remain available for exceptional responsibilities or case/service contexts. +Subtree templates select an explicit Organizations structure and optional +hierarchical relation types. The administration UI can dry-run a draft against +the current organization and incumbency state, showing generated addresses, +vacancy, existing targets, collisions, and hierarchy diagnostics without +materializing data. + Users holding several functions may group selected postboxes into unified inbox views. These are query projections only: messages, address, read state, retention, and evidence remain attached to their source postboxes. @@ -84,7 +90,8 @@ 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. Published template revisions can also opt into bounded linked +administration. Grouping summaries expose batched total and unread counts for +currently visible sources. 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 diff --git a/docs/POSTBOX_CONCEPT.md b/docs/POSTBOX_CONCEPT.md index d6b8abc..ce40c45 100644 --- a/docs/POSTBOX_CONCEPT.md +++ b/docs/POSTBOX_CONCEPT.md @@ -139,6 +139,19 @@ scope, such as a unit type, structure, or subtree. Postbox resolves a stable unit-specific address from the tenant, template revision, concrete unit, concrete function, and optional case/service context. +Subtree scope is explicit about the Organizations structure and may restrict +the hierarchical relation types used within that structure. It does not infer +scope from the legacy `parent_id` when an administrator creates or revises a +template. This prevents an administrative, reporting, and project hierarchy +from being confused when they contain the same units. + +Before saving a draft, administrators can run a read-only impact preview. It +uses the same scope, function matching, address rendering, and incumbent rules +as materialization and reports ready targets, already materialized addresses, +vacancies, collisions, cycles, depth limits, and ambiguous paths. The preview +does not create a template, address, Postbox, or delivery. A large result is +bounded in the UI while its aggregate counts remain visible. + Addresses should be resolved lazily and idempotently rather than eagerly creating empty containers for every unit. They remain durable through vacancy and reassignment. A delivery snapshots the template revision and normalized @@ -155,6 +168,10 @@ unified inbox views and keep other responsibilities separate. Grouping is a query projection only. It never merges source containers, messages, read or acknowledgement state, retention, encryption keys, or audit evidence. +Grouping summaries calculate total and unread counts over the currently +visible source Postboxes in one tenant-bounded query. Hidden sources retained +for later reassignment do not leak counts into the projection. + Every item and action continues to show the source function, unit, postbox, assignment/delegation context, and classification. Policy may require some postboxes to remain separate. diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py index 0fad7c5..e94e5b7 100644 --- a/src/govoplan_postbox/backend/db/models.py +++ b/src/govoplan_postbox/backend/db/models.py @@ -121,6 +121,16 @@ class PostboxTemplateRevision(Base, TimestampMixin): nullable=True, index=True, ) + scope_structure_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + scope_relation_type_ids: Mapped[list[str]] = mapped_column( + JSON, + default=list, + nullable=False, + ) name_pattern: Mapped[str] = mapped_column( String(500), default="{unit_name} / {function_name}", diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index ec723fd..26181dd 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -473,7 +473,12 @@ manifest = ModuleManifest( "Hierarchy copies are independent deliveries with their own evidence; " "vacancy escalation is delayed and separately auditable. Message expiry " "or withdrawal blocks future content access but cannot retract plaintext " - "already copied, exported, or printed." + "already copied, exported, or printed. Subtree templates select one " + "explicit organization structure and optional relation types. Their " + "read-only impact preview reports generated addresses, current holders, " + "vacancy, collisions, cycles, depth limits, and ambiguous paths without " + "creating templates or Postboxes. Grouping totals include only source " + "Postboxes currently visible to the account." ), layer="configured", documentation_types=("admin", "user"), @@ -506,6 +511,7 @@ manifest = ModuleManifest( "postbox.field.classification", "postbox.field.retention", "postbox.field.hierarchy-routing", + "postbox.action.preview-template", "postbox.field.recipients", "postbox.action.archive", "postbox.action.retire-template", @@ -513,6 +519,7 @@ manifest = ModuleManifest( ], "consequence_classes": { "publish_template": "Freezes an immutable address and routing revision for future materialization.", + "preview_template": "Reads current organization, hierarchy, and incumbency state without materializing any address or Postbox.", "retire_template": "Stops new revisions and materialization while retaining existing addresses.", "archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.", "delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.", diff --git a/src/govoplan_postbox/backend/migrations/versions/e9f4a7b2c5d8_v014_template_scope_preview.py b/src/govoplan_postbox/backend/migrations/versions/e9f4a7b2c5d8_v014_template_scope_preview.py new file mode 100644 index 0000000..3b1c80b --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/e9f4a7b2c5d8_v014_template_scope_preview.py @@ -0,0 +1,43 @@ +"""Add explicit Postbox template hierarchy scope. + +Revision ID: e9f4a7b2c5d8 +Revises: d8e3f6a9b2c5 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "e9f4a7b2c5d8" +down_revision = "d8e3f6a9b2c5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("postbox_template_revisions") as batch: + batch.add_column( + sa.Column("scope_structure_id", sa.String(length=36), nullable=True) + ) + batch.add_column( + sa.Column( + "scope_relation_type_ids", + sa.JSON(), + nullable=False, + server_default=sa.text("'[]'"), + ) + ) + batch.create_index( + "ix_postbox_template_revisions_scope_structure", + ["scope_structure_id"], + unique=False, + ) + + +def downgrade() -> None: + with op.batch_alter_table("postbox_template_revisions") as batch: + batch.drop_index("ix_postbox_template_revisions_scope_structure") + batch.drop_column("scope_relation_type_ids") + batch.drop_column("scope_structure_id") diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index 06ca434..aeb2e94 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import asdict from typing import Literal @@ -60,6 +61,8 @@ from govoplan_postbox.backend.schemas import ( PostboxTemplateCreateRequest, PostboxTemplateItem, PostboxTemplateListResponse, + PostboxTemplatePreviewRequest, + PostboxTemplatePreviewResponse, PostboxTemplatePublishRequest, PostboxTemplateReviseRequest, ) @@ -252,6 +255,10 @@ def _template_item(template) -> PostboxTemplateItem: "function_type_id": revision.function_type_id, "scope_kind": revision.scope_kind, "scope_id": revision.scope_id, + "scope_structure_id": revision.scope_structure_id, + "scope_relation_type_ids": list( + revision.scope_relation_type_ids or [] + ), "name_pattern": revision.name_pattern, "address_pattern": revision.address_pattern, "classification": revision.classification, @@ -271,18 +278,33 @@ def _template_item(template) -> PostboxTemplateItem: ) -def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem: +def _grouping_item( + grouping, + *, + visible_ids: set[str], + counts_by_postbox: Mapping[str, Mapping[str, int]] | None = None, +) -> PostboxGroupingItem: + visible_source_ids = [ + source.postbox_id + for source in grouping.sources + if source.postbox_id in visible_ids + ] + counts = counts_by_postbox or {} return PostboxGroupingItem( id=grouping.id, name=grouping.name, is_default=grouping.is_default, resource_revision=grouping.resource_revision, etag=grouping.strong_etag, - postbox_ids=[ - source.postbox_id - for source in grouping.sources - if source.postbox_id in visible_ids - ], + postbox_ids=visible_source_ids, + total_count=sum( + int(counts.get(postbox_id, {}).get("total", 0)) + for postbox_id in visible_source_ids + ), + unread_count=sum( + int(counts.get(postbox_id, {}).get("unread", 0)) + for postbox_id in visible_source_ids + ), created_at=grouping.created_at, updated_at=grouping.updated_at, ) @@ -618,9 +640,19 @@ def api_list_postbox_groupings( tenant_id=principal.tenant_id, actor=actor, ) + counts_by_postbox = get_service().message_counts_by_postbox( + session, + tenant_id=principal.tenant_id, + postbox_ids=tuple(visible_ids), + actor=actor, + ) return PostboxGroupingListResponse( groupings=[ - _grouping_item(grouping, visible_ids=visible_ids) + _grouping_item( + grouping, + visible_ids=visible_ids, + counts_by_postbox=counts_by_postbox, + ) for grouping in groupings ] ) @@ -881,6 +913,27 @@ def api_create_postbox_template( return item +@router.post( + "/admin/templates/preview", + response_model=PostboxTemplatePreviewResponse, +) +def api_preview_postbox_template( + payload: PostboxTemplatePreviewRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxTemplatePreviewResponse: + _require(principal, TEMPLATE_ADMIN_SCOPE) + try: + preview = get_service().preview_template_targets( + session, + tenant_id=principal.tenant_id, + **payload.model_dump(), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + return PostboxTemplatePreviewResponse.model_validate(preview) + + @router.post( "/admin/templates/{template_id}/revisions", response_model=PostboxTemplateItem, diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index 245f438..d7af9ed 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -378,6 +378,8 @@ class PostboxTemplateRevisionPayload(BaseModel): function_type_id: str | None = Field(default=None, max_length=36) scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant" scope_id: str | None = Field(default=None, max_length=255) + scope_structure_id: str | None = Field(default=None, max_length=36) + scope_relation_type_ids: list[str] = Field(default_factory=list, max_length=20) name_pattern: str = Field( default="{unit_name} / {function_name}", min_length=1, @@ -398,6 +400,15 @@ class PostboxTemplateRevisionPayload(BaseModel): default_factory=PostboxRoutingPolicyPayload ) + @model_validator(mode="after") + def normalize_scope(self) -> "PostboxTemplateRevisionPayload": + self.scope_relation_type_ids = list( + dict.fromkeys( + value.strip() for value in self.scope_relation_type_ids if value.strip() + ) + ) + return self + @model_validator(mode="after") def validate_encryption(self) -> "PostboxTemplateRevisionPayload": if self.encryption_profile == "server_envelope_v1": @@ -412,15 +423,69 @@ class PostboxTemplateRevisionPayload(BaseModel): return self +def _validate_template_write_scope( + payload: PostboxTemplateRevisionPayload, +) -> None: + if payload.scope_kind == "subtree" and not payload.scope_structure_id: + raise ValueError("A subtree scope requires an organization structure.") + if payload.scope_kind != "subtree" and ( + payload.scope_structure_id or payload.scope_relation_type_ids + ): + raise ValueError( + "Hierarchy structure and relation filters apply only to subtree scopes." + ) + + class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload): slug: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=250) description: str | None = None + @model_validator(mode="after") + def validate_write_scope(self) -> "PostboxTemplateCreateRequest": + _validate_template_write_scope(self) + return self + + +class PostboxTemplatePreviewRequest(PostboxTemplateCreateRequest): + template_id: str | None = Field(default=None, max_length=36) + context_key: str | None = Field(default=None, max_length=255) + limit: int = Field(default=200, ge=1, le=500) + + +class PostboxTemplatePreviewTarget(BaseModel): + organization_unit_id: str + organization_unit_name: str + function_id: str + function_name: str + address: str + name: str + holder_count: int = Field(ge=0) + vacant: bool + status: str + existing_postbox_id: str | None = None + diagnostics: list[str] = Field(default_factory=list) + + +class PostboxTemplatePreviewResponse(BaseModel): + targets: list[PostboxTemplatePreviewTarget] = Field(default_factory=list) + total: int = Field(ge=0) + ready_count: int = Field(ge=0) + existing_count: int = Field(ge=0) + vacant_count: int = Field(ge=0) + blocked_count: int = Field(ge=0) + truncated: bool = False + diagnostics: list[str] = Field(default_factory=list) + class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload): base_revision: int = Field(ge=1) + @model_validator(mode="after") + def validate_write_scope(self) -> "PostboxTemplateReviseRequest": + _validate_template_write_scope(self) + return self + class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload): id: str @@ -523,6 +588,8 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload): class PostboxGroupingItem(PostboxGroupingPayload): id: str + total_count: int = Field(default=0, ge=0) + unread_count: int = Field(default=0, ge=0) resource_revision: int = Field(ge=1) etag: str created_at: datetime diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index 9f328cd..e07e03a 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -10,7 +10,7 @@ from dataclasses import asdict from datetime import datetime, timedelta, timezone from typing import Any, Literal -from sqlalchemy import and_, func, or_ +from sqlalchemy import and_, case, func, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, object_session, selectinload @@ -739,6 +739,63 @@ class PostboxService: or 0 ) + def message_counts_by_postbox( + self, + session: object, + *, + tenant_id: str, + postbox_ids: Sequence[str], + actor: PostboxActorRef, + ) -> Mapping[str, Mapping[str, int]]: + """Return one access-filtered count projection for multiple inboxes.""" + + db = _session(session) + allowed_ids = self._allowed_postbox_ids( + db, + tenant_id=tenant_id, + postbox_ids=postbox_ids, + actor=actor, + action="read", + ) + if not allowed_ids: + return {} + rows = ( + db.query( + PostboxMessage.postbox_id, + func.count(PostboxMessage.id), + func.sum( + case( + (PostboxMessageReceipt.read_at.is_(None), 1), + else_=0, + ) + ), + ) + .outerjoin( + PostboxMessageReceipt, + and_( + PostboxMessageReceipt.tenant_id == tenant_id, + PostboxMessageReceipt.message_id == PostboxMessage.id, + PostboxMessageReceipt.account_id == actor.account_id, + ), + ) + .filter( + PostboxMessage.tenant_id == tenant_id, + PostboxMessage.postbox_id.in_(allowed_ids), + PostboxMessage.classification.in_( + tuple(actor.authorized_classifications) + ), + ) + .group_by(PostboxMessage.postbox_id) + .all() + ) + return { + str(postbox_id): { + "total": int(total or 0), + "unread": int(unread or 0), + } + for postbox_id, total, unread in rows + } + def _messages_query( self, session: Session, @@ -2866,6 +2923,208 @@ class PostboxService: .all() ) + def preview_template_targets( + self, + session: Session, + *, + tenant_id: str, + slug: str, + name: str, + description: str | None, + function_type_id: str | None, + scope_kind: str, + scope_id: str | None, + scope_structure_id: str | None, + scope_relation_type_ids: Sequence[str], + name_pattern: str, + address_pattern: str, + classification: str, + allow_vacant_delivery: bool, + routing_policy: Mapping[str, object] | None, + encryption_profile: str, + encryption_vault_id: str | None, + template_id: str | None, + context_key: str | None, + limit: int, + ) -> dict[str, object]: + del description + self._validate_classification(classification) + _validate_encryption_configuration( + encryption_profile, + encryption_vault_id, + ) + normalized_routing_policy(routing_policy) + self._validate_scope( + tenant_id=tenant_id, + scope_kind=scope_kind, + scope_id=scope_id, + scope_structure_id=scope_structure_id, + scope_relation_type_ids=scope_relation_type_ids, + ) + self._validate_patterns(name_pattern, address_pattern) + if template_id: + self._get_template( + session, + tenant_id=tenant_id, + template_id=template_id, + ) + + units, diagnostics_by_unit, diagnostics = ( + self._template_preview_scope_units( + tenant_id=tenant_id, + scope_kind=scope_kind, + scope_id=scope_id, + scope_structure_id=scope_structure_id, + scope_relation_type_ids=scope_relation_type_ids, + ) + ) + unit_ids = tuple(unit.id for unit in units) + functions = self._template_preview_functions( + tenant_id=tenant_id, + unit_ids=unit_ids, + function_type_id=function_type_id, + ) + functions_by_unit: dict[str, list[OrganizationFunctionRef]] = {} + for function in functions: + functions_by_unit.setdefault( + function.organization_unit_id, + [], + ).append(function) + + candidate_rows: list[dict[str, object]] = [] + preview_template_id = template_id or f"preview:{_slug(slug or name)}" + for unit in units: + for function in sorted( + functions_by_unit.get(unit.id, ()), + key=lambda item: (item.name.casefold(), item.id), + ): + rendered_name, rendered_address = self._render_template_target( + template_slug=_slug(slug or name, fallback="template"), + template_name=name, + unit=unit, + function=function, + context_key=context_key, + name_pattern=name_pattern, + address_pattern=address_pattern, + ) + candidate_rows.append( + { + "organization_unit_id": unit.id, + "organization_unit_name": unit.name, + "function_id": function.id, + "function_name": function.name, + "address_key": self._template_address_key( + preview_template_id, + unit.id, + function.id, + context_key, + ), + "address": rendered_address, + "name": rendered_name, + "diagnostics": list( + diagnostics_by_unit.get(unit.id, ()) + ), + } + ) + + function_ids = tuple( + dict.fromkeys(str(row["function_id"]) for row in candidate_rows) + ) + holder_counts = self._holder_counts_for_functions( + tenant_id=tenant_id, + function_ids=function_ids, + ) + addresses = tuple(str(row["address"]) for row in candidate_rows) + address_keys = tuple(str(row["address_key"]) for row in candidate_rows) + existing_by_id: dict[str, PostboxAddress] = {} + for offset in range(0, len(candidate_rows), 250): + address_batch = addresses[offset : offset + 250] + key_batch = address_keys[offset : offset + 250] + for item in ( + session.query(PostboxAddress) + .options(selectinload(PostboxAddress.postbox)) + .filter( + PostboxAddress.tenant_id == tenant_id, + or_( + PostboxAddress.address_key.in_(key_batch), + PostboxAddress.address.in_(address_batch), + ), + ) + .all() + ): + existing_by_id[item.id] = item + existing_addresses = tuple(existing_by_id.values()) + existing_by_key = { + item.address_key: item for item in existing_addresses + } + existing_by_address = { + item.address: item for item in existing_addresses + } + generated_address_counts = Counter(addresses) + if any(count > 1 for count in generated_address_counts.values()): + diagnostics.append("duplicate_generated_address") + + targets: list[dict[str, object]] = [] + ready_count = 0 + existing_count = 0 + vacant_count = 0 + blocked_count = 0 + for row in candidate_rows: + target_diagnostics = list(row.pop("diagnostics")) + address_key = str(row.pop("address_key")) + address = str(row["address"]) + holder_count = holder_counts.get(str(row["function_id"]), 0) + vacant = holder_count == 0 + existing = existing_by_key.get(address_key) + collision = existing_by_address.get(address) + if existing is not None: + status = "existing" + existing_count += 1 + target_diagnostics.append("address_already_materialized") + elif collision is not None or generated_address_counts[address] > 1: + status = "address_collision" + blocked_count += 1 + target_diagnostics.append("address_collision") + elif vacant and not allow_vacant_delivery: + status = "blocked_vacant" + blocked_count += 1 + target_diagnostics.append("vacant_delivery_blocked") + else: + status = "ready" + ready_count += 1 + if vacant: + vacant_count += 1 + target_diagnostics.append("no_active_holder") + targets.append( + { + **row, + "holder_count": holder_count, + "vacant": vacant, + "status": status, + "existing_postbox_id": ( + existing.postbox.id + if existing is not None and existing.postbox is not None + else None + ), + "diagnostics": list(dict.fromkeys(target_diagnostics)), + } + ) + + total = len(targets) + truncated = total > limit + if truncated: + diagnostics.append("preview_truncated") + return { + "targets": targets[:limit], + "total": total, + "ready_count": ready_count, + "existing_count": existing_count, + "vacant_count": vacant_count, + "blocked_count": blocked_count, + "truncated": truncated, + "diagnostics": list(dict.fromkeys(diagnostics)), + } + def create_template( self, session: Session, @@ -2882,6 +3141,8 @@ class PostboxService: classification: str, allow_vacant_delivery: bool, actor_id: str | None, + scope_structure_id: str | None = None, + scope_relation_type_ids: Sequence[str] = (), routing_policy: Mapping[str, object] | None = None, encryption_profile: str = "plaintext_v1", encryption_vault_id: str | None = None, @@ -2908,6 +3169,8 @@ class PostboxService: tenant_id=tenant_id, scope_kind=scope_kind, scope_id=scope_id, + scope_structure_id=scope_structure_id, + scope_relation_type_ids=scope_relation_type_ids, ) self._validate_patterns(name_pattern, address_pattern) template = PostboxTemplate( @@ -2926,6 +3189,8 @@ class PostboxService: function_type_id=function_type_id, scope_kind=scope_kind, scope_id=scope_id, + scope_structure_id=scope_structure_id, + scope_relation_type_ids=list(scope_relation_type_ids), name_pattern=name_pattern, address_pattern=address_pattern, classification=classification, @@ -2967,6 +3232,8 @@ class PostboxService: allow_vacant_delivery: bool, actor_id: str | None, expected_revision: int, + scope_structure_id: str | None = None, + scope_relation_type_ids: Sequence[str] = (), routing_policy: Mapping[str, object] | None = None, encryption_profile: str = "plaintext_v1", encryption_vault_id: str | None = None, @@ -2998,6 +3265,8 @@ class PostboxService: tenant_id=tenant_id, scope_kind=scope_kind, scope_id=scope_id, + scope_structure_id=scope_structure_id, + scope_relation_type_ids=scope_relation_type_ids, ) self._validate_patterns(name_pattern, address_pattern) next_revision = max( @@ -3011,6 +3280,8 @@ class PostboxService: function_type_id=function_type_id, scope_kind=scope_kind, scope_id=scope_id, + scope_structure_id=scope_structure_id, + scope_relation_type_ids=list(scope_relation_type_ids), name_pattern=name_pattern, address_pattern=address_pattern, classification=classification, @@ -3188,6 +3459,8 @@ class PostboxService: tenant_id=tenant_id, scope_kind=revision.scope_kind, scope_id=revision.scope_id, + scope_structure_id=revision.scope_structure_id, + scope_relation_type_ids=tuple(revision.scope_relation_type_ids or ()), ): raise PostboxError( "unit_out_of_scope", @@ -3207,44 +3480,21 @@ class PostboxService: if existing is not None: return existing - variables = { - "template_slug": template.slug, - "template_name": template.name, - "unit_id": unit.id, - "unit_slug": unit.slug, - "unit_name": unit.name, - "function_id": function.id, - "function_slug": function.slug, - "function_name": function.name, - "context_key": context_key or "", - } - try: - rendered_name = revision.name_pattern.format_map(variables).strip() - rendered_address = ( - revision.address_pattern.format_map(variables).strip() - ) - except KeyError as exc: - raise PostboxError( - "invalid_template_pattern", - f"Unknown Postbox template variable: {exc.args[0]}", - ) from exc - address = ( - ".".join( - part - for part in ( - _slug(rendered_address), - _slug(context_key) if context_key else "", - ) - if part - ) - + "@postbox" - )[:500] + rendered_name, address = self._render_template_target( + template_slug=template.slug, + template_name=template.name, + unit=unit, + function=function, + context_key=context_key, + name_pattern=revision.name_pattern, + address_pattern=revision.address_pattern, + ) return self._create_postbox_records( session, tenant_id=tenant_id, address_key=key, address=address, - name=rendered_name or f"{unit.name} / {function.name}", + name=rendered_name, description=template.description, classification=revision.classification, organization_unit=unit, @@ -4340,6 +4590,8 @@ class PostboxService: tenant_id: str, scope_kind: str, scope_id: str | None, + scope_structure_id: str | None = None, + scope_relation_type_ids: Sequence[str] = (), ) -> None: if scope_kind not in {"tenant", "unit", "subtree", "unit_type"}: raise PostboxError( @@ -4347,10 +4599,10 @@ class PostboxService: "Postbox template scope must be tenant, unit, subtree, or unit_type.", ) if scope_kind == "tenant": - if scope_id: + if scope_id or scope_structure_id or scope_relation_type_ids: raise PostboxError( "invalid_scope_id", - "Tenant-scoped Postbox templates do not take a scope id.", + "Tenant-scoped Postbox templates do not take hierarchy scope fields.", ) return if not scope_id: @@ -4365,6 +4617,51 @@ class PostboxService: "scope_unit_not_found", "The Postbox template scope unit was not found.", ) + if scope_kind != "subtree": + if scope_structure_id or scope_relation_type_ids: + raise PostboxError( + "invalid_scope_structure", + "Only subtree scopes can select a hierarchy structure.", + ) + return + if scope_relation_type_ids and not scope_structure_id: + raise PostboxError( + "scope_structure_required", + "Subtree relation filters require an organization structure.", + ) + if not scope_structure_id: + return + hierarchy = self._hierarchy_directory() + if hierarchy is None: + raise PostboxError( + "organization_hierarchy_unavailable", + "The organization hierarchy required for this subtree scope is unavailable.", + ) + catalog = hierarchy.hierarchy_catalog(tenant_id) + structures = { + item.id: item + for item in catalog.structures + if item.tenant_id == tenant_id and item.status == "active" + } + if scope_structure_id not in structures: + raise PostboxError( + "scope_structure_not_found", + "The selected organization structure is not active in this tenant.", + ) + allowed_relation_ids = { + item.id + for item in catalog.relation_types + if item.tenant_id == tenant_id + and item.structure_id == scope_structure_id + and item.status == "active" + and item.is_hierarchical + } + invalid_relation_ids = set(scope_relation_type_ids) - allowed_relation_ids + if invalid_relation_ids: + raise PostboxError( + "scope_relation_type_invalid", + "A selected relation type does not belong to the active hierarchy structure.", + ) def _unit_in_scope( self, @@ -4373,6 +4670,8 @@ class PostboxService: tenant_id: str, scope_kind: str, scope_id: str | None, + scope_structure_id: str | None = None, + scope_relation_type_ids: Sequence[str] = (), ) -> bool: if unit.tenant_id != tenant_id: return False @@ -4381,11 +4680,213 @@ class PostboxService: if scope_kind == "unit": return unit.id == scope_id if scope_kind == "subtree" and scope_id: - return self._is_descendant(unit.id, scope_id) + if unit.id == scope_id: + return True + if not scope_structure_id: + return self._is_descendant(unit.id, scope_id) + hierarchy = self._hierarchy_directory() + if hierarchy is None: + return False + resolutions = hierarchy.resolve_hierarchy_paths( + tenant_id, + ((scope_id, unit.id),), + structure_id=scope_structure_id, + relation_type_ids=tuple(scope_relation_type_ids), + direction="descendants", + max_depth=100, + ) + return bool( + resolutions + and resolutions[0].status == "active" + and not resolutions[0].cycle_detected + ) if scope_kind == "unit_type": return unit.unit_type_id == scope_id return False + def _hierarchy_directory(self) -> OrganizationHierarchyDirectory | None: + if self._hierarchy is not None: + return self._hierarchy + if isinstance(self._organizations, OrganizationHierarchyDirectory): + return self._organizations + return None + + def _template_preview_scope_units( + self, + *, + tenant_id: str, + scope_kind: str, + scope_id: str | None, + scope_structure_id: str | None, + scope_relation_type_ids: Sequence[str], + ) -> tuple[ + tuple[OrganizationUnitRef, ...], + dict[str, tuple[str, ...]], + list[str], + ]: + all_units = tuple( + unit + for unit in self._organizations.organization_units_for_tenant( + tenant_id + ) + if unit.tenant_id == tenant_id and unit.status == "active" + ) + diagnostics: list[str] = [] + diagnostics_by_unit: dict[str, tuple[str, ...]] = {} + if scope_kind == "tenant": + selected = all_units + elif scope_kind == "unit": + selected = tuple(unit for unit in all_units if unit.id == scope_id) + elif scope_kind == "unit_type": + selected = tuple( + unit for unit in all_units if unit.unit_type_id == scope_id + ) + else: + hierarchy = self._hierarchy_directory() + if hierarchy is None or not scope_id or not scope_structure_id: + raise PostboxError( + "organization_hierarchy_unavailable", + "The selected organization hierarchy is unavailable for preview.", + ) + resolutions = hierarchy.resolve_hierarchy_relatives( + tenant_id, + (scope_id,), + structure_id=scope_structure_id, + relation_type_ids=tuple(scope_relation_type_ids), + direction="descendants", + max_depth=100, + ) + resolution = resolutions[0] if resolutions else None + if resolution is None or resolution.status not in { + "active", + "inactive", + }: + reason = ( + resolution.status if resolution is not None else "missing" + ) + raise PostboxError( + "organization_hierarchy_unavailable", + f"The hierarchy scope could not be resolved ({reason}).", + ) + selected_by_id: dict[str, OrganizationUnitRef] = {} + if resolution.root is not None and resolution.root.status == "active": + selected_by_id[resolution.root.id] = resolution.root + duplicate_ids: set[str] = set() + for match in resolution.matches: + if match.unit.status != "active": + continue + if match.unit.id in selected_by_id: + duplicate_ids.add(match.unit.id) + selected_by_id[match.unit.id] = match.unit + if duplicate_ids: + diagnostics.append("ambiguous_scope_paths") + diagnostics_by_unit.update( + { + unit_id: ("ambiguous_scope_path",) + for unit_id in duplicate_ids + } + ) + if resolution.cycle_detected: + diagnostics.append("hierarchy_cycle_detected") + if resolution.depth_limited: + diagnostics.append("hierarchy_depth_limited") + diagnostics.extend(resolution.diagnostics) + selected = tuple(selected_by_id.values()) + return ( + tuple( + sorted( + selected, + key=lambda item: (item.name.casefold(), item.id), + ) + ), + diagnostics_by_unit, + list(dict.fromkeys(diagnostics)), + ) + + def _template_preview_functions( + self, + *, + tenant_id: str, + unit_ids: Sequence[str], + function_type_id: str | None, + ) -> tuple[OrganizationFunctionRef, ...]: + if not unit_ids: + return () + unit_id_set = set(unit_ids) + if function_type_id: + hierarchy = self._hierarchy_directory() + if hierarchy is not None: + matches: dict[str, OrganizationFunctionRef] = {} + for offset in range(0, len(unit_ids), 400): + resolution = hierarchy.resolve_functions_by_type( + tenant_id, + function_type_id, + organization_unit_ids=tuple( + unit_ids[offset : offset + 400] + ), + ) + if resolution.status in {"missing", "invalid"}: + raise PostboxError( + "function_type_not_found", + "The selected function type is unavailable in this tenant.", + ) + for function in resolution.matches: + if ( + function.status == "active" + and function.organization_unit_id in unit_id_set + ): + matches[function.id] = function + return tuple(matches.values()) + functions: list[OrganizationFunctionRef] = [] + for unit_id in unit_ids: + functions.extend( + function + for function in self._organizations.functions_for_organization_unit( + unit_id, + include_subunits=False, + ) + if function.tenant_id == tenant_id + and function.status == "active" + and ( + not function_type_id + or function.function_type_id == function_type_id + ) + ) + return tuple(functions) + + def _holder_counts_for_functions( + self, + *, + tenant_id: str, + function_ids: Sequence[str], + ) -> dict[str, int]: + if not function_ids: + return {} + counts: dict[str, int] = {} + try: + for offset in range(0, len(function_ids), 400): + incumbencies = ( + self._incumbencies.organization_function_incumbencies( + tuple(function_ids[offset : offset + 400]), + tenant_id=tenant_id, + ) + ) + counts.update( + { + function_id: len( + { + assignment.identity_id + for assignment in incumbency.assignments + if assignment.status == "active" + } + ) + for function_id, incumbency in incumbencies.items() + } + ) + except ValueError: + return {} + return counts + def _validate_patterns( self, name_pattern: str, @@ -4413,6 +4914,49 @@ class PostboxService: f"Invalid Postbox template pattern: {exc}", ) from exc + def _render_template_target( + self, + *, + template_slug: str, + template_name: str, + unit: OrganizationUnitRef, + function: OrganizationFunctionRef, + context_key: str | None, + name_pattern: str, + address_pattern: str, + ) -> tuple[str, str]: + variables = { + "template_slug": template_slug, + "template_name": template_name, + "unit_id": unit.id, + "unit_slug": unit.slug, + "unit_name": unit.name, + "function_id": function.id, + "function_slug": function.slug, + "function_name": function.name, + "context_key": context_key or "", + } + try: + rendered_name = name_pattern.format_map(variables).strip() + rendered_address = address_pattern.format_map(variables).strip() + except KeyError as exc: + raise PostboxError( + "invalid_template_pattern", + f"Unknown Postbox template variable: {exc.args[0]}", + ) from exc + address = ( + ".".join( + part + for part in ( + _slug(rendered_address), + _slug(context_key) if context_key else "", + ) + if part + ) + + "@postbox" + )[:500] + return rendered_name or f"{unit.name} / {function.name}", address + def _record_access_event( self, session: Session, diff --git a/tests/test_migration.py b/tests/test_migration.py index 4e79cb3..5998980 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -30,6 +30,10 @@ class PostboxMigrationTests(unittest.TestCase): "govoplan_postbox.backend.migrations.versions." "d8e3f6a9b2c5_postbox_content_protection" ) + scope_migration = importlib.import_module( + "govoplan_postbox.backend.migrations.versions." + "e9f4a7b2c5d8_v014_template_scope_preview" + ) engine = create_engine("sqlite:///:memory:") try: with engine.begin() as connection: @@ -39,17 +43,20 @@ class PostboxMigrationTests(unittest.TestCase): occ_original = occ_migration.op envelope_original = envelope_migration.op protection_original = protection_migration.op + scope_original = scope_migration.op migration.op = operations route_migration.op = operations occ_migration.op = operations envelope_migration.op = operations protection_migration.op = operations + scope_migration.op = operations try: migration.upgrade() route_migration.upgrade() occ_migration.upgrade() envelope_migration.upgrade() protection_migration.upgrade() + scope_migration.upgrade() tables = set(inspect(connection).get_table_names()) self.assertIn("postboxes", tables) self.assertIn("postbox_messages", tables) @@ -75,14 +82,18 @@ class PostboxMigrationTests(unittest.TestCase): "encryption_resource_id", }.issubset(message_columns) ) - self.assertIn( - "encryption_vault_id", + template_revision_columns = { + column["name"] + for column in inspect(connection).get_columns( + "postbox_template_revisions" + ) + } + self.assertTrue( { - column["name"] - for column in inspect(connection).get_columns( - "postbox_template_revisions" - ) - }, + "encryption_vault_id", + "scope_structure_id", + "scope_relation_type_ids", + }.issubset(template_revision_columns) ) self.assertIn("authoring_key", message_columns) for table_name in ( @@ -110,6 +121,7 @@ class PostboxMigrationTests(unittest.TestCase): route_columns ) ) + scope_migration.downgrade() protection_migration.downgrade() envelope_migration.downgrade() occ_migration.downgrade() @@ -128,6 +140,7 @@ class PostboxMigrationTests(unittest.TestCase): occ_migration.op = occ_original envelope_migration.op = envelope_original protection_migration.op = protection_original + scope_migration.op = scope_original finally: engine.dispose() diff --git a/tests/test_router.py b/tests/test_router.py index 2320d34..fecce15 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -250,6 +250,63 @@ class PostboxRouterTests(unittest.TestCase): self.assertEqual(response.status_code, 403) self.assertIn("not active for this principal", response.text) + def test_template_impact_preview_is_available_without_writes(self) -> None: + with Session(self.engine) as session: + before = session.query(Postbox).count() + response = self.client.post( + "/api/v1/postbox/admin/templates/preview", + json={ + "slug": "case-intake", + "name": "Case intake", + "scope_kind": "tenant", + "name_pattern": "{unit_name} / {function_name}", + "address_pattern": "{template_slug}.{unit_slug}.{function_slug}", + "classification": "internal", + }, + ) + self.assertEqual(200, response.status_code, response.text) + self.assertEqual(1, response.json()["total"]) + self.assertEqual(1, response.json()["ready_count"]) + with Session(self.engine) as session: + self.assertEqual(before, session.query(Postbox).count()) + + def test_legacy_subtree_template_remains_readable_but_cannot_be_created_by_api( + self, + ) -> None: + with Session(self.engine) as session: + self.service.create_template( + session, + tenant_id="tenant-1", + slug="legacy-subtree", + name="Legacy subtree", + description=None, + function_type_id="clerk-type", + scope_kind="subtree", + scope_id="unit-1", + name_pattern="{unit_name} / {function_name}", + address_pattern="{template_slug}.{unit_slug}.{function_slug}", + classification="internal", + allow_vacant_delivery=True, + actor_id="account-1", + ) + session.commit() + + listing = self.client.get("/api/v1/postbox/admin/templates") + self.assertEqual(200, listing.status_code, listing.text) + revision = listing.json()["templates"][0]["revisions"][0] + self.assertIsNone(revision["scope_structure_id"]) + + rejected = self.client.post( + "/api/v1/postbox/admin/templates", + json={ + "slug": "new-subtree", + "name": "New subtree", + "scope_kind": "subtree", + "scope_id": "unit-1", + }, + ) + self.assertEqual(422, rejected.status_code, rejected.text) + def test_directory_delivery_message_and_receipt_round_trip(self) -> None: directory = self.client.get("/api/v1/postbox/directory") self.assertEqual(200, directory.status_code, directory.text) @@ -289,6 +346,20 @@ class PostboxRouterTests(unittest.TestCase): self.assertEqual(200, filtered.status_code, filtered.text) self.assertEqual(1, filtered.json()["total"]) + grouping = self.client.post( + "/api/v1/postbox/groupings", + json={ + "name": "Assigned work", + "is_default": True, + "postbox_ids": [self.postbox_id], + }, + ) + self.assertEqual(201, grouping.status_code, grouping.text) + grouped_before_read = self.client.get("/api/v1/postbox/groupings") + self.assertEqual(200, grouped_before_read.status_code) + self.assertEqual(1, grouped_before_read.json()["groupings"][0]["total_count"]) + self.assertEqual(1, grouped_before_read.json()["groupings"][0]["unread_count"]) + acknowledged = self.client.patch( f"/api/v1/postbox/messages/{message_id}/state", json={"state": "acknowledged"}, @@ -306,6 +377,8 @@ class PostboxRouterTests(unittest.TestCase): ) self.assertEqual(200, unread.status_code, unread.text) self.assertEqual(0, unread.json()["total"]) + grouped_after_read = self.client.get("/api/v1/postbox/groupings") + self.assertEqual(0, grouped_after_read.json()["groupings"][0]["unread_count"]) def test_routing_dry_run_explains_default_disabled_state(self) -> None: response = self.client.post( diff --git a/tests/test_service.py b/tests/test_service.py index e4a049b..8f8a1a2 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -899,6 +899,133 @@ class PostboxServiceTests(unittest.TestCase): revised.revisions[1].name_pattern, ) + def test_template_preview_is_read_only_and_explains_existing_vacant_and_colliding_targets( + self, + ) -> None: + self.idm.assignments = [self.assignment] + with Session(self.engine) as session: + template = self.service.create_template( + session, + tenant_id="tenant-1", + slug="case-intake", + name="Case intake", + description=None, + function_type_id="case-clerk-type", + scope_kind="tenant", + scope_id=None, + name_pattern="{unit_name} / {function_name}", + address_pattern="{template_slug}.{unit_slug}.{function_slug}", + classification="internal", + allow_vacant_delivery=True, + actor_id="admin-1", + ) + self.service.publish_template( + session, + tenant_id="tenant-1", + template_id=template.id, + revision_number=None, + actor_id="admin-1", + expected_revision=template.resource_revision, + ) + self.service.materialize_template( + session, + tenant_id="tenant-1", + template_id=template.id, + organization_unit_id="unit-1", + function_id="function-1", + context_key=None, + actor_id="admin-1", + ) + before = session.query(Postbox).count() + + preview = self.service.preview_template_targets( + session, + tenant_id="tenant-1", + template_id=template.id, + slug=template.slug, + name=template.name, + description=None, + function_type_id="case-clerk-type", + scope_kind="tenant", + scope_id=None, + scope_structure_id=None, + scope_relation_type_ids=(), + name_pattern="{unit_name} / {function_name}", + address_pattern="{template_slug}.{unit_slug}.{function_slug}", + classification="internal", + allow_vacant_delivery=True, + routing_policy={}, + encryption_profile="plaintext_v1", + encryption_vault_id=None, + context_key=None, + limit=200, + ) + + self.assertEqual(3, preview["total"]) + self.assertEqual(1, preview["existing_count"]) + self.assertEqual(2, preview["vacant_count"]) + self.assertEqual(before, session.query(Postbox).count()) + + collision_preview = self.service.preview_template_targets( + session, + tenant_id="tenant-1", + template_id=None, + slug="collision", + name="Collision", + description=None, + function_type_id="case-clerk-type", + scope_kind="tenant", + scope_id=None, + scope_structure_id=None, + scope_relation_type_ids=(), + name_pattern="{unit_name} / {function_name}", + address_pattern="same-address", + classification="internal", + allow_vacant_delivery=True, + routing_policy={}, + encryption_profile="plaintext_v1", + encryption_vault_id=None, + context_key=None, + limit=200, + ) + self.assertEqual(3, collision_preview["blocked_count"]) + self.assertIn( + "duplicate_generated_address", + collision_preview["diagnostics"], + ) + + self.organizations.duplicate_parent_match = True + hierarchy_preview = self.service.preview_template_targets( + session, + tenant_id="tenant-1", + template_id=None, + slug="hierarchy", + name="Hierarchy", + description=None, + function_type_id="case-clerk-type", + scope_kind="subtree", + scope_id="unit-child", + scope_structure_id="structure-1", + scope_relation_type_ids=("relation-type-1",), + name_pattern="{unit_name} / {function_name}", + address_pattern="{template_slug}.{unit_slug}.{function_slug}", + classification="internal", + allow_vacant_delivery=True, + routing_policy={}, + encryption_profile="plaintext_v1", + encryption_vault_id=None, + context_key=None, + limit=200, + ) + self.assertIn( + "ambiguous_scope_paths", + hierarchy_preview["diagnostics"], + ) + self.assertEqual( + "descendants", + self.organizations.last_hierarchy_request["direction"], + ) + def test_delivery_catalog_exposes_exact_and_derived_target_choices( self, ) -> None: diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts index c3a4136..3948e85 100644 --- a/webui/src/api/postbox.ts +++ b/webui/src/api/postbox.ts @@ -114,6 +114,8 @@ export type PostboxGrouping = { name: string; is_default: boolean; postbox_ids: string[]; + total_count: number; + unread_count: number; resource_revision: number; etag: string; created_at: string; @@ -192,6 +194,8 @@ export type PostboxTemplateRevision = { function_type_id?: string | null; scope_kind: "tenant" | "unit" | "subtree" | "unit_type"; scope_id?: string | null; + scope_structure_id?: string | null; + scope_relation_type_ids: string[]; name_pattern: string; address_pattern: string; classification: string; @@ -225,6 +229,8 @@ export type PostboxTemplateRevisionPayload = Pick< | "function_type_id" | "scope_kind" | "scope_id" + | "scope_structure_id" + | "scope_relation_type_ids" | "name_pattern" | "address_pattern" | "classification" @@ -238,6 +244,31 @@ export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & { description?: string | null; }; +export type PostboxTemplatePreviewTarget = { + organization_unit_id: string; + organization_unit_name: string; + function_id: string; + function_name: string; + address: string; + name: string; + holder_count: number; + vacant: boolean; + status: string; + existing_postbox_id?: string | null; + diagnostics: string[]; +}; + +export type PostboxTemplatePreview = { + targets: PostboxTemplatePreviewTarget[]; + total: number; + ready_count: number; + existing_count: number; + vacant_count: number; + blocked_count: number; + truncated: boolean; + diagnostics: string[]; +}; + export type PostboxExactCreatePayload = { name: string; description?: string | null; @@ -435,6 +466,21 @@ export function createPostboxTemplate( return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload); } +export function previewPostboxTemplate( + settings: ApiSettings, + payload: PostboxTemplateCreatePayload & { + template_id?: string | null; + context_key?: string | null; + limit?: number; + } +): Promise { + return apiPostJson( + settings, + "/api/v1/postbox/admin/templates/preview", + payload + ); +} + export function revisePostboxTemplate( settings: ApiSettings, template: PostboxTemplate, diff --git a/webui/src/features/postbox/PostboxAdminPanel.tsx b/webui/src/features/postbox/PostboxAdminPanel.tsx index 27371ed..83227be 100644 --- a/webui/src/features/postbox/PostboxAdminPanel.tsx +++ b/webui/src/features/postbox/PostboxAdminPanel.tsx @@ -3,6 +3,7 @@ import { Archive, Boxes, Building2, + Eye, Inbox, Pencil, Plus, @@ -20,6 +21,7 @@ import { DocumentationHelpLink, FormField, IconButton, + MetricCard, SegmentedControl, SelectionList, SelectionListItem, @@ -38,6 +40,7 @@ import { listPostboxOrganizationTargets, listPostboxTemplates, materializePostboxTemplate, + previewPostboxTemplate, publishPostboxTemplate, retirePostboxTemplate, revisePostboxTemplate, @@ -49,6 +52,7 @@ import { type PostboxRoutingPolicy, type PostboxTemplate, type PostboxTemplateCreatePayload, + type PostboxTemplatePreview, type PostboxTemplateRevisionPayload } from "../../api/postbox"; import { @@ -102,6 +106,8 @@ const templateDefaults = (): TemplateDraft => ({ function_type_id: null, scope_kind: "tenant", scope_id: null, + scope_structure_id: null, + scope_relation_type_ids: [], name_pattern: "{unit_name} / {function_name}", address_pattern: "{template_slug}.{unit_slug}.{function_slug}", classification: "internal", @@ -143,6 +149,8 @@ export default function PostboxAdminPanel({ const [templateDialogOpen, setTemplateDialogOpen] = useState(false); const [templateDraft, setTemplateDraft] = useState(templateDefaults); const [templateBaseline, setTemplateBaseline] = useState(templateDefaults); + const [templatePreview, setTemplatePreview] = useState(null); + const [templatePreviewLoading, setTemplatePreviewLoading] = useState(false); const [exactDialogOpen, setExactDialogOpen] = useState(false); const [exactDraft, setExactDraft] = useState(exactDefaults); const [exactBaseline, setExactBaseline] = useState(exactDefaults); @@ -239,6 +247,7 @@ export default function PostboxAdminPanel({ function openNewTemplate() { const next = templateDefaults(); + setTemplatePreview(null); setTemplateDraft(next); setTemplateBaseline(next); setTemplateDialogOpen(true); @@ -255,12 +264,15 @@ export default function PostboxAdminPanel({ function_type_id: revision.function_type_id ?? null, scope_kind: revision.scope_kind, scope_id: revision.scope_id ?? null, + scope_structure_id: revision.scope_structure_id ?? null, + scope_relation_type_ids: revision.scope_relation_type_ids ?? [], name_pattern: revision.name_pattern, address_pattern: revision.address_pattern, classification: revision.classification, allow_vacant_delivery: revision.allow_vacant_delivery, routing_policy: revision.routing_policy ?? routingDefaults() }; + setTemplatePreview(null); setTemplateDraft(next); setTemplateBaseline(next); setTemplateDialogOpen(true); @@ -303,6 +315,27 @@ export default function PostboxAdminPanel({ } } + async function previewTemplate() { + setTemplatePreviewLoading(true); + setError(""); + try { + const preview = await previewPostboxTemplate(settings, { + slug: templateDraft.slug, + name: templateDraft.name, + description: templateDraft.description || null, + ...revisionPayload(templateDraft), + template_id: templateDraft.templateId || null, + limit: 200 + }); + setTemplatePreview(preview); + } catch (actionError) { + setTemplatePreview(null); + setError(errorMessage(actionError)); + } finally { + setTemplatePreviewLoading(false); + } + } + async function publishSelected() { if (!selectedTemplate) return; setBusy(true); @@ -569,7 +602,13 @@ export default function PostboxAdminPanel({ functionTypes={functionTypes} unitTypes={unitTypes} busy={busy} - onChange={setTemplateDraft} + preview={templatePreview} + previewLoading={templatePreviewLoading} + onChange={(draft) => { + setTemplateDraft(draft); + setTemplatePreview(null); + }} + onPreview={() => void previewTemplate()} onClose={closeTemplateDialog} onSave={() => void saveTemplate()} /> @@ -855,7 +894,10 @@ function TemplateDialog({ functionTypes, unitTypes, busy, + preview, + previewLoading, onChange, + onPreview, onClose, onSave }: { @@ -867,7 +909,10 @@ function TemplateDialog({ functionTypes: Array<{ id: string; name: string }>; unitTypes: Array<{ id: string; example: string }>; busy: boolean; + preview: PostboxTemplatePreview | null; + previewLoading: boolean; onChange: (draft: TemplateDraft) => void; + onPreview: () => void; onClose: () => void; onSave: () => void; }) { @@ -877,6 +922,9 @@ function TemplateDialog({ : units.map((unit) => ({ id: unit.id, label: unit.name })); const linkedCopy = draft.routing_policy.linked_copy; const attention = draft.routing_policy.attention; + const selectedScopeStructure = structures.find( + (item) => item.id === draft.scope_structure_id + ); const selectedStructure = structures.find( (item) => item.id === linkedCopy.structure_id ); @@ -900,6 +948,7 @@ function TemplateDialog({ draft.name_pattern.trim() && draft.address_pattern.trim() && (draft.scope_kind === "tenant" || Boolean(draft.scope_id)) && + (draft.scope_kind !== "subtree" || Boolean(draft.scope_structure_id)) && ( !linkedCopy.enabled || Boolean( @@ -919,6 +968,13 @@ function TemplateDialog({ closeDisabled={busy} footer={
+