diff --git a/README.md b/README.md index b887a4c..fbf0523 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This repository owns: normalized Identity, IDM, Organizations, and Core/Access contracts - API routes for postbox directory, messages, access checks, and administration - optional integration capabilities for campaign, files, portal, notification, and mail-facing workflows -- future WebUI package `@govoplan/postbox-webui` +- inbox and tenant administration WebUI package `@govoplan/postbox-webui` Core owns auth, tenants, RBAC evaluation, database/session primitives, module discovery, migrations, CSRF/API helpers, and shell layout. Identity owns @@ -72,3 +72,24 @@ Frontend package: ``` Platform RBAC, module capability contracts, and governance rules are documented in `govoplan-core/docs/`. + +## Current implementation + +The first usable slice includes immutable template revisions, stable lazy +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. + +The persistence model reserves ciphertext manifests, wrapped keys, key epochs, +expiry, and withdrawal state. The active profile remains `plaintext_v1`; the +module does not claim end-to-end encryption until the history, recovery, and +handover policy choices in the security issues are resolved. + +Run focused checks with: + +```bash +/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests +cd webui && npm run test:ui-structure +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..adff214 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-postbox" +version = "0.1.1" +description = "Function-bound institutional postboxes for GovOPlaN." +readme = "README.md" +requires-python = ">=3.12" +license = "AGPL-3.0-or-later" +authors = [{ name = "GovOPlaN" }] +dependencies = ["govoplan-core>=0.1.14"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_postbox = ["py.typed"] + +[project.entry-points."govoplan.modules"] +postbox = "govoplan_postbox.backend.manifest:get_manifest" diff --git a/src/govoplan_postbox/__init__.py b/src/govoplan_postbox/__init__.py new file mode 100644 index 0000000..d017d0a --- /dev/null +++ b/src/govoplan_postbox/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Postbox module.""" + +__version__ = "0.1.0" diff --git a/src/govoplan_postbox/backend/__init__.py b/src/govoplan_postbox/backend/__init__.py new file mode 100644 index 0000000..eac2884 --- /dev/null +++ b/src/govoplan_postbox/backend/__init__.py @@ -0,0 +1 @@ +"""Backend implementation for GovOPlaN Postbox.""" diff --git a/src/govoplan_postbox/backend/db/__init__.py b/src/govoplan_postbox/backend/db/__init__.py new file mode 100644 index 0000000..5c0daac --- /dev/null +++ b/src/govoplan_postbox/backend/db/__init__.py @@ -0,0 +1,33 @@ +from govoplan_postbox.backend.db.models import ( + Postbox, + PostboxAccessEvent, + PostboxAddress, + PostboxAttachmentReference, + PostboxBinding, + PostboxDelivery, + PostboxGrouping, + PostboxGroupingSource, + PostboxMessage, + PostboxMessageReceipt, + PostboxParticipant, + PostboxRoute, + PostboxTemplate, + PostboxTemplateRevision, +) + +__all__ = [ + "Postbox", + "PostboxAccessEvent", + "PostboxAddress", + "PostboxAttachmentReference", + "PostboxBinding", + "PostboxDelivery", + "PostboxGrouping", + "PostboxGroupingSource", + "PostboxMessage", + "PostboxMessageReceipt", + "PostboxParticipant", + "PostboxRoute", + "PostboxTemplate", + "PostboxTemplateRevision", +] diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py new file mode 100644 index 0000000..5de8b8b --- /dev/null +++ b/src/govoplan_postbox/backend/db/models.py @@ -0,0 +1,873 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class PostboxTemplate(Base, TimestampMixin): + __tablename__ = "postbox_templates" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "slug", + name="uq_postbox_templates_tenant_slug", + ), + Index("ix_postbox_templates_tenant_status", "tenant_id", "status"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(120), nullable=False) + name: Mapped[str] = mapped_column(String(250), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + String(24), + default="draft", + nullable=False, + index=True, + ) + current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + published_revision_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + retired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + revisions: Mapped[list["PostboxTemplateRevision"]] = relationship( + back_populates="template", + cascade="all, delete-orphan", + order_by="PostboxTemplateRevision.revision", + ) + + +class PostboxTemplateRevision(Base, TimestampMixin): + __tablename__ = "postbox_template_revisions" + __table_args__ = ( + UniqueConstraint( + "template_id", + "revision", + name="uq_postbox_template_revision_number", + ), + Index( + "ix_postbox_template_revisions_function_scope", + "tenant_id", + "function_type_id", + "scope_kind", + "scope_id", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + template_id: Mapped[str] = mapped_column( + ForeignKey("postbox_templates.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + function_type_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + scope_kind: Mapped[str] = mapped_column( + String(30), + default="tenant", + nullable=False, + ) + scope_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + name_pattern: Mapped[str] = mapped_column( + String(500), + default="{unit_name} / {function_name}", + nullable=False, + ) + address_pattern: Mapped[str] = mapped_column( + String(500), + default="{template_slug}.{unit_slug}.{function_slug}", + nullable=False, + ) + classification: Mapped[str] = mapped_column( + String(50), + default="internal", + nullable=False, + ) + allow_vacant_delivery: Mapped[bool] = mapped_column( + Boolean, + default=True, + nullable=False, + ) + encryption_profile: Mapped[str] = mapped_column( + String(80), + default="plaintext_v1", + nullable=False, + ) + history_policy: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + routing_policy: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + retention_policy: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + index=True, + ) + + template: Mapped[PostboxTemplate] = relationship(back_populates="revisions") + + +class PostboxAddress(Base, TimestampMixin): + __tablename__ = "postbox_addresses" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "address_key", + name="uq_postbox_addresses_tenant_key", + ), + UniqueConstraint( + "tenant_id", + "address", + name="uq_postbox_addresses_tenant_address", + ), + Index( + "ix_postbox_addresses_function_scope", + "tenant_id", + "organization_unit_id", + "function_id", + "context_key", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + address_key: Mapped[str] = mapped_column(String(500), nullable=False) + address: Mapped[str] = mapped_column(String(500), nullable=False) + template_id: Mapped[str | None] = mapped_column( + ForeignKey("postbox_templates.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + template_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("postbox_template_revisions.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + organization_unit_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + organization_unit_name: Mapped[str | None] = mapped_column( + String(500), + nullable=True, + ) + function_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + function_name: Mapped[str | None] = mapped_column(String(500), nullable=True) + function_type_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + context_key: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + status: Mapped[str] = mapped_column( + String(24), + default="active", + nullable=False, + index=True, + ) + + postbox: Mapped["Postbox | None"] = relationship( + back_populates="address_record", + uselist=False, + ) + + +class Postbox(Base, TimestampMixin): + __tablename__ = "postboxes" + __table_args__ = ( + UniqueConstraint("address_id", name="uq_postboxes_address"), + Index("ix_postboxes_tenant_status", "tenant_id", "status"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + address_id: Mapped[str] = mapped_column( + ForeignKey("postbox_addresses.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + String(24), + default="active", + nullable=False, + index=True, + ) + classification: Mapped[str] = mapped_column( + String(50), + default="internal", + nullable=False, + index=True, + ) + encryption_profile: Mapped[str] = mapped_column( + String(80), + default="plaintext_v1", + nullable=False, + ) + key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + archived_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + address_record: Mapped[PostboxAddress] = relationship( + back_populates="postbox", + ) + bindings: Mapped[list["PostboxBinding"]] = relationship( + back_populates="postbox", + cascade="all, delete-orphan", + ) + messages: Mapped[list["PostboxMessage"]] = relationship( + back_populates="postbox", + cascade="all, delete-orphan", + ) + + +class PostboxBinding(Base, TimestampMixin): + __tablename__ = "postbox_bindings" + __table_args__ = ( + Index( + "ix_postbox_bindings_function_scope", + "tenant_id", + "organization_unit_id", + "function_id", + "is_active", + ), + Index("ix_postbox_bindings_postbox_active", "postbox_id", "is_active"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + postbox_id: Mapped[str] = mapped_column( + ForeignKey("postboxes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + binding_type: Mapped[str] = mapped_column( + String(30), + default="function", + nullable=False, + ) + organization_unit_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + function_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + function_type_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + applies_to_subunits: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + source: Mapped[str] = mapped_column( + String(30), + default="exact", + nullable=False, + ) + is_active: Mapped[bool] = mapped_column( + Boolean, + default=True, + nullable=False, + index=True, + ) + valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + valid_until: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + settings: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + + postbox: Mapped[Postbox] = relationship(back_populates="bindings") + + +class PostboxMessage(Base, TimestampMixin): + __tablename__ = "postbox_messages" + __table_args__ = ( + Index( + "ix_postbox_messages_postbox_delivered", + "postbox_id", + "delivered_at", + ), + Index("ix_postbox_messages_tenant_status", "tenant_id", "status"), + Index( + "ix_postbox_messages_producer", + "tenant_id", + "producer_module", + "producer_resource_type", + "producer_resource_id", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + postbox_id: Mapped[str] = mapped_column( + ForeignKey("postboxes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + subject: Mapped[str] = mapped_column(String(1000), nullable=False) + body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + String(30), + default="delivered", + nullable=False, + index=True, + ) + classification: Mapped[str] = mapped_column( + String(50), + default="internal", + nullable=False, + index=True, + ) + sender_label: Mapped[str | None] = mapped_column(String(500), nullable=True) + producer_module: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + index=True, + ) + producer_resource_type: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + index=True, + ) + producer_resource_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + in_reply_to_message_id: Mapped[str | None] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + replaces_message_id: Mapped[str | None] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + encryption_profile: Mapped[str] = mapped_column( + String(80), + default="plaintext_v1", + nullable=False, + ) + key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + ciphertext_ref: Mapped[str | None] = mapped_column( + String(1000), + nullable=True, + ) + signed_manifest_ref: Mapped[str | None] = mapped_column( + String(1000), + nullable=True, + ) + wrapped_keys: Mapped[list[dict[str, Any]]] = mapped_column( + JSON, + default=list, + nullable=False, + ) + delivered_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + index=True, + ) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + index=True, + ) + withdrawn_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + index=True, + ) + retention_hold_until: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", + JSON, + default=dict, + nullable=False, + ) + + postbox: Mapped[Postbox] = relationship(back_populates="messages") + participants: Mapped[list["PostboxParticipant"]] = relationship( + back_populates="message", + cascade="all, delete-orphan", + order_by="PostboxParticipant.position", + ) + attachments: Mapped[list["PostboxAttachmentReference"]] = relationship( + back_populates="message", + cascade="all, delete-orphan", + order_by="PostboxAttachmentReference.position", + ) + receipts: Mapped[list["PostboxMessageReceipt"]] = relationship( + back_populates="message", + cascade="all, delete-orphan", + ) + + +class PostboxParticipant(Base, TimestampMixin): + __tablename__ = "postbox_participants" + __table_args__ = ( + Index("ix_postbox_participants_message_kind", "message_id", "kind"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + message_id: Mapped[str] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + kind: Mapped[str] = mapped_column(String(30), nullable=False) + reference_type: Mapped[str] = mapped_column(String(50), nullable=False) + reference_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + label: Mapped[str | None] = mapped_column(String(500), nullable=True) + address: Mapped[str | None] = mapped_column(String(500), nullable=True) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", + JSON, + default=dict, + nullable=False, + ) + + message: Mapped[PostboxMessage] = relationship(back_populates="participants") + + +class PostboxAttachmentReference(Base, TimestampMixin): + __tablename__ = "postbox_attachment_references" + __table_args__ = ( + Index( + "ix_postbox_attachment_references_target", + "tenant_id", + "reference_type", + "reference_id", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + message_id: Mapped[str] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + reference_type: Mapped[str] = mapped_column(String(50), nullable=False) + reference_id: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str | None] = mapped_column(String(1000), nullable=True) + media_type: Mapped[str | None] = mapped_column(String(255), nullable=True) + size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + digest: Mapped[str | None] = mapped_column(String(255), nullable=True) + ciphertext_ref: Mapped[str | None] = mapped_column( + String(1000), + nullable=True, + ) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", + JSON, + default=dict, + nullable=False, + ) + + message: Mapped[PostboxMessage] = relationship(back_populates="attachments") + + +class PostboxDelivery(Base, TimestampMixin): + __tablename__ = "postbox_deliveries" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "producer_module", + "idempotency_key", + name="uq_postbox_deliveries_producer_idempotency", + ), + Index("ix_postbox_deliveries_postbox_status", "postbox_id", "status"), + Index( + "ix_postbox_deliveries_producer", + "tenant_id", + "producer_module", + "producer_resource_type", + "producer_resource_id", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + postbox_id: Mapped[str] = mapped_column( + ForeignKey("postboxes.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + message_id: Mapped[str] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + producer_module: Mapped[str] = mapped_column(String(100), nullable=False) + producer_resource_type: Mapped[str] = mapped_column(String(100), nullable=False) + producer_resource_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[str] = mapped_column( + String(40), + default="accepted", + nullable=False, + index=True, + ) + template_revision_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + organization_unit_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + function_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + holder_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + target_snapshot: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + accepted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + index=True, + ) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", + JSON, + default=dict, + nullable=False, + ) + + +class PostboxRoute(Base, TimestampMixin): + __tablename__ = "postbox_routes" + __table_args__ = ( + Index("ix_postbox_routes_delivery_kind", "delivery_id", "route_kind"), + Index("ix_postbox_routes_target", "tenant_id", "target_postbox_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + delivery_id: Mapped[str] = mapped_column( + ForeignKey("postbox_deliveries.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source_postbox_id: Mapped[str] = mapped_column( + ForeignKey("postboxes.id", ondelete="RESTRICT"), + nullable=False, + ) + source_message_id: Mapped[str] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="RESTRICT"), + nullable=False, + ) + target_postbox_id: Mapped[str | None] = mapped_column( + ForeignKey("postboxes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + target_message_id: Mapped[str | None] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="SET NULL"), + nullable=True, + ) + route_kind: Mapped[str] = mapped_column(String(40), nullable=False) + status: Mapped[str] = mapped_column(String(40), nullable=False) + depth: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + source_route_id: Mapped[str | None] = mapped_column( + ForeignKey("postbox_routes.id", ondelete="SET NULL"), + nullable=True, + ) + policy_snapshot: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + + +class PostboxMessageReceipt(Base, TimestampMixin): + __tablename__ = "postbox_message_receipts" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "message_id", + "account_id", + name="uq_postbox_receipts_message_account", + ), + Index( + "ix_postbox_receipts_account_state", + "tenant_id", + "account_id", + "read_at", + "acknowledged_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + message_id: Mapped[str] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + identity_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + assignment_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + read_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + acknowledged_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", + JSON, + default=dict, + nullable=False, + ) + + message: Mapped[PostboxMessage] = relationship(back_populates="receipts") + + +class PostboxGrouping(Base, TimestampMixin): + __tablename__ = "postbox_groupings" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "account_id", + "name", + name="uq_postbox_groupings_account_name", + ), + Index("ix_postbox_groupings_account", "tenant_id", "account_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(250), nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + + sources: Mapped[list["PostboxGroupingSource"]] = relationship( + back_populates="grouping", + cascade="all, delete-orphan", + order_by="PostboxGroupingSource.position", + ) + + +class PostboxGroupingSource(Base, TimestampMixin): + __tablename__ = "postbox_grouping_sources" + __table_args__ = ( + UniqueConstraint( + "grouping_id", + "postbox_id", + name="uq_postbox_grouping_sources_postbox", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + grouping_id: Mapped[str] = mapped_column( + ForeignKey("postbox_groupings.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + postbox_id: Mapped[str] = mapped_column( + ForeignKey("postboxes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + grouping: Mapped[PostboxGrouping] = relationship(back_populates="sources") + + +class PostboxAccessEvent(Base, TimestampMixin): + __tablename__ = "postbox_access_events" + __table_args__ = ( + Index( + "ix_postbox_access_events_resource", + "tenant_id", + "postbox_id", + "message_id", + "occurred_at", + ), + Index( + "ix_postbox_access_events_actor", + "tenant_id", + "account_id", + "occurred_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + postbox_id: Mapped[str | None] = mapped_column( + ForeignKey("postboxes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + message_id: Mapped[str | None] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + account_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + identity_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + assignment_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + action: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + outcome: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + reason_code: Mapped[str] = mapped_column(String(80), nullable=False) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + index=True, + ) + details: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + + +__all__ = [ + "Postbox", + "PostboxAccessEvent", + "PostboxAddress", + "PostboxAttachmentReference", + "PostboxBinding", + "PostboxDelivery", + "PostboxGrouping", + "PostboxGroupingSource", + "PostboxMessage", + "PostboxMessageReceipt", + "PostboxParticipant", + "PostboxRoute", + "PostboxTemplate", + "PostboxTemplateRevision", + "new_uuid", +] diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py new file mode 100644 index 0000000..0dc4b47 --- /dev/null +++ b/src/govoplan_postbox/backend/manifest.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.access import ( + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) +from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY +from govoplan_core.core.idm import ( + CAPABILITY_IDM_DIRECTORY, + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, +) +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleInterfaceRequirement, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH +from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY +from govoplan_core.core.postbox import ( + CAPABILITY_POSTBOX_ACCESS, + CAPABILITY_POSTBOX_DELIVERY, + CAPABILITY_POSTBOX_DIRECTORY, + CAPABILITY_POSTBOX_EVIDENCE, + CAPABILITY_POSTBOX_MESSAGES, +) +from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_postbox.backend.db import models as postbox_models + + +MODULE_ID = "postbox" +MODULE_NAME = "Postbox" +MODULE_VERSION = "0.1.1" + +READ_SCOPE = "postbox:postbox:read" +SEND_SCOPE = "postbox:message:write" +ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge" +DELIVERY_SCOPE = "postbox:delivery:write" +BINDING_ADMIN_SCOPE = "postbox:binding:admin" +TEMPLATE_ADMIN_SCOPE = "postbox:template:admin" + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Postbox", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission( + READ_SCOPE, + "View assigned postboxes", + "Discover and read postboxes for currently effective organization-function assignments.", + ), + _permission( + SEND_SCOPE, + "Send through assigned postboxes", + "Create replies and new messages in postboxes available through the current function context.", + ), + _permission( + ACKNOWLEDGE_SCOPE, + "Acknowledge postbox messages", + "Record personal read and acknowledgement state for accessible messages.", + ), + _permission( + DELIVERY_SCOPE, + "Deliver to postboxes", + "Accept idempotent deliveries from approved platform producers.", + ), + _permission( + BINDING_ADMIN_SCOPE, + "Administer postbox bindings", + "Create, archive, and inspect exact organization-function postboxes and bindings.", + ), + _permission( + TEMPLATE_ADMIN_SCOPE, + "Administer postbox templates", + "Create, revise, publish, and retire reusable function-scoped postbox templates.", + ), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="postbox_user", + name="Postbox user", + description="Use postboxes available through current function assignments.", + permissions=(READ_SCOPE, SEND_SCOPE, ACKNOWLEDGE_SCOPE), + default_authenticated=True, + ), + RoleTemplate( + slug="postbox_manager", + name="Postbox manager", + description="Administer postbox templates and concrete function-bound containers.", + permissions=( + READ_SCOPE, + SEND_SCOPE, + ACKNOWLEDGE_SCOPE, + DELIVERY_SCOPE, + BINDING_ADMIN_SCOPE, + TEMPLATE_ADMIN_SCOPE, + ), + ), +) + + +def _configure(context: ModuleContext): + from govoplan_postbox.backend.runtime import configure_runtime, get_service + + configure_runtime(registry=context.registry) + return get_service() + + +def _router(context: ModuleContext): + _configure(context) + from govoplan_postbox.backend.router import router + + return router + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + return { + "postboxes": session.query(postbox_models.Postbox) + .filter( + postbox_models.Postbox.tenant_id == tenant_id, + postbox_models.Postbox.status == "active", + ) + .count(), + "postbox_messages": session.query(postbox_models.PostboxMessage) + .filter(postbox_models.PostboxMessage.tenant_id == tenant_id) + .count(), + "vacant_deliveries": session.query(postbox_models.PostboxDelivery) + .filter( + postbox_models.PostboxDelivery.tenant_id == tenant_id, + postbox_models.PostboxDelivery.status == "accepted_vacant", + ) + .count(), + } + + +_OWNED_TABLES = ( + postbox_models.PostboxAccessEvent, + postbox_models.PostboxGroupingSource, + postbox_models.PostboxGrouping, + postbox_models.PostboxMessageReceipt, + postbox_models.PostboxRoute, + postbox_models.PostboxDelivery, + postbox_models.PostboxAttachmentReference, + postbox_models.PostboxParticipant, + postbox_models.PostboxMessage, + postbox_models.PostboxBinding, + postbox_models.Postbox, + postbox_models.PostboxAddress, + postbox_models.PostboxTemplateRevision, + postbox_models.PostboxTemplate, +) + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + dependencies=("identity", "organizations", "idm"), + optional_dependencies=( + "access", + "audit", + "campaigns", + "files", + "mail", + "notifications", + "policy", + "portal", + "views", + "workflow", + ), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_IDENTITY_DIRECTORY, + CAPABILITY_IDM_DIRECTORY, + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + CAPABILITY_ORGANIZATION_DIRECTORY, + ), + provides_interfaces=tuple( + ModuleInterfaceProvider(name=name, version=MODULE_VERSION) + for name in ( + CAPABILITY_POSTBOX_DIRECTORY, + CAPABILITY_POSTBOX_ACCESS, + CAPABILITY_POSTBOX_MESSAGES, + CAPABILITY_POSTBOX_DELIVERY, + CAPABILITY_POSTBOX_EVIDENCE, + ) + ), + requires_interfaces=( + ModuleInterfaceRequirement( + name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + version_min="0.1.8", + version_max_exclusive="0.2.0", + ), + ModuleInterfaceRequirement( + name=CAPABILITY_NOTIFICATIONS_DISPATCH, + version_min="0.1.8", + version_max_exclusive="0.2.0", + optional=True, + ), + ), + permissions=PERMISSIONS, + role_templates=ROLE_TEMPLATES, + nav_items=( + NavItem( + path="/postbox", + label="Postbox", + icon="inbox", + required_any=(READ_SCOPE,), + order=58, + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/postbox-webui", + routes=( + FrontendRoute( + path="/postbox", + component="PostboxPage", + required_any=(READ_SCOPE,), + order=58, + ), + ), + nav_items=( + NavItem( + path="/postbox", + label="Postbox", + icon="inbox", + required_any=(READ_SCOPE,), + order=58, + ), + ), + view_surfaces=( + ViewSurface( + id="postbox.inbox.directory", + module_id=MODULE_ID, + kind="section", + label="Postbox directory", + order=10, + ), + ViewSurface( + id="postbox.inbox.messages", + module_id=MODULE_ID, + kind="section", + label="Postbox messages", + order=20, + ), + ViewSurface( + id="postbox.widget.inbox", + module_id=MODULE_ID, + kind="section", + label="Postbox inbox widget", + order=25, + ), + ViewSurface( + id="postbox.admin.templates", + module_id=MODULE_ID, + kind="section", + label="Postbox templates and bindings", + order=30, + ), + ), + ), + route_factory=_router, + tenant_summary_providers=(_tenant_summary,), + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + *_OWNED_TABLES, + label="Postbox", + ), + retirement_notes=( + "Destructive retirement removes Postbox templates, addresses, " + "messages, delivery evidence, receipts, groupings, and access events " + "after the installer captures a database snapshot." + ), + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + postbox_models.Postbox, + postbox_models.PostboxMessage, + postbox_models.PostboxDelivery, + label="Postbox", + ), + ), + capability_factories={ + CAPABILITY_POSTBOX_DIRECTORY: _configure, + CAPABILITY_POSTBOX_ACCESS: _configure, + CAPABILITY_POSTBOX_MESSAGES: _configure, + CAPABILITY_POSTBOX_DELIVERY: _configure, + CAPABILITY_POSTBOX_EVIDENCE: _configure, + }, + documentation=( + DocumentationTopic( + id="postbox.function-bound-containers", + title="Function-bound Postboxes", + summary=( + "Durable institutional message containers whose access follows " + "effective organization-function assignments." + ), + body=( + "Postboxes belong to responsibilities, not individual accounts. " + "A stable postbox remains addressable during vacancy and " + "reassignment. Current access combines a generic Postbox " + "permission with effective IDM assignment context. Templates " + "can lazily materialize unit-specific addresses, while exact " + "postboxes cover exceptional responsibilities. The first " + "implementation persists plaintext but reserves explicit " + "ciphertext, signed-manifest, and key-epoch fields; it does not " + "claim end-to-end encryption until a trusted policy profile is selected." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("administrator", "user", "campaign_manager"), + related_modules=( + "identity", + "idm", + "organizations", + "campaigns", + "files", + "notifications", + ), + order=35, + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_postbox/backend/migrations/__init__.py b/src/govoplan_postbox/backend/migrations/__init__.py new file mode 100644 index 0000000..30230f1 --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Postbox-owned Alembic migrations.""" diff --git a/src/govoplan_postbox/backend/migrations/versions/__init__.py b/src/govoplan_postbox/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..c0ff685 --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Postbox release migration lineage.""" diff --git a/src/govoplan_postbox/backend/migrations/versions/c7d2e5f8a1b4_v010_postbox_baseline.py b/src/govoplan_postbox/backend/migrations/versions/c7d2e5f8a1b4_v010_postbox_baseline.py new file mode 100644 index 0000000..1356f35 --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/c7d2e5f8a1b4_v010_postbox_baseline.py @@ -0,0 +1,798 @@ +"""v0.1.0 Postbox baseline + +Revision ID: c7d2e5f8a1b4 +Revises: None +Depends on: 8f9a0b1c2d3e +Create Date: 2026-07-28 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c7d2e5f8a1b4" +down_revision = None +branch_labels = None +depends_on = "8f9a0b1c2d3e" + + +def _timestamps() -> tuple[sa.Column, sa.Column]: + return ( + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def upgrade() -> None: + op.create_table( + "postbox_templates", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("slug", sa.String(length=120), nullable=False), + sa.Column("name", sa.String(length=250), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("current_revision", sa.Integer(), nullable=False), + sa.Column("published_revision_id", sa.String(length=36), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True), + *_timestamps(), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_templates")), + sa.UniqueConstraint( + "tenant_id", + "slug", + name="uq_postbox_templates_tenant_slug", + ), + ) + op.create_index( + op.f("ix_postbox_templates_tenant_id"), + "postbox_templates", + ["tenant_id"], + ) + op.create_index( + op.f("ix_postbox_templates_status"), + "postbox_templates", + ["status"], + ) + op.create_index( + op.f("ix_postbox_templates_published_revision_id"), + "postbox_templates", + ["published_revision_id"], + ) + op.create_index( + "ix_postbox_templates_tenant_status", + "postbox_templates", + ["tenant_id", "status"], + ) + + op.create_table( + "postbox_template_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("template_id", sa.String(length=36), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("function_type_id", sa.String(length=36), nullable=True), + sa.Column("scope_kind", sa.String(length=30), nullable=False), + sa.Column("scope_id", sa.String(length=255), nullable=True), + sa.Column("name_pattern", sa.String(length=500), nullable=False), + sa.Column("address_pattern", sa.String(length=500), nullable=False), + sa.Column("classification", sa.String(length=50), nullable=False), + sa.Column("allow_vacant_delivery", sa.Boolean(), nullable=False), + sa.Column("encryption_profile", sa.String(length=80), nullable=False), + sa.Column("history_policy", sa.JSON(), nullable=False), + sa.Column("routing_policy", sa.JSON(), nullable=False), + sa.Column("retention_policy", sa.JSON(), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + *_timestamps(), + sa.ForeignKeyConstraint( + ["template_id"], + ["postbox_templates.id"], + name=op.f( + "fk_postbox_template_revisions_template_id_postbox_templates" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_postbox_template_revisions"), + ), + sa.UniqueConstraint( + "template_id", + "revision", + name="uq_postbox_template_revision_number", + ), + ) + for column in ( + "tenant_id", + "template_id", + "function_type_id", + "scope_id", + "published_at", + ): + op.create_index( + op.f(f"ix_postbox_template_revisions_{column}"), + "postbox_template_revisions", + [column], + ) + op.create_index( + "ix_postbox_template_revisions_function_scope", + "postbox_template_revisions", + ["tenant_id", "function_type_id", "scope_kind", "scope_id"], + ) + + op.create_table( + "postbox_addresses", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("address_key", sa.String(length=500), nullable=False), + sa.Column("address", sa.String(length=500), nullable=False), + sa.Column("template_id", sa.String(length=36), nullable=True), + sa.Column("template_revision_id", sa.String(length=36), nullable=True), + sa.Column("organization_unit_id", sa.String(length=36), nullable=True), + sa.Column("organization_unit_name", sa.String(length=500), nullable=True), + sa.Column("function_id", sa.String(length=36), nullable=True), + sa.Column("function_name", sa.String(length=500), nullable=True), + sa.Column("function_type_id", sa.String(length=36), nullable=True), + sa.Column("context_key", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["template_id"], + ["postbox_templates.id"], + name=op.f("fk_postbox_addresses_template_id_postbox_templates"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["template_revision_id"], + ["postbox_template_revisions.id"], + name=op.f( + "fk_postbox_addresses_template_revision_id_postbox_template_revisions" + ), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_addresses")), + sa.UniqueConstraint( + "tenant_id", + "address_key", + name="uq_postbox_addresses_tenant_key", + ), + sa.UniqueConstraint( + "tenant_id", + "address", + name="uq_postbox_addresses_tenant_address", + ), + ) + for column in ( + "tenant_id", + "template_id", + "template_revision_id", + "organization_unit_id", + "function_id", + "function_type_id", + "context_key", + "status", + ): + op.create_index( + op.f(f"ix_postbox_addresses_{column}"), + "postbox_addresses", + [column], + ) + op.create_index( + "ix_postbox_addresses_function_scope", + "postbox_addresses", + [ + "tenant_id", + "organization_unit_id", + "function_id", + "context_key", + ], + ) + + op.create_table( + "postboxes", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("address_id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("classification", sa.String(length=50), nullable=False), + sa.Column("encryption_profile", sa.String(length=80), nullable=False), + sa.Column("key_epoch", sa.Integer(), nullable=False), + sa.Column("settings", sa.JSON(), nullable=False), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + *_timestamps(), + sa.ForeignKeyConstraint( + ["address_id"], + ["postbox_addresses.id"], + name=op.f("fk_postboxes_address_id_postbox_addresses"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postboxes")), + sa.UniqueConstraint("address_id", name="uq_postboxes_address"), + ) + for column in ("tenant_id", "address_id", "status", "classification"): + op.create_index( + op.f(f"ix_postboxes_{column}"), + "postboxes", + [column], + ) + op.create_index( + "ix_postboxes_tenant_status", + "postboxes", + ["tenant_id", "status"], + ) + + op.create_table( + "postbox_bindings", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("postbox_id", sa.String(length=36), nullable=False), + sa.Column("binding_type", sa.String(length=30), nullable=False), + sa.Column("organization_unit_id", sa.String(length=36), nullable=True), + sa.Column("function_id", sa.String(length=36), nullable=True), + sa.Column("function_type_id", sa.String(length=36), nullable=True), + sa.Column("applies_to_subunits", sa.Boolean(), nullable=False), + sa.Column("source", sa.String(length=30), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("settings", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["postbox_id"], + ["postboxes.id"], + name=op.f("fk_postbox_bindings_postbox_id_postboxes"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_bindings")), + ) + for column in ( + "tenant_id", + "postbox_id", + "organization_unit_id", + "function_id", + "function_type_id", + "is_active", + ): + op.create_index( + op.f(f"ix_postbox_bindings_{column}"), + "postbox_bindings", + [column], + ) + op.create_index( + "ix_postbox_bindings_function_scope", + "postbox_bindings", + [ + "tenant_id", + "organization_unit_id", + "function_id", + "is_active", + ], + ) + op.create_index( + "ix_postbox_bindings_postbox_active", + "postbox_bindings", + ["postbox_id", "is_active"], + ) + + op.create_table( + "postbox_messages", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("postbox_id", sa.String(length=36), nullable=False), + sa.Column("subject", sa.String(length=1000), nullable=False), + sa.Column("body_text", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("classification", sa.String(length=50), nullable=False), + sa.Column("sender_label", sa.String(length=500), nullable=True), + sa.Column("producer_module", sa.String(length=100), nullable=True), + sa.Column("producer_resource_type", sa.String(length=100), nullable=True), + sa.Column("producer_resource_id", sa.String(length=255), nullable=True), + sa.Column("in_reply_to_message_id", sa.String(length=36), nullable=True), + sa.Column("replaces_message_id", sa.String(length=36), nullable=True), + sa.Column("encryption_profile", sa.String(length=80), nullable=False), + sa.Column("key_epoch", sa.Integer(), nullable=False), + sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True), + sa.Column("signed_manifest_ref", sa.String(length=1000), nullable=True), + sa.Column("wrapped_keys", sa.JSON(), nullable=False), + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "retention_hold_until", + sa.DateTime(timezone=True), + nullable=True, + ), + sa.Column("metadata", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["postbox_id"], + ["postboxes.id"], + name=op.f("fk_postbox_messages_postbox_id_postboxes"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["in_reply_to_message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_messages_in_reply_to_message_id_postbox_messages" + ), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["replaces_message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_messages_replaces_message_id_postbox_messages" + ), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_messages")), + ) + for column in ( + "tenant_id", + "postbox_id", + "status", + "classification", + "producer_module", + "producer_resource_type", + "producer_resource_id", + "in_reply_to_message_id", + "replaces_message_id", + "delivered_at", + "expires_at", + "withdrawn_at", + ): + op.create_index( + op.f(f"ix_postbox_messages_{column}"), + "postbox_messages", + [column], + ) + op.create_index( + "ix_postbox_messages_postbox_delivered", + "postbox_messages", + ["postbox_id", "delivered_at"], + ) + op.create_index( + "ix_postbox_messages_tenant_status", + "postbox_messages", + ["tenant_id", "status"], + ) + op.create_index( + "ix_postbox_messages_producer", + "postbox_messages", + [ + "tenant_id", + "producer_module", + "producer_resource_type", + "producer_resource_id", + ], + ) + + op.create_table( + "postbox_participants", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("message_id", sa.String(length=36), nullable=False), + sa.Column("kind", sa.String(length=30), nullable=False), + sa.Column("reference_type", sa.String(length=50), nullable=False), + sa.Column("reference_id", sa.String(length=255), nullable=True), + sa.Column("label", sa.String(length=500), nullable=True), + sa.Column("address", sa.String(length=500), nullable=True), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("metadata", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_participants_message_id_postbox_messages" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_participants")), + ) + for column in ("tenant_id", "message_id", "reference_id"): + op.create_index( + op.f(f"ix_postbox_participants_{column}"), + "postbox_participants", + [column], + ) + op.create_index( + "ix_postbox_participants_message_kind", + "postbox_participants", + ["message_id", "kind"], + ) + + op.create_table( + "postbox_attachment_references", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("message_id", sa.String(length=36), nullable=False), + sa.Column("reference_type", sa.String(length=50), nullable=False), + sa.Column("reference_id", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=1000), nullable=True), + sa.Column("media_type", sa.String(length=255), nullable=True), + sa.Column("size_bytes", sa.Integer(), nullable=True), + sa.Column("digest", sa.String(length=255), nullable=True), + sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("metadata", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_attachment_references_message_id_postbox_messages" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_postbox_attachment_references"), + ), + ) + for column in ("tenant_id", "message_id"): + op.create_index( + op.f(f"ix_postbox_attachment_references_{column}"), + "postbox_attachment_references", + [column], + ) + op.create_index( + "ix_postbox_attachment_references_target", + "postbox_attachment_references", + ["tenant_id", "reference_type", "reference_id"], + ) + + op.create_table( + "postbox_deliveries", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("postbox_id", sa.String(length=36), nullable=False), + sa.Column("message_id", sa.String(length=36), nullable=False), + sa.Column("producer_module", sa.String(length=100), nullable=False), + sa.Column("producer_resource_type", sa.String(length=100), nullable=False), + sa.Column("producer_resource_id", sa.String(length=255), nullable=True), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("template_revision_id", sa.String(length=36), nullable=True), + sa.Column("organization_unit_id", sa.String(length=36), nullable=True), + sa.Column("function_id", sa.String(length=36), nullable=True), + sa.Column("holder_count", sa.Integer(), nullable=False), + sa.Column("target_snapshot", sa.JSON(), nullable=False), + sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("metadata", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["postbox_id"], + ["postboxes.id"], + name=op.f("fk_postbox_deliveries_postbox_id_postboxes"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_deliveries_message_id_postbox_messages" + ), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_deliveries")), + sa.UniqueConstraint( + "tenant_id", + "producer_module", + "idempotency_key", + name="uq_postbox_deliveries_producer_idempotency", + ), + ) + for column in ( + "tenant_id", + "postbox_id", + "message_id", + "producer_resource_id", + "status", + "template_revision_id", + "organization_unit_id", + "function_id", + "accepted_at", + ): + op.create_index( + op.f(f"ix_postbox_deliveries_{column}"), + "postbox_deliveries", + [column], + ) + op.create_index( + "ix_postbox_deliveries_postbox_status", + "postbox_deliveries", + ["postbox_id", "status"], + ) + op.create_index( + "ix_postbox_deliveries_producer", + "postbox_deliveries", + [ + "tenant_id", + "producer_module", + "producer_resource_type", + "producer_resource_id", + ], + ) + + op.create_table( + "postbox_routes", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("delivery_id", sa.String(length=36), nullable=False), + sa.Column("source_postbox_id", sa.String(length=36), nullable=False), + sa.Column("source_message_id", sa.String(length=36), nullable=False), + sa.Column("target_postbox_id", sa.String(length=36), nullable=True), + sa.Column("target_message_id", sa.String(length=36), nullable=True), + sa.Column("route_kind", sa.String(length=40), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("depth", sa.Integer(), nullable=False), + sa.Column("source_route_id", sa.String(length=36), nullable=True), + sa.Column("policy_snapshot", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["delivery_id"], + ["postbox_deliveries.id"], + name=op.f("fk_postbox_routes_delivery_id_postbox_deliveries"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["source_postbox_id"], + ["postboxes.id"], + name=op.f("fk_postbox_routes_source_postbox_id_postboxes"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["source_message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_routes_source_message_id_postbox_messages" + ), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["target_postbox_id"], + ["postboxes.id"], + name=op.f("fk_postbox_routes_target_postbox_id_postboxes"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["target_message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_routes_target_message_id_postbox_messages" + ), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["source_route_id"], + ["postbox_routes.id"], + name=op.f("fk_postbox_routes_source_route_id_postbox_routes"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_routes")), + ) + for column in ("tenant_id", "delivery_id", "target_postbox_id"): + op.create_index( + op.f(f"ix_postbox_routes_{column}"), + "postbox_routes", + [column], + ) + op.create_index( + "ix_postbox_routes_delivery_kind", + "postbox_routes", + ["delivery_id", "route_kind"], + ) + op.create_index( + "ix_postbox_routes_target", + "postbox_routes", + ["tenant_id", "target_postbox_id"], + ) + + op.create_table( + "postbox_message_receipts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("message_id", sa.String(length=36), nullable=False), + sa.Column("account_id", sa.String(length=255), nullable=False), + sa.Column("identity_id", sa.String(length=255), nullable=True), + sa.Column("assignment_id", sa.String(length=36), nullable=True), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_message_receipts_message_id_postbox_messages" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_postbox_message_receipts"), + ), + sa.UniqueConstraint( + "tenant_id", + "message_id", + "account_id", + name="uq_postbox_receipts_message_account", + ), + ) + for column in ( + "tenant_id", + "message_id", + "account_id", + "identity_id", + "assignment_id", + ): + op.create_index( + op.f(f"ix_postbox_message_receipts_{column}"), + "postbox_message_receipts", + [column], + ) + op.create_index( + "ix_postbox_receipts_account_state", + "postbox_message_receipts", + [ + "tenant_id", + "account_id", + "read_at", + "acknowledged_at", + ], + ) + + op.create_table( + "postbox_groupings", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("account_id", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=250), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False), + sa.Column("settings", sa.JSON(), nullable=False), + *_timestamps(), + sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_groupings")), + sa.UniqueConstraint( + "tenant_id", + "account_id", + "name", + name="uq_postbox_groupings_account_name", + ), + ) + for column in ("tenant_id", "account_id"): + op.create_index( + op.f(f"ix_postbox_groupings_{column}"), + "postbox_groupings", + [column], + ) + op.create_index( + "ix_postbox_groupings_account", + "postbox_groupings", + ["tenant_id", "account_id"], + ) + + op.create_table( + "postbox_grouping_sources", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("grouping_id", sa.String(length=36), nullable=False), + sa.Column("postbox_id", sa.String(length=36), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["grouping_id"], + ["postbox_groupings.id"], + name=op.f( + "fk_postbox_grouping_sources_grouping_id_postbox_groupings" + ), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["postbox_id"], + ["postboxes.id"], + name=op.f( + "fk_postbox_grouping_sources_postbox_id_postboxes" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_postbox_grouping_sources"), + ), + sa.UniqueConstraint( + "grouping_id", + "postbox_id", + name="uq_postbox_grouping_sources_postbox", + ), + ) + for column in ("tenant_id", "grouping_id", "postbox_id"): + op.create_index( + op.f(f"ix_postbox_grouping_sources_{column}"), + "postbox_grouping_sources", + [column], + ) + + op.create_table( + "postbox_access_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("postbox_id", sa.String(length=36), nullable=True), + sa.Column("message_id", sa.String(length=36), nullable=True), + sa.Column("account_id", sa.String(length=255), nullable=True), + sa.Column("identity_id", sa.String(length=255), nullable=True), + sa.Column("assignment_id", sa.String(length=36), nullable=True), + sa.Column("action", sa.String(length=80), nullable=False), + sa.Column("outcome", sa.String(length=30), nullable=False), + sa.Column("reason_code", sa.String(length=80), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("details", sa.JSON(), nullable=False), + *_timestamps(), + sa.ForeignKeyConstraint( + ["postbox_id"], + ["postboxes.id"], + name=op.f("fk_postbox_access_events_postbox_id_postboxes"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["message_id"], + ["postbox_messages.id"], + name=op.f( + "fk_postbox_access_events_message_id_postbox_messages" + ), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_postbox_access_events"), + ), + ) + for column in ( + "tenant_id", + "postbox_id", + "message_id", + "account_id", + "identity_id", + "assignment_id", + "action", + "outcome", + "occurred_at", + ): + op.create_index( + op.f(f"ix_postbox_access_events_{column}"), + "postbox_access_events", + [column], + ) + op.create_index( + "ix_postbox_access_events_resource", + "postbox_access_events", + ["tenant_id", "postbox_id", "message_id", "occurred_at"], + ) + op.create_index( + "ix_postbox_access_events_actor", + "postbox_access_events", + ["tenant_id", "account_id", "occurred_at"], + ) + + +def downgrade() -> None: + op.drop_table("postbox_access_events") + op.drop_table("postbox_grouping_sources") + op.drop_table("postbox_groupings") + op.drop_table("postbox_message_receipts") + op.drop_table("postbox_routes") + op.drop_table("postbox_deliveries") + op.drop_table("postbox_attachment_references") + op.drop_table("postbox_participants") + op.drop_table("postbox_messages") + op.drop_table("postbox_bindings") + op.drop_table("postboxes") + op.drop_table("postbox_addresses") + op.drop_table("postbox_template_revisions") + op.drop_table("postbox_templates") diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py new file mode 100644 index 0000000..84e5843 --- /dev/null +++ b/src/govoplan_postbox/backend/router.py @@ -0,0 +1,732 @@ +from __future__ import annotations + +from dataclasses import asdict +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.postbox import ( + PostboxActorRef, + PostboxAttachmentRef, + PostboxDeliveryRequest, + PostboxParticipantRef, + PostboxTargetRef, +) +from govoplan_core.db.session import get_session +from govoplan_postbox.backend.manifest import ( + ACKNOWLEDGE_SCOPE, + BINDING_ADMIN_SCOPE, + DELIVERY_SCOPE, + READ_SCOPE, + SEND_SCOPE, + TEMPLATE_ADMIN_SCOPE, +) +from govoplan_postbox.backend.runtime import get_service +from govoplan_postbox.backend.schemas import ( + PostboxAccessDecisionResponse, + PostboxDeliveryCreateRequest, + PostboxDeliveryResponse, + PostboxDirectoryItem, + PostboxDirectoryResponse, + PostboxExactCreateRequest, + PostboxGroupingItem, + PostboxGroupingListResponse, + PostboxGroupingPayload, + PostboxMaterializeRequest, + PostboxMessageItem, + PostboxMessageListResponse, + PostboxMessageStateRequest, + PostboxOrganizationTargetsResponse, + PostboxTemplateCreateRequest, + PostboxTemplateItem, + PostboxTemplateListResponse, + PostboxTemplatePublishRequest, + PostboxTemplateRevisionPayload, +) +from govoplan_postbox.backend.service import PostboxError + + +router = APIRouter(prefix="/postbox", tags=["postbox"]) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing scope: {scope}", + ) + + +def _require_any(principal: ApiPrincipal, *scopes: str) -> None: + if any(has_scope(principal, scope) for scope in scopes): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires one of: {', '.join(scopes)}", + ) + + +def _actor( + principal: ApiPrincipal, + *, + assignment_context_id: str | None = None, +) -> PostboxActorRef: + actions: set[str] = set() + if has_scope(principal, READ_SCOPE): + actions.update(("discover", "read")) + if has_scope(principal, SEND_SCOPE): + actions.add("send") + if has_scope(principal, ACKNOWLEDGE_SCOPE): + actions.add("acknowledge") + if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope( + principal, + TEMPLATE_ADMIN_SCOPE, + ): + actions.add("administer") + selected = assignment_context_id + if selected is None and len(principal.function_assignment_ids) == 1: + selected = next(iter(principal.function_assignment_ids)) + return PostboxActorRef( + account_id=principal.account_id, + identity_id=principal.identity_id, + selected_assignment_id=selected, + acting_for_account_id=principal.acting_for_account_id, + authorized_actions=frozenset(actions), # type: ignore[arg-type] + ) + + +def _http_error(exc: PostboxError) -> HTTPException: + if exc.code.endswith("_not_found") or exc.code in { + "message_not_found", + "postbox_not_found", + "template_not_found", + "revision_not_found", + "grouping_not_found", + "target_not_found", + }: + code = status.HTTP_404_NOT_FOUND + elif exc.code in {"access_denied", "grouping_source_denied"}: + code = status.HTTP_403_FORBIDDEN + elif exc.code in { + "template_slug_exists", + "address_collision", + "idempotency_conflict", + }: + code = status.HTTP_409_CONFLICT + else: + code = status.HTTP_400_BAD_REQUEST + return HTTPException( + status_code=code, + detail={"code": exc.code, "message": str(exc)}, + ) + + +def _directory_item(value) -> PostboxDirectoryItem: + return PostboxDirectoryItem.model_validate(asdict(value)) + + +def _message_item(value) -> PostboxMessageItem: + return PostboxMessageItem.model_validate(asdict(value)) + + +def _template_item(template) -> PostboxTemplateItem: + return PostboxTemplateItem( + id=template.id, + tenant_id=template.tenant_id, + slug=template.slug, + name=template.name, + description=template.description, + status=template.status, + current_revision=template.current_revision, + published_revision_id=template.published_revision_id, + revisions=[ + { + "id": revision.id, + "revision": revision.revision, + "function_type_id": revision.function_type_id, + "scope_kind": revision.scope_kind, + "scope_id": revision.scope_id, + "name_pattern": revision.name_pattern, + "address_pattern": revision.address_pattern, + "classification": revision.classification, + "allow_vacant_delivery": revision.allow_vacant_delivery, + "encryption_profile": revision.encryption_profile, + "history_policy": dict(revision.history_policy or {}), + "routing_policy": dict(revision.routing_policy or {}), + "retention_policy": dict(revision.retention_policy or {}), + "published_at": revision.published_at, + "created_at": revision.created_at, + } + for revision in template.revisions + ], + created_at=template.created_at, + updated_at=template.updated_at, + ) + + +def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem: + return PostboxGroupingItem( + id=grouping.id, + name=grouping.name, + is_default=grouping.is_default, + postbox_ids=[ + source.postbox_id + for source in grouping.sources + if source.postbox_id in visible_ids + ], + created_at=grouping.created_at, + updated_at=grouping.updated_at, + ) + + +@router.get("/directory", response_model=PostboxDirectoryResponse) +def api_postbox_directory( + assignment_context_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxDirectoryResponse: + _require(principal, READ_SCOPE) + entries = get_service().list_visible_postboxes( + session, + tenant_id=principal.tenant_id, + actor=_actor( + principal, + assignment_context_id=assignment_context_id, + ), + ) + return PostboxDirectoryResponse( + postboxes=[_directory_item(entry) for entry in entries] + ) + + +@router.get( + "/directory/{postbox_id}/access", + response_model=PostboxAccessDecisionResponse, +) +def api_postbox_access( + postbox_id: str, + action: Literal[ + "discover", + "read", + "send", + "acknowledge", + "administer", + ] = "read", + assignment_context_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxAccessDecisionResponse: + _require(principal, READ_SCOPE) + try: + decision = get_service().explain_access( + session, + tenant_id=principal.tenant_id, + postbox_id=postbox_id, + actor=_actor( + principal, + assignment_context_id=assignment_context_id, + ), + action=action, + ) + except PostboxError as exc: + raise _http_error(exc) from exc + return PostboxAccessDecisionResponse.model_validate(asdict(decision)) + + +@router.get("/messages", response_model=PostboxMessageListResponse) +def api_list_postbox_messages( + postbox_id: list[str] = Query(default=[]), + assignment_context_id: str | None = None, + q: str | None = Query(default=None, max_length=200), + state_filter: Literal[ + "all", + "unread", + "read", + "acknowledged", + ] = Query(default="all", alias="state"), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxMessageListResponse: + _require(principal, READ_SCOPE) + actor = _actor( + principal, + assignment_context_id=assignment_context_id, + ) + requested = tuple(postbox_id) + if not requested: + requested = tuple( + entry.id + for entry in get_service().list_visible_postboxes( + session, + tenant_id=principal.tenant_id, + actor=actor, + ) + ) + messages = get_service().list_messages( + session, + tenant_id=principal.tenant_id, + postbox_ids=requested, + actor=actor, + limit=limit, + offset=offset, + query=q, + state=state_filter, + ) + total = get_service().count_messages( + session, + tenant_id=principal.tenant_id, + postbox_ids=requested, + actor=actor, + query=q, + state=state_filter, + ) + return PostboxMessageListResponse( + messages=[_message_item(message) for message in messages], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/messages/{message_id}", response_model=PostboxMessageItem) +def api_get_postbox_message( + message_id: str, + assignment_context_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxMessageItem: + _require(principal, READ_SCOPE) + try: + message = get_service().get_message( + session, + tenant_id=principal.tenant_id, + message_id=message_id, + actor=_actor( + principal, + assignment_context_id=assignment_context_id, + ), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + if message is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Postbox message not found.", + ) + session.commit() + return _message_item(message) + + +@router.patch( + "/messages/{message_id}/state", + response_model=PostboxMessageItem, +) +def api_mark_postbox_message( + message_id: str, + payload: PostboxMessageStateRequest, + assignment_context_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxMessageItem: + _require( + principal, + ACKNOWLEDGE_SCOPE if payload.state == "acknowledged" else READ_SCOPE, + ) + try: + message = get_service().mark_message( + session, + tenant_id=principal.tenant_id, + message_id=message_id, + actor=_actor( + principal, + assignment_context_id=assignment_context_id, + ), + state=payload.state, + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _message_item(message) + + +@router.post( + "/deliveries", + response_model=PostboxDeliveryResponse, + status_code=status.HTTP_201_CREATED, +) +def api_deliver_to_postbox( + payload: PostboxDeliveryCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxDeliveryResponse: + _require(principal, DELIVERY_SCOPE) + request = PostboxDeliveryRequest( + tenant_id=principal.tenant_id, + target=PostboxTargetRef(**payload.target.model_dump()), + producer_module=payload.producer_module, + producer_resource_type=payload.producer_resource_type, + producer_resource_id=payload.producer_resource_id, + idempotency_key=payload.idempotency_key, + subject=payload.subject, + body_text=payload.body_text, + sender_label=payload.sender_label, + classification=payload.classification, + participants=tuple( + PostboxParticipantRef(**participant.model_dump()) + for participant in payload.participants + ), + attachments=tuple( + PostboxAttachmentRef(**attachment.model_dump()) + for attachment in payload.attachments + ), + expires_at=payload.expires_at, + metadata=payload.metadata, + ) + try: + result = get_service().deliver(session, request) + except PostboxError as exc: + session.rollback() + raise _http_error(exc) from exc + session.commit() + return PostboxDeliveryResponse.model_validate(asdict(result)) + + +@router.get("/groupings", response_model=PostboxGroupingListResponse) +def api_list_postbox_groupings( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxGroupingListResponse: + _require(principal, READ_SCOPE) + actor = _actor(principal) + visible_ids = { + item.id + for item in get_service().list_visible_postboxes( + session, + tenant_id=principal.tenant_id, + actor=actor, + ) + } + groupings = get_service().list_groupings( + session, + tenant_id=principal.tenant_id, + actor=actor, + ) + return PostboxGroupingListResponse( + groupings=[ + _grouping_item(grouping, visible_ids=visible_ids) + for grouping in groupings + ] + ) + + +@router.post( + "/groupings", + response_model=PostboxGroupingItem, + status_code=status.HTTP_201_CREATED, +) +def api_create_postbox_grouping( + payload: PostboxGroupingPayload, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxGroupingItem: + _require(principal, READ_SCOPE) + actor = _actor(principal) + try: + grouping = get_service().save_grouping( + session, + tenant_id=principal.tenant_id, + actor=actor, + grouping_id=None, + **payload.model_dump(), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) + + +@router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem) +def api_update_postbox_grouping( + grouping_id: str, + payload: PostboxGroupingPayload, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxGroupingItem: + _require(principal, READ_SCOPE) + actor = _actor(principal) + try: + grouping = get_service().save_grouping( + session, + tenant_id=principal.tenant_id, + actor=actor, + grouping_id=grouping_id, + **payload.model_dump(), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) + + +@router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_postbox_grouping( + grouping_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> None: + _require(principal, READ_SCOPE) + try: + get_service().delete_grouping( + session, + tenant_id=principal.tenant_id, + actor=_actor(principal), + grouping_id=grouping_id, + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + + +@router.get( + "/admin/organization-targets", + response_model=PostboxOrganizationTargetsResponse, +) +def api_postbox_organization_targets( + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxOrganizationTargetsResponse: + _require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE) + return PostboxOrganizationTargetsResponse( + units=list( + get_service().organization_targets(tenant_id=principal.tenant_id) + ) + ) + + +@router.get("/admin/postboxes", response_model=PostboxDirectoryResponse) +def api_admin_postboxes( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxDirectoryResponse: + _require(principal, BINDING_ADMIN_SCOPE) + return PostboxDirectoryResponse( + postboxes=[ + _directory_item(item) + for item in get_service().list_admin_postboxes( + session, + tenant_id=principal.tenant_id, + ) + ] + ) + + +@router.post( + "/admin/postboxes", + response_model=PostboxDirectoryItem, + status_code=status.HTTP_201_CREATED, +) +def api_create_exact_postbox( + payload: PostboxExactCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxDirectoryItem: + _require(principal, BINDING_ADMIN_SCOPE) + try: + postbox = get_service().create_exact_postbox( + session, + tenant_id=principal.tenant_id, + actor_id=principal.account_id, + **payload.model_dump(), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _directory_item( + get_service().resolve_postbox( + session, + tenant_id=principal.tenant_id, + target=PostboxTargetRef(postbox_id=postbox.id), + ) + ) + + +@router.delete( + "/admin/postboxes/{postbox_id}", + response_model=PostboxDirectoryItem, +) +def api_archive_postbox( + postbox_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxDirectoryItem: + _require(principal, BINDING_ADMIN_SCOPE) + try: + postbox = get_service().archive_postbox( + session, + tenant_id=principal.tenant_id, + postbox_id=postbox_id, + actor_id=principal.account_id, + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _directory_item( + get_service().resolve_postbox( + session, + tenant_id=principal.tenant_id, + target=PostboxTargetRef(postbox_id=postbox.id), + ) + ) + + +@router.get("/admin/templates", response_model=PostboxTemplateListResponse) +def api_postbox_templates( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxTemplateListResponse: + _require(principal, TEMPLATE_ADMIN_SCOPE) + return PostboxTemplateListResponse( + templates=[ + _template_item(template) + for template in get_service().list_templates( + session, + tenant_id=principal.tenant_id, + ) + ] + ) + + +@router.post( + "/admin/templates", + response_model=PostboxTemplateItem, + status_code=status.HTTP_201_CREATED, +) +def api_create_postbox_template( + payload: PostboxTemplateCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxTemplateItem: + _require(principal, TEMPLATE_ADMIN_SCOPE) + try: + template = get_service().create_template( + session, + tenant_id=principal.tenant_id, + actor_id=principal.account_id, + **payload.model_dump(), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _template_item(template) + + +@router.post( + "/admin/templates/{template_id}/revisions", + response_model=PostboxTemplateItem, +) +def api_revise_postbox_template( + template_id: str, + payload: PostboxTemplateRevisionPayload, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxTemplateItem: + _require(principal, TEMPLATE_ADMIN_SCOPE) + try: + template = get_service().revise_template( + session, + tenant_id=principal.tenant_id, + template_id=template_id, + actor_id=principal.account_id, + **payload.model_dump(), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _template_item(template) + + +@router.post( + "/admin/templates/{template_id}/publish", + response_model=PostboxTemplateItem, +) +def api_publish_postbox_template( + template_id: str, + payload: PostboxTemplatePublishRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxTemplateItem: + _require(principal, TEMPLATE_ADMIN_SCOPE) + try: + template = get_service().publish_template( + session, + tenant_id=principal.tenant_id, + template_id=template_id, + revision_number=payload.revision, + actor_id=principal.account_id, + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _template_item(template) + + +@router.post( + "/admin/templates/{template_id}/retire", + response_model=PostboxTemplateItem, +) +def api_retire_postbox_template( + template_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxTemplateItem: + _require(principal, TEMPLATE_ADMIN_SCOPE) + try: + template = get_service().retire_template( + session, + tenant_id=principal.tenant_id, + template_id=template_id, + actor_id=principal.account_id, + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + return _template_item(template) + + +@router.post( + "/admin/templates/{template_id}/materialize", + response_model=PostboxDirectoryItem, +) +def api_materialize_postbox_template( + template_id: str, + payload: PostboxMaterializeRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxDirectoryItem: + _require(principal, BINDING_ADMIN_SCOPE) + _require(principal, TEMPLATE_ADMIN_SCOPE) + try: + postbox = get_service().materialize_template( + session, + tenant_id=principal.tenant_id, + template_id=template_id, + actor_id=principal.account_id, + **payload.model_dump(), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + session.commit() + entry = get_service().resolve_postbox( + session, + tenant_id=principal.tenant_id, + target=PostboxTargetRef(postbox_id=postbox.id), + ) + if entry is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Materialized Postbox could not be reloaded.", + ) + return _directory_item(entry) diff --git a/src/govoplan_postbox/backend/runtime.py b/src/govoplan_postbox/backend/runtime.py new file mode 100644 index 0000000..213b830 --- /dev/null +++ b/src/govoplan_postbox/backend/runtime.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from govoplan_core.core.registry import PlatformRegistry + + +_registry: PlatformRegistry | None = None +_service: object | None = None + + +def configure_runtime(*, registry: object) -> None: + global _registry, _service + if not isinstance(registry, PlatformRegistry): + raise RuntimeError("Postbox requires a platform registry") + if registry is not _registry: + _registry = registry + _service = None + + +def get_registry() -> PlatformRegistry: + if _registry is None: + raise RuntimeError("Postbox runtime has not been configured") + return _registry + + +def get_service(): + global _service + if _service is None: + from govoplan_postbox.backend.service import PostboxService + + _service = PostboxService.from_registry(get_registry()) + return _service diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py new file mode 100644 index 0000000..6930330 --- /dev/null +++ b/src/govoplan_postbox/backend/schemas.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + + +class PostboxAccessDecisionResponse(BaseModel): + allowed: bool + action: str + postbox_id: str + reason_code: str + explanation: str + organization_unit_id: str | None = None + function_id: str | None = None + assignment_ids: list[str] = Field(default_factory=list) + assignment_sources: list[str] = Field(default_factory=list) + selected_assignment_id: str | None = None + holder_count: int = 0 + vacant: bool = True + + +class PostboxDirectoryItem(BaseModel): + id: str + tenant_id: str + address: str + address_key: str + name: str + status: str + classification: str + organization_unit_id: str | None = None + organization_unit_name: str | None = None + function_id: str | None = None + function_name: str | None = None + context_key: str | None = None + template_revision_id: str | None = None + holder_count: int = 0 + vacant: bool = True + access: PostboxAccessDecisionResponse | None = None + + +class PostboxDirectoryResponse(BaseModel): + postboxes: list[PostboxDirectoryItem] + + +class PostboxParticipantPayload(BaseModel): + kind: str = Field(min_length=1, max_length=30) + reference_type: str = Field(min_length=1, max_length=50) + reference_id: str | None = Field(default=None, max_length=255) + label: str | None = Field(default=None, max_length=500) + address: str | None = Field(default=None, max_length=500) + + +class PostboxAttachmentPayload(BaseModel): + reference_type: str = Field(min_length=1, max_length=50) + reference_id: str = Field(min_length=1, max_length=255) + name: str | None = Field(default=None, max_length=1000) + media_type: str | None = Field(default=None, max_length=255) + size_bytes: int | None = Field(default=None, ge=0) + digest: str | None = Field(default=None, max_length=255) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class PostboxMessageItem(BaseModel): + id: str + tenant_id: str + postbox_id: str + subject: str + body_text: str | None = None + status: str + classification: str + sender_label: str | None = None + delivered_at: datetime + read_at: datetime | None = None + acknowledged_at: datetime | None = None + expires_at: datetime | None = None + withdrawn_at: datetime | None = None + producer_module: str | None = None + producer_resource_type: str | None = None + producer_resource_id: str | None = None + encryption_profile: str + key_epoch: int + ciphertext_ref: str | None = None + signed_manifest_ref: str | None = None + participants: list[PostboxParticipantPayload] = Field(default_factory=list) + attachments: list[PostboxAttachmentPayload] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class PostboxMessageListResponse(BaseModel): + messages: list[PostboxMessageItem] + total: int + limit: int + offset: int + + +class PostboxMessageStateRequest(BaseModel): + state: Literal["read", "acknowledged"] + + +class PostboxTargetPayload(BaseModel): + postbox_id: str | None = Field(default=None, max_length=36) + address_key: str | None = Field(default=None, max_length=500) + template_id: str | None = Field(default=None, max_length=36) + organization_unit_id: str | None = Field(default=None, max_length=36) + function_id: str | None = Field(default=None, max_length=36) + context_key: str | None = Field(default=None, max_length=255) + + @model_validator(mode="after") + def validate_target(self) -> "PostboxTargetPayload": + direct = bool(self.postbox_id or self.address_key) + templated = bool( + self.template_id + and self.organization_unit_id + and self.function_id + ) + if direct == templated: + raise ValueError( + "Specify one direct Postbox target or one complete template target." + ) + return self + + +class PostboxDeliveryCreateRequest(BaseModel): + target: PostboxTargetPayload + producer_module: str = Field(min_length=1, max_length=100) + producer_resource_type: str = Field(min_length=1, max_length=100) + producer_resource_id: str | None = Field(default=None, max_length=255) + idempotency_key: str = Field(min_length=1, max_length=255) + subject: str = Field(min_length=1, max_length=1000) + body_text: str | None = None + sender_label: str | None = Field(default=None, max_length=500) + classification: str = Field(default="internal", min_length=1, max_length=50) + participants: list[PostboxParticipantPayload] = Field(default_factory=list) + attachments: list[PostboxAttachmentPayload] = Field(default_factory=list) + expires_at: datetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class PostboxDeliveryResponse(BaseModel): + delivery_id: str + postbox_id: str + message_id: str + address: str + status: str + vacant: bool + holder_count: int + duplicate: bool = False + evidence: dict[str, Any] = Field(default_factory=dict) + + +class PostboxExactCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=500) + description: str | None = None + organization_unit_id: str = Field(min_length=1, max_length=36) + function_id: str = Field(min_length=1, max_length=36) + address_key: str | None = Field(default=None, max_length=120) + classification: str = Field(default="internal", min_length=1, max_length=50) + + +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) + name_pattern: str = Field( + default="{unit_name} / {function_name}", + min_length=1, + max_length=500, + ) + address_pattern: str = Field( + default="{template_slug}.{unit_slug}.{function_slug}", + min_length=1, + max_length=500, + ) + classification: str = Field(default="internal", min_length=1, max_length=50) + allow_vacant_delivery: bool = True + + +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 + + +class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload): + id: str + 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 + + +class PostboxTemplateItem(BaseModel): + id: str + tenant_id: str + slug: str + name: str + description: str | None = None + status: str + current_revision: int + published_revision_id: str | None = None + revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class PostboxTemplateListResponse(BaseModel): + templates: list[PostboxTemplateItem] + + +class PostboxTemplatePublishRequest(BaseModel): + revision: int | None = Field(default=None, ge=1) + + +class PostboxMaterializeRequest(BaseModel): + organization_unit_id: str = Field(min_length=1, max_length=36) + function_id: str = Field(min_length=1, max_length=36) + context_key: str | None = Field(default=None, max_length=255) + + +class PostboxOrganizationFunctionItem(BaseModel): + id: str + slug: str + name: str + function_type_id: str | None = None + delegable: bool = False + act_in_place_allowed: bool = False + + +class PostboxOrganizationUnitItem(BaseModel): + id: str + slug: str + name: str + unit_type_id: str | None = None + parent_id: str | None = None + functions: list[PostboxOrganizationFunctionItem] = Field(default_factory=list) + + +class PostboxOrganizationTargetsResponse(BaseModel): + units: list[PostboxOrganizationUnitItem] + + +class PostboxGroupingPayload(BaseModel): + name: str = Field(min_length=1, max_length=250) + is_default: bool = False + postbox_ids: list[str] = Field(default_factory=list, max_length=250) + + +class PostboxGroupingItem(PostboxGroupingPayload): + id: str + created_at: datetime + updated_at: datetime + + +class PostboxGroupingListResponse(BaseModel): + groupings: list[PostboxGroupingItem] diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py new file mode 100644 index 0000000..343082f --- /dev/null +++ b/src/govoplan_postbox/backend/service.py @@ -0,0 +1,2428 @@ +from __future__ import annotations + +import hashlib +import logging +import re +from collections.abc import Mapping, Sequence +from typing import Literal + +from sqlalchemy import and_, func, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, selectinload + +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) +from govoplan_core.core.identity import ( + CAPABILITY_IDENTITY_DIRECTORY, + IdentityDirectory, +) +from govoplan_core.core.idm import ( + CAPABILITY_IDM_DIRECTORY, + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + IdmDirectory, + IdmFunctionAssignmentDirectory, + OrganizationFunctionAssignmentRef, +) +from govoplan_core.core.notifications import ( + NotificationDispatchProvider, + NotificationDispatchRequest, + notification_dispatch_provider, +) +from govoplan_core.core.organizations import ( + CAPABILITY_ORGANIZATION_DIRECTORY, + OrganizationDirectory, + OrganizationFunctionRef, + OrganizationUnitRef, +) +from govoplan_core.core.postbox import ( + PostboxAccessDecisionRef, + PostboxAction, + PostboxActorRef, + PostboxAttachmentRef, + PostboxDeliveryCatalogRef, + PostboxDeliveryRequest, + PostboxDeliveryRejected, + PostboxDeliveryResult, + PostboxDeliveryTemplateRef, + PostboxDirectoryEntryRef, + PostboxMessageRef, + PostboxMessageListState, + PostboxOrganizationFunctionTargetRef, + PostboxOrganizationUnitTargetRef, + PostboxParticipantRef, + PostboxTargetRef, +) +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.security.time import utc_now +from govoplan_postbox.backend.db.models import ( + Postbox, + PostboxAccessEvent, + PostboxAddress, + PostboxAttachmentReference, + PostboxBinding, + PostboxDelivery, + PostboxGrouping, + PostboxGroupingSource, + PostboxMessage, + PostboxMessageReceipt, + PostboxParticipant, + PostboxTemplate, + PostboxTemplateRevision, +) + +logger = logging.getLogger(__name__) + + +class PostboxError(PostboxDeliveryRejected, ValueError): + def __init__( + self, + code: str, + message: str, + *, + temporary: bool = False, + ) -> None: + super().__init__(code, message, temporary=temporary) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Postbox requires a SQLAlchemy Session") + return value + + +def _slug(value: str, *, fallback: str = "postbox") -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", value.strip().casefold()).strip("-") + if normalized: + return normalized[:100] + digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12] + return f"{fallback}-{digest}" + + +def _mapping(value: Mapping[str, object] | None) -> dict[str, object]: + return dict(value or {}) + + +def _publish_postbox_event( + session: Session, + event_type: str, + *, + tenant_id: str, + resource_type: str, + resource_id: str, + postbox_id: str | None = None, + actor_type: str | None = None, + actor_id: str | None = None, + payload: Mapping[str, object] | None = None, +) -> None: + emit_platform_event( + session, + PlatformEvent( + type=event_type, + module_id="postbox", + payload=dict(payload or {}), + actor=( + EventActorRef(type=actor_type, id=actor_id) + if actor_type + else None + ), + tenant=EventTenantRef(id=tenant_id), + subject=( + EventObjectRef(type="postbox", id=postbox_id) + if postbox_id + else None + ), + resource=EventObjectRef(type=resource_type, id=resource_id), + classification="internal", + ) + ) + + +class PostboxService: + def __init__( + self, + *, + identities: IdentityDirectory, + idm: IdmDirectory, + incumbencies: IdmFunctionAssignmentDirectory, + organizations: OrganizationDirectory, + notifications: NotificationDispatchProvider | None = None, + ) -> None: + self._identities = identities + self._idm = idm + self._incumbencies = incumbencies + self._organizations = organizations + self._notifications = notifications + + @classmethod + def from_registry(cls, registry: PlatformRegistry) -> "PostboxService": + identities = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) + idm = registry.require_capability(CAPABILITY_IDM_DIRECTORY) + incumbencies = registry.require_capability( + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS + ) + organizations = registry.require_capability( + CAPABILITY_ORGANIZATION_DIRECTORY + ) + if not isinstance(identities, IdentityDirectory): + raise RuntimeError( + f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}" + ) + if not isinstance(idm, IdmDirectory): + raise RuntimeError(f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}") + if not isinstance(incumbencies, IdmFunctionAssignmentDirectory): + raise RuntimeError( + f"Invalid capability: {CAPABILITY_IDM_FUNCTION_ASSIGNMENTS}" + ) + if not isinstance(organizations, OrganizationDirectory): + raise RuntimeError( + f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}" + ) + return cls( + identities=identities, + idm=idm, + incumbencies=incumbencies, + organizations=organizations, + notifications=notification_dispatch_provider(registry), + ) + + # Capability: directory + def list_visible_postboxes( + self, + session: object, + *, + tenant_id: str, + actor: PostboxActorRef, + ) -> tuple[PostboxDirectoryEntryRef, ...]: + db = _session(session) + assignments = self._assignments_for_actor(actor, tenant_id=tenant_id) + postboxes = ( + db.query(Postbox) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter( + Postbox.tenant_id == tenant_id, + Postbox.status == "active", + ) + .order_by(Postbox.name.asc(), Postbox.id.asc()) + .all() + ) + holder_cache = self._holder_cache( + tenant_id=tenant_id, + postboxes=postboxes, + ) + visible: list[PostboxDirectoryEntryRef] = [] + for postbox in postboxes: + decision = self._access_decision( + postbox, + actor=actor, + action="discover", + assignments=assignments, + holder_cache=holder_cache, + ) + if decision.allowed: + visible.append( + self._directory_entry( + postbox, + decision=decision, + holder_cache=holder_cache, + ) + ) + return tuple(visible) + + def resolve_postbox( + self, + session: object, + *, + tenant_id: str, + target: PostboxTargetRef, + materialize: bool = False, + ) -> PostboxDirectoryEntryRef | None: + db = _session(session) + postbox: Postbox | None = None + if target.postbox_id: + postbox = self._get_postbox( + db, + tenant_id=tenant_id, + postbox_id=target.postbox_id, + required=False, + ) + elif target.address_key: + postbox = ( + db.query(Postbox) + .join(PostboxAddress, Postbox.address_id == PostboxAddress.id) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter( + Postbox.tenant_id == tenant_id, + PostboxAddress.address_key == target.address_key, + ) + .one_or_none() + ) + elif ( + target.template_id + and target.organization_unit_id + and target.function_id + ): + address_key = self._template_address_key( + target.template_id, + target.organization_unit_id, + target.function_id, + target.context_key, + ) + postbox = ( + db.query(Postbox) + .join(PostboxAddress, Postbox.address_id == PostboxAddress.id) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter( + Postbox.tenant_id == tenant_id, + PostboxAddress.address_key == address_key, + ) + .one_or_none() + ) + if postbox is None and materialize: + postbox = self.materialize_template( + db, + tenant_id=tenant_id, + template_id=target.template_id, + organization_unit_id=target.organization_unit_id, + function_id=target.function_id, + context_key=target.context_key, + actor_id=None, + ) + if postbox is None: + return None + return self._directory_entry(postbox) + + def delivery_catalog( + self, + session: object, + *, + tenant_id: str, + ) -> PostboxDeliveryCatalogRef: + db = _session(session) + postboxes = tuple( + item + for item in self.list_admin_postboxes(db, tenant_id=tenant_id) + if item.status == "active" + ) + templates: list[PostboxDeliveryTemplateRef] = [] + for template in self.list_templates(db, tenant_id=tenant_id): + if template.status != "published" or not template.published_revision_id: + continue + revision = next( + ( + item + for item in template.revisions + if item.id == template.published_revision_id + ), + None, + ) + if revision is None: + continue + templates.append( + PostboxDeliveryTemplateRef( + id=template.id, + slug=template.slug, + name=template.name, + description=template.description, + published_revision_id=revision.id, + function_type_id=revision.function_type_id, + scope_kind=revision.scope_kind, + scope_id=revision.scope_id, + classification=revision.classification, + allow_vacant_delivery=revision.allow_vacant_delivery, + ) + ) + organization_units = tuple( + PostboxOrganizationUnitTargetRef( + id=str(unit["id"]), + slug=str(unit["slug"]), + name=str(unit["name"]), + unit_type_id=( + str(unit["unit_type_id"]) + if unit.get("unit_type_id") is not None + else None + ), + parent_id=( + str(unit["parent_id"]) + if unit.get("parent_id") is not None + else None + ), + functions=tuple( + PostboxOrganizationFunctionTargetRef( + id=str(function["id"]), + slug=str(function["slug"]), + name=str(function["name"]), + function_type_id=( + str(function["function_type_id"]) + if function.get("function_type_id") is not None + else None + ), + ) + for function in unit.get("functions", ()) + if isinstance(function, Mapping) + ), + ) + for unit in self.organization_targets(tenant_id=tenant_id) + ) + return PostboxDeliveryCatalogRef( + postboxes=postboxes, + templates=tuple(templates), + organization_units=organization_units, + ) + + # Capability: access + def explain_access( + self, + session: object, + *, + tenant_id: str, + postbox_id: str, + actor: PostboxActorRef, + action: PostboxAction, + ) -> PostboxAccessDecisionRef: + db = _session(session) + postbox = self._get_postbox( + db, + tenant_id=tenant_id, + postbox_id=postbox_id, + ) + decision = self._access_decision( + postbox, + actor=actor, + action=action, + assignments=self._assignments_for_actor(actor, tenant_id=tenant_id), + holder_cache={}, + ) + if not decision.allowed: + self._record_access_event( + db, + tenant_id=tenant_id, + postbox_id=postbox.id, + actor=actor, + action=f"access.{action}", + outcome="denied", + reason_code=decision.reason_code, + assignment_id=decision.selected_assignment_id, + ) + return decision + + # Capability: messages + def list_messages( + self, + session: object, + *, + tenant_id: str, + postbox_ids: Sequence[str], + actor: PostboxActorRef, + limit: int = 100, + offset: int = 0, + query: str | None = None, + state: PostboxMessageListState = "all", + ) -> tuple[PostboxMessageRef, ...]: + 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 () + messages = ( + self._messages_query( + db, + tenant_id=tenant_id, + allowed_ids=allowed_ids, + account_id=actor.account_id, + query=query, + state=state, + ) + .options( + selectinload(PostboxMessage.participants), + selectinload(PostboxMessage.attachments), + selectinload(PostboxMessage.receipts), + ) + .order_by( + PostboxMessage.delivered_at.desc(), + PostboxMessage.id.desc(), + ) + .offset(max(offset, 0)) + .limit(min(max(limit, 1), 500)) + .all() + ) + return tuple( + self._message_ref(message, account_id=actor.account_id) + for message in messages + ) + + def count_messages( + self, + session: object, + *, + tenant_id: str, + postbox_ids: Sequence[str], + actor: PostboxActorRef, + query: str | None = None, + state: PostboxMessageListState = "all", + ) -> int: + 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 0 + return int( + self._messages_query( + db, + tenant_id=tenant_id, + allowed_ids=allowed_ids, + account_id=actor.account_id, + query=query, + state=state, + ) + .with_entities(func.count(PostboxMessage.id)) + .scalar() + or 0 + ) + + def _messages_query( + self, + session: Session, + *, + tenant_id: str, + allowed_ids: Sequence[str], + account_id: str, + query: str | None, + state: PostboxMessageListState, + ): + result = session.query(PostboxMessage).filter( + PostboxMessage.tenant_id == tenant_id, + PostboxMessage.postbox_id.in_(allowed_ids), + ) + needle = (query or "").strip().casefold() + if needle: + pattern = f"%{needle}%" + result = result.filter( + or_( + func.lower(PostboxMessage.subject).like(pattern), + func.lower( + func.coalesce(PostboxMessage.sender_label, "") + ).like(pattern), + PostboxMessage.participants.any( + or_( + func.lower( + func.coalesce(PostboxParticipant.label, "") + ).like(pattern), + func.lower( + func.coalesce(PostboxParticipant.address, "") + ).like(pattern), + ) + ), + ) + ) + read_receipt = PostboxMessage.receipts.any( + and_( + PostboxMessageReceipt.account_id == account_id, + PostboxMessageReceipt.read_at.is_not(None), + ) + ) + acknowledged_receipt = PostboxMessage.receipts.any( + and_( + PostboxMessageReceipt.account_id == account_id, + PostboxMessageReceipt.acknowledged_at.is_not(None), + ) + ) + if state == "unread": + result = result.filter(~read_receipt) + elif state == "read": + result = result.filter(read_receipt) + elif state == "acknowledged": + result = result.filter(acknowledged_receipt) + return result + + def get_message( + self, + session: object, + *, + tenant_id: str, + message_id: str, + actor: PostboxActorRef, + ) -> PostboxMessageRef | None: + db = _session(session) + message = self._get_message( + db, + tenant_id=tenant_id, + message_id=message_id, + required=False, + ) + if message is None: + return None + decision = self.explain_access( + db, + tenant_id=tenant_id, + postbox_id=message.postbox_id, + actor=actor, + action="read", + ) + if not decision.allowed: + return None + self._record_access_event( + db, + tenant_id=tenant_id, + postbox_id=message.postbox_id, + message_id=message.id, + actor=actor, + action="message.read", + outcome="allowed", + reason_code=decision.reason_code, + assignment_id=decision.selected_assignment_id, + ) + return self._message_ref(message, account_id=actor.account_id) + + def mark_message( + self, + session: object, + *, + tenant_id: str, + message_id: str, + actor: PostboxActorRef, + state: Literal["read", "acknowledged"], + ) -> PostboxMessageRef: + db = _session(session) + message = self._get_message( + db, + tenant_id=tenant_id, + message_id=message_id, + ) + action: PostboxAction = ( + "acknowledge" if state == "acknowledged" else "read" + ) + decision = self.explain_access( + db, + tenant_id=tenant_id, + postbox_id=message.postbox_id, + actor=actor, + action=action, + ) + if not decision.allowed: + raise PostboxError("access_denied", decision.explanation) + receipt = ( + db.query(PostboxMessageReceipt) + .filter( + PostboxMessageReceipt.tenant_id == tenant_id, + PostboxMessageReceipt.message_id == message.id, + PostboxMessageReceipt.account_id == actor.account_id, + ) + .one_or_none() + ) + if receipt is None: + receipt = PostboxMessageReceipt( + tenant_id=tenant_id, + message_id=message.id, + account_id=actor.account_id, + identity_id=actor.identity_id, + assignment_id=decision.selected_assignment_id, + metadata_={}, + ) + message.receipts.append(receipt) + was_read = receipt.read_at is not None + was_acknowledged = receipt.acknowledged_at is not None + now = utc_now() + if state == "read": + receipt.read_at = receipt.read_at or now + else: + receipt.read_at = receipt.read_at or now + receipt.acknowledged_at = receipt.acknowledged_at or now + db.flush() + self._record_access_event( + db, + tenant_id=tenant_id, + postbox_id=message.postbox_id, + message_id=message.id, + actor=actor, + action=f"message.{state}", + outcome="allowed", + reason_code=decision.reason_code, + assignment_id=decision.selected_assignment_id, + ) + if ( + state == "read" + and not was_read + or state == "acknowledged" + and not was_acknowledged + ): + _publish_postbox_event( + db, + f"postbox.message.{state}.v1", + tenant_id=tenant_id, + resource_type="postbox_message", + resource_id=message.id, + postbox_id=message.postbox_id, + actor_type="account", + actor_id=actor.account_id, + payload={ + "state": state, + "assignment_id": decision.selected_assignment_id, + }, + ) + db.refresh(message) + return self._message_ref(message, account_id=actor.account_id) + + # Capability: delivery + def deliver( + self, + session: object, + request: PostboxDeliveryRequest, + ) -> PostboxDeliveryResult: + db = _session(session) + existing = ( + db.query(PostboxDelivery) + .filter( + PostboxDelivery.tenant_id == request.tenant_id, + PostboxDelivery.producer_module == request.producer_module, + PostboxDelivery.idempotency_key == request.idempotency_key, + ) + .one_or_none() + ) + if existing is not None: + postbox = self._get_postbox( + db, + tenant_id=request.tenant_id, + postbox_id=existing.postbox_id, + ) + return self._delivery_result(existing, postbox, duplicate=True) + + entry = self.resolve_postbox( + db, + tenant_id=request.tenant_id, + target=request.target, + materialize=True, + ) + if entry is None: + raise PostboxError( + "target_not_found", + "The Postbox delivery target could not be resolved.", + ) + postbox = self._get_postbox( + db, + tenant_id=request.tenant_id, + postbox_id=entry.id, + ) + if postbox.status != "active": + raise PostboxError("postbox_inactive", "The target Postbox is not active.") + + holders = self._holders( + request.tenant_id, + postbox.address_record.function_id, + ) + revision = self._address_revision(db, postbox.address_record) + if not holders and revision is not None and not revision.allow_vacant_delivery: + raise PostboxError( + "vacant_delivery_blocked", + "The target function is vacant and its template blocks vacant delivery.", + ) + + now = utc_now() + message = PostboxMessage( + tenant_id=request.tenant_id, + postbox_id=postbox.id, + subject=request.subject.strip() or "(No subject)", + body_text=request.body_text, + status="delivered", + classification=request.classification, + sender_label=request.sender_label, + producer_module=request.producer_module, + producer_resource_type=request.producer_resource_type, + producer_resource_id=request.producer_resource_id, + encryption_profile=postbox.encryption_profile, + key_epoch=postbox.key_epoch, + delivered_at=now, + expires_at=request.expires_at, + metadata_=_mapping(request.metadata), + ) + for position, participant in enumerate(request.participants): + message.participants.append( + PostboxParticipant( + tenant_id=request.tenant_id, + kind=participant.kind, + reference_type=participant.reference_type, + reference_id=participant.reference_id, + label=participant.label, + address=participant.address, + position=position, + metadata_={}, + ) + ) + for position, attachment in enumerate(request.attachments): + message.attachments.append( + PostboxAttachmentReference( + tenant_id=request.tenant_id, + reference_type=attachment.reference_type, + reference_id=attachment.reference_id, + name=attachment.name, + media_type=attachment.media_type, + size_bytes=attachment.size_bytes, + digest=attachment.digest, + position=position, + metadata_=_mapping(attachment.metadata), + ) + ) + db.add(message) + db.flush() + + snapshot = { + "address": entry.address, + "address_key": entry.address_key, + "postbox_name": entry.name, + "organization_unit_id": entry.organization_unit_id, + "organization_unit_name": entry.organization_unit_name, + "function_id": entry.function_id, + "function_name": entry.function_name, + "context_key": entry.context_key, + "template_revision_id": entry.template_revision_id, + "holder_assignment_ids": [holder.id for holder in holders], + } + delivery = PostboxDelivery( + tenant_id=request.tenant_id, + postbox_id=postbox.id, + message_id=message.id, + producer_module=request.producer_module, + producer_resource_type=request.producer_resource_type, + producer_resource_id=request.producer_resource_id, + idempotency_key=request.idempotency_key, + status="accepted" if holders else "accepted_vacant", + template_revision_id=entry.template_revision_id, + organization_unit_id=entry.organization_unit_id, + function_id=entry.function_id, + holder_count=len({holder.identity_id for holder in holders}), + target_snapshot=snapshot, + accepted_at=now, + metadata_=_mapping(request.metadata), + ) + db.add(delivery) + try: + db.flush() + except IntegrityError as exc: + raise PostboxError( + "idempotency_conflict", + "The delivery idempotency key was accepted concurrently.", + ) from exc + self._record_access_event( + db, + tenant_id=request.tenant_id, + postbox_id=postbox.id, + message_id=message.id, + actor=None, + action="delivery.accept", + outcome="allowed", + reason_code=delivery.status, + details={ + "producer_module": request.producer_module, + "producer_resource_type": request.producer_resource_type, + "producer_resource_id": request.producer_resource_id, + "delivery_id": delivery.id, + }, + ) + _publish_postbox_event( + db, + "postbox.delivery.accepted.v1", + tenant_id=request.tenant_id, + resource_type="postbox_message", + resource_id=message.id, + postbox_id=postbox.id, + actor_type="module", + actor_id=request.producer_module, + payload={ + "delivery_id": delivery.id, + "status": delivery.status, + "vacant": not bool(holders), + "holder_count": delivery.holder_count, + "producer_resource_type": request.producer_resource_type, + "producer_resource_id": request.producer_resource_id, + }, + ) + self._notify_delivery_holders( + db, + request=request, + delivery=delivery, + message=message, + holders=holders, + ) + return self._delivery_result(delivery, postbox) + + def _notify_delivery_holders( + self, + session: Session, + *, + request: PostboxDeliveryRequest, + delivery: PostboxDelivery, + message: PostboxMessage, + holders: Sequence[OrganizationFunctionAssignmentRef], + ) -> None: + if self._notifications is None: + return + recipients: dict[str, tuple[str, str]] = {} + for holder in holders: + if holder.account_id: + recipients.setdefault( + holder.account_id, + ("account", holder.id), + ) + elif holder.identity_id: + recipients.setdefault( + holder.identity_id, + ("identity", holder.id), + ) + for recipient_id, (recipient_type, assignment_id) in recipients.items(): + try: + with session.begin_nested(): + self._notifications.enqueue_notification( + session, + NotificationDispatchRequest( + tenant_id=request.tenant_id, + source_module="postbox", + source_resource_type="postbox_message", + source_resource_id=message.id, + event_kind="postbox.delivery.accepted.v1", + channel="inbox", + recipient_type=recipient_type, + recipient_id=recipient_id, + subject="New Postbox message", + body_text=( + "A new message is available in one of your " + "currently assigned Postboxes." + ), + action_url=f"/postbox?message={message.id}", + priority=2, + payload={ + "postbox_id": message.postbox_id, + "message_id": message.id, + "delivery_id": delivery.id, + "delivery_status": delivery.status, + }, + metadata={ + "assignment_id": assignment_id, + "producer_module": request.producer_module, + }, + ), + enqueue_delivery=False, + ) + except Exception: # noqa: BLE001 - delivery remains authoritative. + logger.warning( + "Postbox delivery notification enqueue failed", + exc_info=True, + extra={ + "postbox_delivery_id": delivery.id, + "postbox_message_id": message.id, + "notification_recipient_id": recipient_id, + }, + ) + + # Capability: evidence + def link_evidence( + self, + session: object, + *, + tenant_id: str, + message_id: str, + attachment: PostboxAttachmentRef, + ) -> PostboxMessageRef: + db = _session(session) + message = self._get_message( + db, + tenant_id=tenant_id, + message_id=message_id, + ) + position = len(message.attachments) + message.attachments.append( + PostboxAttachmentReference( + tenant_id=tenant_id, + reference_type=attachment.reference_type, + reference_id=attachment.reference_id, + name=attachment.name, + media_type=attachment.media_type, + size_bytes=attachment.size_bytes, + digest=attachment.digest, + position=position, + metadata_=_mapping(attachment.metadata), + ) + ) + db.flush() + _publish_postbox_event( + db, + "postbox.evidence.linked.v1", + tenant_id=tenant_id, + resource_type="postbox_attachment_reference", + resource_id=message.attachments[-1].id, + postbox_id=message.postbox_id, + payload={ + "message_id": message.id, + "reference_type": attachment.reference_type, + "reference_id": attachment.reference_id, + }, + ) + return self._message_ref(message, account_id="") + + # Administration + def list_admin_postboxes( + self, + session: Session, + *, + tenant_id: str, + ) -> tuple[PostboxDirectoryEntryRef, ...]: + postboxes = ( + session.query(Postbox) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter(Postbox.tenant_id == tenant_id) + .order_by(Postbox.name.asc(), Postbox.id.asc()) + .all() + ) + holder_cache = self._holder_cache( + tenant_id=tenant_id, + postboxes=postboxes, + ) + return tuple( + self._directory_entry( + postbox, + holder_cache=holder_cache, + ) + for postbox in postboxes + ) + + def create_exact_postbox( + self, + session: Session, + *, + tenant_id: str, + name: str, + organization_unit_id: str, + function_id: str, + address_key: str | None, + description: str | None, + classification: str, + actor_id: str | None, + ) -> Postbox: + unit, function = self._validate_function_target( + tenant_id=tenant_id, + organization_unit_id=organization_unit_id, + function_id=function_id, + ) + key = ( + f"exact:{_slug(address_key)}" + if address_key + else f"exact:{organization_unit_id}:{function_id}" + ) + existing = self._postbox_for_address_key( + session, + tenant_id=tenant_id, + address_key=key, + ) + if existing is not None: + return existing + address = ( + f"{_slug(address_key or name)}.{_slug(unit.slug)}." + f"{_slug(function.slug)}@postbox" + )[:500] + return self._create_postbox_records( + session, + tenant_id=tenant_id, + address_key=key, + address=address, + name=name.strip() or f"{unit.name} / {function.name}", + description=description, + classification=classification, + organization_unit=unit, + function=function, + context_key=None, + template=None, + revision=None, + source="exact", + actor_id=actor_id, + ) + + def archive_postbox( + self, + session: Session, + *, + tenant_id: str, + postbox_id: str, + actor_id: str | None, + ) -> Postbox: + postbox = self._get_postbox( + session, + tenant_id=tenant_id, + postbox_id=postbox_id, + ) + postbox.status = "archived" + postbox.archived_at = utc_now() + postbox.address_record.status = "archived" + for binding in postbox.bindings: + binding.is_active = False + self._record_access_event( + session, + tenant_id=tenant_id, + postbox_id=postbox.id, + actor=( + PostboxActorRef( + account_id=actor_id, + authorized_actions=frozenset({"administer"}), + ) + if actor_id + else None + ), + action="postbox.archive", + outcome="allowed", + reason_code="administrator", + ) + session.flush() + _publish_postbox_event( + session, + "postbox.archived.v1", + tenant_id=tenant_id, + resource_type="postbox", + resource_id=postbox.id, + postbox_id=postbox.id, + actor_type="user", + actor_id=actor_id, + ) + return postbox + + def list_templates( + self, + session: Session, + *, + tenant_id: str, + ) -> tuple[PostboxTemplate, ...]: + return tuple( + session.query(PostboxTemplate) + .options(selectinload(PostboxTemplate.revisions)) + .filter(PostboxTemplate.tenant_id == tenant_id) + .order_by(PostboxTemplate.name.asc(), PostboxTemplate.id.asc()) + .all() + ) + + def create_template( + 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, + name_pattern: str, + address_pattern: str, + classification: str, + allow_vacant_delivery: bool, + actor_id: str | None, + ) -> PostboxTemplate: + clean_slug = _slug(slug or name, fallback="template") + if ( + session.query(PostboxTemplate) + .filter( + PostboxTemplate.tenant_id == tenant_id, + PostboxTemplate.slug == clean_slug, + ) + .count() + ): + raise PostboxError( + "template_slug_exists", + f"A Postbox template with slug '{clean_slug}' already exists.", + ) + self._validate_scope( + tenant_id=tenant_id, + scope_kind=scope_kind, + scope_id=scope_id, + ) + self._validate_patterns(name_pattern, address_pattern) + template = PostboxTemplate( + tenant_id=tenant_id, + slug=clean_slug, + name=name.strip(), + description=description, + status="draft", + current_revision=1, + created_by=actor_id, + updated_by=actor_id, + ) + revision = PostboxTemplateRevision( + tenant_id=tenant_id, + revision=1, + function_type_id=function_type_id, + scope_kind=scope_kind, + scope_id=scope_id, + name_pattern=name_pattern, + address_pattern=address_pattern, + classification=classification, + allow_vacant_delivery=allow_vacant_delivery, + encryption_profile="plaintext_v1", + history_policy={}, + routing_policy={"mode": "none"}, + retention_policy={}, + created_by=actor_id, + ) + template.revisions.append(revision) + session.add(template) + session.flush() + _publish_postbox_event( + session, + "postbox.template.created.v1", + tenant_id=tenant_id, + resource_type="postbox_template", + resource_id=template.id, + actor_type="user", + actor_id=actor_id, + payload={"revision": 1, "status": template.status}, + ) + return template + + def revise_template( + self, + session: Session, + *, + tenant_id: str, + template_id: str, + function_type_id: str | None, + scope_kind: str, + scope_id: str | None, + name_pattern: str, + address_pattern: str, + classification: str, + allow_vacant_delivery: bool, + actor_id: str | None, + ) -> PostboxTemplate: + template = self._get_template( + session, + tenant_id=tenant_id, + template_id=template_id, + ) + if template.status == "retired": + raise PostboxError( + "template_retired", + "A retired Postbox template cannot be revised.", + ) + self._validate_scope( + tenant_id=tenant_id, + scope_kind=scope_kind, + scope_id=scope_id, + ) + self._validate_patterns(name_pattern, address_pattern) + next_revision = max( + (revision.revision for revision in template.revisions), + default=0, + ) + 1 + template.revisions.append( + PostboxTemplateRevision( + tenant_id=tenant_id, + revision=next_revision, + function_type_id=function_type_id, + scope_kind=scope_kind, + scope_id=scope_id, + name_pattern=name_pattern, + address_pattern=address_pattern, + classification=classification, + allow_vacant_delivery=allow_vacant_delivery, + encryption_profile="plaintext_v1", + history_policy={}, + routing_policy={"mode": "none"}, + retention_policy={}, + created_by=actor_id, + ) + ) + template.current_revision = next_revision + template.status = "draft" + template.updated_by = actor_id + session.flush() + _publish_postbox_event( + session, + "postbox.template.revised.v1", + tenant_id=tenant_id, + resource_type="postbox_template", + resource_id=template.id, + actor_type="user", + actor_id=actor_id, + payload={"revision": next_revision, "status": template.status}, + ) + return template + + def publish_template( + self, + session: Session, + *, + tenant_id: str, + template_id: str, + revision_number: int | None, + actor_id: str | None, + ) -> PostboxTemplate: + template = self._get_template( + session, + tenant_id=tenant_id, + template_id=template_id, + ) + revision = next( + ( + item + for item in template.revisions + if item.revision == (revision_number or template.current_revision) + ), + None, + ) + if revision is None: + raise PostboxError( + "revision_not_found", + "The requested Postbox template revision does not exist.", + ) + if revision.published_at is None: + revision.published_at = utc_now() + template.published_revision_id = revision.id + template.current_revision = revision.revision + template.status = "published" + template.updated_by = actor_id + session.flush() + _publish_postbox_event( + session, + "postbox.template.published.v1", + tenant_id=tenant_id, + resource_type="postbox_template", + resource_id=template.id, + actor_type="user", + actor_id=actor_id, + payload={ + "revision": revision.revision, + "revision_id": revision.id, + "status": template.status, + }, + ) + return template + + def retire_template( + self, + session: Session, + *, + tenant_id: str, + template_id: str, + actor_id: str | None, + ) -> PostboxTemplate: + template = self._get_template( + session, + tenant_id=tenant_id, + template_id=template_id, + ) + template.status = "retired" + template.retired_at = utc_now() + template.updated_by = actor_id + session.flush() + _publish_postbox_event( + session, + "postbox.template.retired.v1", + tenant_id=tenant_id, + resource_type="postbox_template", + resource_id=template.id, + actor_type="user", + actor_id=actor_id, + payload={"status": template.status}, + ) + return template + + def materialize_template( + self, + session: Session, + *, + tenant_id: str, + template_id: str, + organization_unit_id: str, + function_id: str, + context_key: str | None, + actor_id: str | None, + ) -> Postbox: + template = self._get_template( + session, + tenant_id=tenant_id, + template_id=template_id, + ) + if template.status != "published" or not template.published_revision_id: + raise PostboxError( + "template_not_published", + "Only a published Postbox template can materialize an address.", + ) + revision = next( + ( + item + for item in template.revisions + if item.id == template.published_revision_id + ), + None, + ) + if revision is None: + raise PostboxError( + "revision_not_found", + "The published Postbox template revision is unavailable.", + ) + unit, function = self._validate_function_target( + tenant_id=tenant_id, + organization_unit_id=organization_unit_id, + function_id=function_id, + ) + if ( + revision.function_type_id + and function.function_type_id != revision.function_type_id + ): + raise PostboxError( + "function_type_mismatch", + "The selected function does not match the template function type.", + ) + if not self._unit_in_scope( + unit, + tenant_id=tenant_id, + scope_kind=revision.scope_kind, + scope_id=revision.scope_id, + ): + raise PostboxError( + "unit_out_of_scope", + "The selected organization unit is outside the template scope.", + ) + key = self._template_address_key( + template.id, + unit.id, + function.id, + context_key, + ) + existing = self._postbox_for_address_key( + session, + tenant_id=tenant_id, + address_key=key, + ) + 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] + return self._create_postbox_records( + session, + tenant_id=tenant_id, + address_key=key, + address=address, + name=rendered_name or f"{unit.name} / {function.name}", + description=template.description, + classification=revision.classification, + organization_unit=unit, + function=function, + context_key=context_key, + template=template, + revision=revision, + source="template", + actor_id=actor_id, + ) + + def organization_targets( + self, + *, + tenant_id: str, + ) -> tuple[dict[str, object], ...]: + units = self._organizations.organization_units_for_tenant(tenant_id) + result: list[dict[str, object]] = [] + for unit in units: + if unit.status != "active": + continue + functions = self._organizations.functions_for_organization_unit( + unit.id, + include_subunits=False, + ) + result.append( + { + "id": unit.id, + "slug": unit.slug, + "name": unit.name, + "unit_type_id": unit.unit_type_id, + "parent_id": unit.parent_id, + "functions": [ + { + "id": function.id, + "slug": function.slug, + "name": function.name, + "function_type_id": function.function_type_id, + "delegable": function.delegable, + "act_in_place_allowed": function.act_in_place_allowed, + } + for function in functions + if function.status == "active" + ], + } + ) + return tuple(result) + + # Grouping projections + def list_groupings( + self, + session: Session, + *, + tenant_id: str, + actor: PostboxActorRef, + ) -> tuple[PostboxGrouping, ...]: + return tuple( + session.query(PostboxGrouping) + .options(selectinload(PostboxGrouping.sources)) + .filter( + PostboxGrouping.tenant_id == tenant_id, + PostboxGrouping.account_id == actor.account_id, + ) + .order_by( + PostboxGrouping.is_default.desc(), + PostboxGrouping.name.asc(), + ) + .all() + ) + + def save_grouping( + self, + session: Session, + *, + tenant_id: str, + actor: PostboxActorRef, + grouping_id: str | None, + name: str, + is_default: bool, + postbox_ids: Sequence[str], + ) -> PostboxGrouping: + grouping = ( + session.query(PostboxGrouping) + .options(selectinload(PostboxGrouping.sources)) + .filter( + PostboxGrouping.id == grouping_id, + PostboxGrouping.tenant_id == tenant_id, + PostboxGrouping.account_id == actor.account_id, + ) + .one_or_none() + if grouping_id + else None + ) + if grouping_id and grouping is None: + raise PostboxError("grouping_not_found", "Postbox grouping not found.") + existing_ids = tuple( + source.postbox_id for source in grouping.sources + ) if grouping is not None else () + requested = tuple(dict.fromkeys(postbox_ids)) + visibility_candidates = tuple(dict.fromkeys((*requested, *existing_ids))) + allowed = set( + self._allowed_postbox_ids( + session, + tenant_id=tenant_id, + postbox_ids=visibility_candidates, + actor=actor, + action="read", + ) + ) + if not set(requested).issubset(allowed): + raise PostboxError( + "grouping_source_denied", + "A grouping can only include Postboxes currently visible to the account.", + ) + retained_hidden_ids = tuple( + postbox_id + for postbox_id in existing_ids + if postbox_id not in allowed and postbox_id not in requested + ) + saved_ids = (*requested, *retained_hidden_ids) + if grouping is None: + grouping = PostboxGrouping( + tenant_id=tenant_id, + account_id=actor.account_id, + name=name.strip(), + is_default=is_default, + settings={}, + ) + session.add(grouping) + else: + grouping.name = name.strip() + grouping.is_default = is_default + grouping.sources.clear() + if is_default: + ( + session.query(PostboxGrouping) + .filter( + PostboxGrouping.tenant_id == tenant_id, + PostboxGrouping.account_id == actor.account_id, + PostboxGrouping.id != grouping.id, + ) + .update({PostboxGrouping.is_default: False}) + ) + for position, postbox_id in enumerate(saved_ids): + grouping.sources.append( + PostboxGroupingSource( + tenant_id=tenant_id, + postbox_id=postbox_id, + position=position, + ) + ) + session.flush() + return grouping + + def delete_grouping( + self, + session: Session, + *, + tenant_id: str, + actor: PostboxActorRef, + grouping_id: str, + ) -> None: + grouping = ( + session.query(PostboxGrouping) + .filter( + PostboxGrouping.id == grouping_id, + PostboxGrouping.tenant_id == tenant_id, + PostboxGrouping.account_id == actor.account_id, + ) + .one_or_none() + ) + if grouping is None: + raise PostboxError("grouping_not_found", "Postbox grouping not found.") + session.delete(grouping) + session.flush() + + # Internal helpers + def _get_postbox( + self, + session: Session, + *, + tenant_id: str, + postbox_id: str, + required: bool = True, + ) -> Postbox | None: + postbox = ( + session.query(Postbox) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter( + Postbox.id == postbox_id, + Postbox.tenant_id == tenant_id, + ) + .one_or_none() + ) + if postbox is None and required: + raise PostboxError("postbox_not_found", "Postbox not found.") + return postbox + + def _get_message( + self, + session: Session, + *, + tenant_id: str, + message_id: str, + required: bool = True, + ) -> PostboxMessage | None: + message = ( + session.query(PostboxMessage) + .options( + selectinload(PostboxMessage.participants), + selectinload(PostboxMessage.attachments), + selectinload(PostboxMessage.receipts), + ) + .filter( + PostboxMessage.id == message_id, + PostboxMessage.tenant_id == tenant_id, + ) + .one_or_none() + ) + if message is None and required: + raise PostboxError("message_not_found", "Postbox message not found.") + return message + + def _get_template( + self, + session: Session, + *, + tenant_id: str, + template_id: str, + ) -> PostboxTemplate: + template = ( + session.query(PostboxTemplate) + .options(selectinload(PostboxTemplate.revisions)) + .filter( + PostboxTemplate.id == template_id, + PostboxTemplate.tenant_id == tenant_id, + ) + .one_or_none() + ) + if template is None: + raise PostboxError( + "template_not_found", + "Postbox template not found.", + ) + return template + + def _address_revision( + self, + session: Session, + address: PostboxAddress, + ) -> PostboxTemplateRevision | None: + if not address.template_revision_id: + return None + return session.get( + PostboxTemplateRevision, + address.template_revision_id, + ) + + def _assignments_for_actor( + self, + actor: PostboxActorRef, + *, + tenant_id: str, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + assignments = self._idm.organization_function_assignments_for_account( + actor.account_id, + tenant_id=tenant_id, + ) + if assignments: + return tuple(assignments) + if actor.identity_id: + return tuple( + self._idm.organization_function_assignments_for_identity( + actor.identity_id, + tenant_id=tenant_id, + ) + ) + identity = self._identities.identity_for_account(actor.account_id) + if identity is None: + return () + return tuple( + self._idm.organization_function_assignments_for_identity( + identity.id, + tenant_id=tenant_id, + ) + ) + + def _holders( + self, + tenant_id: str, + function_id: str | None, + cache: dict[ + str, + tuple[OrganizationFunctionAssignmentRef, ...], + ] + | None = None, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + if not function_id: + return () + if cache is not None and function_id in cache: + return cache[function_id] + try: + holders = tuple( + self._incumbencies.organization_function_assignments_for_function( + function_id, + tenant_id=tenant_id, + ) + ) + except ValueError: + holders = () + if cache is not None: + cache[function_id] = holders + return holders + + def _holder_cache( + self, + *, + tenant_id: str, + postboxes: Sequence[Postbox], + ) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]: + discovered: list[str] = [] + for postbox in postboxes: + binding = self._active_binding(postbox) + function_id = ( + binding.function_id + if binding is not None + else postbox.address_record.function_id + ) + if function_id: + discovered.append(function_id) + function_ids = tuple(dict.fromkeys(discovered)) + if not function_ids: + return {} + try: + incumbencies = ( + self._incumbencies.organization_function_incumbencies( + function_ids, + tenant_id=tenant_id, + ) + ) + except ValueError: + return {} + return { + function_id: tuple( + incumbencies[function_id].assignments + ) + for function_id in function_ids + if function_id in incumbencies + } + + def _active_binding(self, postbox: Postbox) -> PostboxBinding | None: + now = utc_now() + for binding in postbox.bindings: + if not binding.is_active: + continue + if binding.valid_from and binding.valid_from > now: + continue + if binding.valid_until and binding.valid_until <= now: + continue + return binding + return None + + def _access_decision( + self, + postbox: Postbox, + *, + actor: PostboxActorRef, + action: PostboxAction, + assignments: Sequence[OrganizationFunctionAssignmentRef], + holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]], + ) -> PostboxAccessDecisionRef: + binding = self._active_binding(postbox) + function_id = ( + binding.function_id + if binding is not None + else postbox.address_record.function_id + ) + unit_id = ( + binding.organization_unit_id + if binding is not None + else postbox.address_record.organization_unit_id + ) + holders = self._holders(postbox.tenant_id, function_id, holder_cache) + holder_count = len({holder.identity_id for holder in holders}) + base = { + "action": action, + "postbox_id": postbox.id, + "organization_unit_id": unit_id, + "function_id": function_id, + "holder_count": holder_count, + "vacant": holder_count == 0, + } + if postbox.status != "active": + return PostboxAccessDecisionRef( + allowed=False, + reason_code="postbox_inactive", + explanation="The Postbox is not active.", + **base, + ) + if action not in actor.authorized_actions: + return PostboxAccessDecisionRef( + allowed=False, + reason_code="generic_permission_missing", + explanation=( + "The account lacks the generic Postbox permission for this action." + ), + **base, + ) + if action == "administer": + return PostboxAccessDecisionRef( + allowed=True, + reason_code="generic_administrator", + explanation="The account has Postbox administration permission.", + **base, + ) + if binding is None or not function_id or not unit_id: + return PostboxAccessDecisionRef( + allowed=False, + reason_code="function_binding_missing", + explanation=( + "This Postbox has no active organization-function binding." + ), + **base, + ) + + matching: list[OrganizationFunctionAssignmentRef] = [] + for assignment in assignments: + if assignment.tenant_id != postbox.tenant_id: + continue + if not self._assignment_matches_binding(assignment, binding): + continue + if assignment.source == "acting_for": + if actor.selected_assignment_id != assignment.id: + continue + if ( + assignment.acting_for_account_id + and actor.acting_for_account_id + != assignment.acting_for_account_id + ): + continue + matching.append(assignment) + if not matching: + return PostboxAccessDecisionRef( + allowed=False, + reason_code="effective_assignment_missing", + explanation=( + "No current function assignment grants this account access " + "to the Postbox." + ), + **base, + ) + selected = next( + ( + assignment + for assignment in matching + if assignment.id == actor.selected_assignment_id + ), + matching[0], + ) + return PostboxAccessDecisionRef( + allowed=True, + reason_code=f"effective_{selected.source}_assignment", + explanation=( + "Access follows the current effective organization-function " + f"assignment ({selected.source})." + ), + assignment_ids=tuple(item.id for item in matching), + assignment_sources=tuple(item.source for item in matching), + selected_assignment_id=selected.id, + **base, + ) + + def _assignment_matches_binding( + self, + assignment: OrganizationFunctionAssignmentRef, + binding: PostboxBinding, + ) -> bool: + if ( + assignment.function_id == binding.function_id + and assignment.organization_unit_id == binding.organization_unit_id + ): + return True + if not assignment.applies_to_subunits or not binding.function_type_id: + return False + assigned_function = self._organizations.get_function( + assignment.function_id + ) + target_function = ( + self._organizations.get_function(binding.function_id) + if binding.function_id + else None + ) + if ( + assigned_function is None + or target_function is None + or assigned_function.function_type_id != binding.function_type_id + or target_function.function_type_id != binding.function_type_id + or not binding.organization_unit_id + ): + return False + return self._is_descendant( + binding.organization_unit_id, + assignment.organization_unit_id, + ) + + def _is_descendant(self, unit_id: str, ancestor_id: str) -> bool: + seen: set[str] = set() + current_id: str | None = unit_id + while current_id and current_id not in seen: + if current_id == ancestor_id: + return True + seen.add(current_id) + unit = self._organizations.get_organization_unit(current_id) + current_id = unit.parent_id if unit is not None else None + return False + + def _allowed_postbox_ids( + self, + session: Session, + *, + tenant_id: str, + postbox_ids: Sequence[str], + actor: PostboxActorRef, + action: PostboxAction, + ) -> tuple[str, ...]: + requested = tuple(dict.fromkeys(postbox_ids)) + if not requested: + return () + postboxes = ( + session.query(Postbox) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter( + Postbox.tenant_id == tenant_id, + Postbox.id.in_(requested), + ) + .all() + ) + assignments = self._assignments_for_actor(actor, tenant_id=tenant_id) + holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]] = {} + return tuple( + postbox.id + for postbox in postboxes + if self._access_decision( + postbox, + actor=actor, + action=action, + assignments=assignments, + holder_cache=holder_cache, + ).allowed + ) + + def _directory_entry( + self, + postbox: Postbox, + *, + decision: PostboxAccessDecisionRef | None = None, + holder_cache: dict[ + str, + tuple[OrganizationFunctionAssignmentRef, ...], + ] + | None = None, + ) -> PostboxDirectoryEntryRef: + address = postbox.address_record + holders = self._holders( + postbox.tenant_id, + address.function_id, + holder_cache, + ) + holder_count = len({holder.identity_id for holder in holders}) + return PostboxDirectoryEntryRef( + id=postbox.id, + tenant_id=postbox.tenant_id, + address=address.address, + address_key=address.address_key, + name=postbox.name, + status=postbox.status, + classification=postbox.classification, + organization_unit_id=address.organization_unit_id, + organization_unit_name=address.organization_unit_name, + function_id=address.function_id, + function_name=address.function_name, + context_key=address.context_key, + template_revision_id=address.template_revision_id, + holder_count=holder_count, + vacant=holder_count == 0, + access=decision, + ) + + def _message_ref( + self, + message: PostboxMessage, + *, + account_id: str, + ) -> PostboxMessageRef: + receipt = next( + ( + item + for item in message.receipts + if item.account_id == account_id + ), + None, + ) + return PostboxMessageRef( + id=message.id, + tenant_id=message.tenant_id, + postbox_id=message.postbox_id, + subject=message.subject, + body_text=message.body_text, + status=message.status, + classification=message.classification, + sender_label=message.sender_label, + delivered_at=message.delivered_at, + read_at=receipt.read_at if receipt else None, + acknowledged_at=receipt.acknowledged_at if receipt else None, + expires_at=message.expires_at, + withdrawn_at=message.withdrawn_at, + producer_module=message.producer_module, + producer_resource_type=message.producer_resource_type, + producer_resource_id=message.producer_resource_id, + encryption_profile=message.encryption_profile, + key_epoch=message.key_epoch, + ciphertext_ref=message.ciphertext_ref, + signed_manifest_ref=message.signed_manifest_ref, + participants=tuple( + PostboxParticipantRef( + kind=item.kind, + reference_type=item.reference_type, + reference_id=item.reference_id, + label=item.label, + address=item.address, + ) + for item in message.participants + ), + attachments=tuple( + PostboxAttachmentRef( + reference_type=item.reference_type, + reference_id=item.reference_id, + name=item.name, + media_type=item.media_type, + size_bytes=item.size_bytes, + digest=item.digest, + metadata=_mapping(item.metadata_), + ) + for item in message.attachments + ), + metadata=_mapping(message.metadata_), + ) + + def _delivery_result( + self, + delivery: PostboxDelivery, + postbox: Postbox, + *, + duplicate: bool = False, + ) -> PostboxDeliveryResult: + return PostboxDeliveryResult( + delivery_id=delivery.id, + postbox_id=delivery.postbox_id, + message_id=delivery.message_id, + address=postbox.address_record.address, + status=delivery.status, + vacant=delivery.holder_count == 0, + holder_count=delivery.holder_count, + duplicate=duplicate, + evidence={ + "producer_module": delivery.producer_module, + "producer_resource_type": delivery.producer_resource_type, + "producer_resource_id": delivery.producer_resource_id, + "template_revision_id": delivery.template_revision_id, + "target_snapshot": dict(delivery.target_snapshot or {}), + "accepted_at": delivery.accepted_at.isoformat(), + }, + ) + + def _template_address_key( + self, + template_id: str, + organization_unit_id: str, + function_id: str, + context_key: str | None, + ) -> str: + context_digest = ( + hashlib.sha256(context_key.encode("utf-8")).hexdigest() + if context_key + else "-" + ) + return ( + f"template:{template_id}:unit:{organization_unit_id}:" + f"function:{function_id}:context:{context_digest}" + ) + + def _postbox_for_address_key( + self, + session: Session, + *, + tenant_id: str, + address_key: str, + ) -> Postbox | None: + return ( + session.query(Postbox) + .join(PostboxAddress, Postbox.address_id == PostboxAddress.id) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter( + Postbox.tenant_id == tenant_id, + PostboxAddress.address_key == address_key, + ) + .one_or_none() + ) + + def _create_postbox_records( + self, + session: Session, + *, + tenant_id: str, + address_key: str, + address: str, + name: str, + description: str | None, + classification: str, + organization_unit: OrganizationUnitRef, + function: OrganizationFunctionRef, + context_key: str | None, + template: PostboxTemplate | None, + revision: PostboxTemplateRevision | None, + source: str, + actor_id: str | None, + ) -> Postbox: + address_record = PostboxAddress( + tenant_id=tenant_id, + address_key=address_key, + address=address, + template_id=template.id if template else None, + template_revision_id=revision.id if revision else None, + organization_unit_id=organization_unit.id, + organization_unit_name=organization_unit.name, + function_id=function.id, + function_name=function.name, + function_type_id=function.function_type_id, + context_key=context_key, + status="active", + ) + postbox = Postbox( + tenant_id=tenant_id, + address_record=address_record, + name=name, + description=description, + status="active", + classification=classification, + encryption_profile=( + revision.encryption_profile if revision else "plaintext_v1" + ), + key_epoch=1, + settings={}, + ) + postbox.bindings.append( + PostboxBinding( + tenant_id=tenant_id, + binding_type="function", + organization_unit_id=organization_unit.id, + function_id=function.id, + function_type_id=function.function_type_id, + source=source, + is_active=True, + settings={ + "template_id": template.id if template else None, + "template_revision_id": revision.id if revision else None, + }, + ) + ) + session.add(postbox) + try: + session.flush() + except IntegrityError as exc: + raise PostboxError( + "address_collision", + "The stable Postbox address collides with an existing address.", + ) from exc + self._record_access_event( + session, + tenant_id=tenant_id, + postbox_id=postbox.id, + actor=( + PostboxActorRef( + account_id=actor_id, + authorized_actions=frozenset({"administer"}), + ) + if actor_id + else None + ), + action="postbox.materialize", + outcome="allowed", + reason_code=source, + details={ + "address_key": address_key, + "template_id": template.id if template else None, + "template_revision_id": revision.id if revision else None, + "organization_unit_id": organization_unit.id, + "function_id": function.id, + }, + ) + _publish_postbox_event( + session, + "postbox.materialized.v1", + tenant_id=tenant_id, + resource_type="postbox", + resource_id=postbox.id, + postbox_id=postbox.id, + actor_type="user", + actor_id=actor_id, + payload={ + "source": source, + "template_id": template.id if template else None, + "template_revision_id": revision.id if revision else None, + "organization_unit_id": organization_unit.id, + "function_id": function.id, + }, + ) + return postbox + + def _validate_function_target( + self, + *, + tenant_id: str, + organization_unit_id: str, + function_id: str, + ) -> tuple[OrganizationUnitRef, OrganizationFunctionRef]: + unit = self._organizations.get_organization_unit(organization_unit_id) + function = self._organizations.get_function(function_id) + if unit is None or unit.tenant_id != tenant_id: + raise PostboxError( + "organization_unit_not_found", + "Organization unit not found in the active tenant.", + ) + if function is None or function.tenant_id != tenant_id: + raise PostboxError( + "function_not_found", + "Organization function not found in the active tenant.", + ) + if function.organization_unit_id != unit.id: + raise PostboxError( + "function_unit_mismatch", + "The selected function does not belong to the organization unit.", + ) + if unit.status != "active" or function.status != "active": + raise PostboxError( + "organization_target_inactive", + "The selected organization unit or function is inactive.", + ) + return unit, function + + def _validate_scope( + self, + *, + tenant_id: str, + scope_kind: str, + scope_id: str | None, + ) -> None: + if scope_kind not in {"tenant", "unit", "subtree", "unit_type"}: + raise PostboxError( + "invalid_scope_kind", + "Postbox template scope must be tenant, unit, subtree, or unit_type.", + ) + if scope_kind == "tenant": + if scope_id: + raise PostboxError( + "invalid_scope_id", + "Tenant-scoped Postbox templates do not take a scope id.", + ) + return + if not scope_id: + raise PostboxError( + "scope_id_required", + "This Postbox template scope requires a scope id.", + ) + if scope_kind in {"unit", "subtree"}: + unit = self._organizations.get_organization_unit(scope_id) + if unit is None or unit.tenant_id != tenant_id: + raise PostboxError( + "scope_unit_not_found", + "The Postbox template scope unit was not found.", + ) + + def _unit_in_scope( + self, + unit: OrganizationUnitRef, + *, + tenant_id: str, + scope_kind: str, + scope_id: str | None, + ) -> bool: + if unit.tenant_id != tenant_id: + return False + if scope_kind == "tenant": + return True + 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 scope_kind == "unit_type": + return unit.unit_type_id == scope_id + return False + + def _validate_patterns( + self, + name_pattern: str, + address_pattern: str, + ) -> None: + variables = { + "template_slug": "template", + "template_name": "Template", + "unit_id": "unit-id", + "unit_slug": "unit", + "unit_name": "Unit", + "function_id": "function-id", + "function_slug": "function", + "function_name": "Function", + "context_key": "context", + } + try: + if not name_pattern.format_map(variables).strip(): + raise ValueError("empty name") + if not address_pattern.format_map(variables).strip(): + raise ValueError("empty address") + except (KeyError, ValueError) as exc: + raise PostboxError( + "invalid_template_pattern", + f"Invalid Postbox template pattern: {exc}", + ) from exc + + def _record_access_event( + self, + session: Session, + *, + tenant_id: str, + action: str, + outcome: str, + reason_code: str, + actor: PostboxActorRef | None, + postbox_id: str | None = None, + message_id: str | None = None, + assignment_id: str | None = None, + details: Mapping[str, object] | None = None, + ) -> None: + session.add( + PostboxAccessEvent( + tenant_id=tenant_id, + postbox_id=postbox_id, + message_id=message_id, + account_id=actor.account_id if actor else None, + identity_id=actor.identity_id if actor else None, + assignment_id=assignment_id, + action=action, + outcome=outcome, + reason_code=reason_code, + occurred_at=utc_now(), + details=_mapping(details), + ) + ) + + +__all__ = ["PostboxError", "PostboxService"] diff --git a/src/govoplan_postbox/py.typed b/src/govoplan_postbox/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_postbox/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..311a23c --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.postbox import ( + CAPABILITY_POSTBOX_ACCESS, + CAPABILITY_POSTBOX_DELIVERY, + CAPABILITY_POSTBOX_DIRECTORY, + CAPABILITY_POSTBOX_EVIDENCE, + CAPABILITY_POSTBOX_MESSAGES, +) +from govoplan_postbox.backend.manifest import get_manifest + + +class PostboxManifestTests(unittest.TestCase): + def test_manifest_announces_owned_contracts_and_dependencies(self) -> None: + manifest = get_manifest() + + self.assertEqual(manifest.id, "postbox") + self.assertEqual( + {"identity", "organizations", "idm"}, + set(manifest.dependencies), + ) + self.assertEqual( + { + CAPABILITY_POSTBOX_DIRECTORY, + CAPABILITY_POSTBOX_ACCESS, + CAPABILITY_POSTBOX_MESSAGES, + CAPABILITY_POSTBOX_DELIVERY, + CAPABILITY_POSTBOX_EVIDENCE, + }, + set(manifest.capability_factories), + ) + self.assertEqual("@govoplan/postbox-webui", manifest.frontend.package_name) + self.assertEqual(["/postbox"], [route.path for route in manifest.frontend.routes]) + self.assertIn( + "idm.function_assignments", + manifest.required_capabilities, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..0afeedb --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib +import unittest + +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import create_engine, inspect + + +class PostboxMigrationTests(unittest.TestCase): + def test_baseline_creates_and_drops_owned_tables(self) -> None: + migration = importlib.import_module( + "govoplan_postbox.backend.migrations.versions." + "c7d2e5f8a1b4_v010_postbox_baseline" + ) + engine = create_engine("sqlite:///:memory:") + try: + with engine.begin() as connection: + operations = Operations(MigrationContext.configure(connection)) + original = migration.op + migration.op = operations + try: + migration.upgrade() + tables = set(inspect(connection).get_table_names()) + self.assertIn("postboxes", tables) + self.assertIn("postbox_messages", tables) + self.assertIn("postbox_deliveries", tables) + self.assertIn("postbox_access_events", tables) + message_columns = { + column["name"] + for column in inspect(connection).get_columns( + "postbox_messages" + ) + } + self.assertTrue( + { + "ciphertext_ref", + "signed_manifest_ref", + "wrapped_keys", + "key_epoch", + "expires_at", + "withdrawn_at", + }.issubset(message_columns) + ) + migration.downgrade() + self.assertFalse( + { + table + for table in inspect(connection).get_table_names() + if table.startswith("postbox") + } + ) + finally: + migration.op = original + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_module_boundary.py b/tests/test_module_boundary.py new file mode 100644 index 0000000..6f15ddb --- /dev/null +++ b/tests/test_module_boundary.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + + +class PostboxModuleBoundaryTests(unittest.TestCase): + def test_backend_does_not_import_sibling_module_implementations(self) -> None: + root = Path(__file__).parents[1] / "src" / "govoplan_postbox" + forbidden = ( + "govoplan_access", + "govoplan_campaign", + "govoplan_files", + "govoplan_identity", + "govoplan_idm", + "govoplan_mail", + "govoplan_organizations", + "govoplan_portal", + ) + violations: list[str] = [] + for path in root.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + names: list[str] = [] + if isinstance(node, ast.Import): + names.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + names.append(node.module) + for name in names: + if name.startswith(forbidden): + violations.append(f"{path.relative_to(root)}: {name}") + self.assertEqual([], violations) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_router.py b/tests/test_router.py new file mode 100644 index 0000000..01e45bc --- /dev/null +++ b/tests/test_router.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.identity import IdentityRef +from govoplan_core.core.idm import ( + OrganizationFunctionAssignmentRef, + OrganizationFunctionIncumbencyRef, +) +from govoplan_core.core.organizations import ( + OrganizationFunctionRef, + OrganizationUnitRef, +) +from govoplan_core.db.base import Base +from govoplan_core.db.session import get_session +from govoplan_postbox.backend.db.models import ( + Postbox, + PostboxAccessEvent, + PostboxAddress, + PostboxAttachmentReference, + PostboxBinding, + PostboxDelivery, + PostboxGrouping, + PostboxGroupingSource, + PostboxMessage, + PostboxMessageReceipt, + PostboxParticipant, + PostboxRoute, + PostboxTemplate, + PostboxTemplateRevision, +) +from govoplan_postbox.backend.router import router +from govoplan_postbox.backend.service import PostboxService + + +TABLES = ( + PostboxTemplate.__table__, + PostboxTemplateRevision.__table__, + PostboxAddress.__table__, + Postbox.__table__, + PostboxBinding.__table__, + PostboxMessage.__table__, + PostboxParticipant.__table__, + PostboxAttachmentReference.__table__, + PostboxDelivery.__table__, + PostboxRoute.__table__, + PostboxMessageReceipt.__table__, + PostboxGrouping.__table__, + PostboxGroupingSource.__table__, + PostboxAccessEvent.__table__, +) + + +class FakeIdentityDirectory: + def get_identity(self, identity_id: str): + return IdentityRef(id=identity_id, primary_account_id="account-1") + + def identity_for_account(self, account_id: str): + return IdentityRef(id="identity-1", primary_account_id=account_id) + + def identities_for_accounts(self, account_ids): + return tuple(self.identity_for_account(account_id) for account_id in account_ids) + + def accounts_for_identity(self, identity_id: str): + return () + + +class FakeIdmDirectory: + def __init__(self, assignment: OrganizationFunctionAssignmentRef) -> None: + self.assignment = assignment + + def get_organization_function_assignment(self, assignment_id: str): + return self.assignment if assignment_id == self.assignment.id else None + + def organization_function_assignments_for_identity( + self, + identity_id: str, + *, + tenant_id: str | None = None, + ): + return (self.assignment,) if identity_id == self.assignment.identity_id else () + + def organization_function_assignments_for_account( + self, + account_id: str, + *, + tenant_id: str | None = None, + ): + return (self.assignment,) if account_id == self.assignment.account_id else () + + def organization_function_assignments_for_function( + self, + function_id: str, + *, + tenant_id: str | None = None, + ): + return (self.assignment,) if function_id == self.assignment.function_id else () + + def organization_function_incumbencies( + self, + function_ids, + *, + tenant_id: str, + effective_at=None, + ): + del effective_at + return { + function_id: OrganizationFunctionIncumbencyRef( + tenant_id=tenant_id, + function_id=function_id, + assignments=self.organization_function_assignments_for_function( + function_id, + tenant_id=tenant_id, + ), + ) + for function_id in function_ids + } + + +class FakeOrganizationDirectory: + unit = OrganizationUnitRef( + id="unit-1", + tenant_id="tenant-1", + slug="district", + name="District", + ) + function = OrganizationFunctionRef( + id="function-1", + tenant_id="tenant-1", + organization_unit_id="unit-1", + slug="clerk", + name="Clerk", + function_type_id="clerk-type", + ) + + def get_organization_unit(self, organization_unit_id: str): + return self.unit if organization_unit_id == self.unit.id else None + + def organization_units_for_tenant(self, tenant_id: str): + return (self.unit,) if tenant_id == self.unit.tenant_id else () + + def get_function(self, function_id: str): + return self.function if function_id == self.function.id else None + + def functions_for_organization_unit( + self, + organization_unit_id: str, + *, + include_subunits: bool = False, + ): + return (self.function,) if organization_unit_id == self.unit.id else () + + +class PostboxRouterTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(self.engine, tables=TABLES) + assignment = OrganizationFunctionAssignmentRef( + id="assignment-1", + tenant_id="tenant-1", + identity_id="identity-1", + account_id="account-1", + function_id="function-1", + organization_unit_id="unit-1", + ) + idm = FakeIdmDirectory(assignment) + self.service = PostboxService( + identities=FakeIdentityDirectory(), # type: ignore[arg-type] + idm=idm, # type: ignore[arg-type] + incumbencies=idm, # type: ignore[arg-type] + organizations=FakeOrganizationDirectory(), # type: ignore[arg-type] + ) + with Session(self.engine) as session: + self.postbox = self.service.create_exact_postbox( + session, + tenant_id="tenant-1", + name="District / Clerk", + organization_unit_id="unit-1", + function_id="function-1", + address_key=None, + description=None, + classification="internal", + actor_id="account-1", + ) + session.commit() + self.postbox_id = self.postbox.id + + principal = ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + identity_id="identity-1", + scopes=frozenset( + { + "postbox:postbox:read", + "postbox:message:write", + "postbox:message:acknowledge", + "postbox:delivery:write", + "postbox:binding:admin", + "postbox:template:admin", + } + ), + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="membership-1"), + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + + def session_dependency(): + with Session(self.engine) as session: + yield session + + app.dependency_overrides[get_session] = session_dependency + app.dependency_overrides[get_api_principal] = lambda: principal + self.patch = patch( + "govoplan_postbox.backend.router.get_service", + return_value=self.service, + ) + self.patch.start() + self.client = TestClient(app) + + def tearDown(self) -> None: + self.client.close() + self.patch.stop() + self.engine.dispose() + + 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) + self.assertEqual(self.postbox_id, directory.json()["postboxes"][0]["id"]) + + delivery = self.client.post( + "/api/v1/postbox/deliveries", + json={ + "target": {"postbox_id": self.postbox_id}, + "producer_module": "campaigns", + "producer_resource_type": "campaign_recipient", + "producer_resource_id": "recipient-1", + "idempotency_key": "campaign-1:recipient-1", + "subject": "Decision", + "body_text": "The decision is ready.", + }, + ) + self.assertEqual(201, delivery.status_code, delivery.text) + message_id = delivery.json()["message_id"] + + messages = self.client.get( + "/api/v1/postbox/messages", + params={"postbox_id": self.postbox_id}, + ) + self.assertEqual(200, messages.status_code, messages.text) + self.assertEqual(1, messages.json()["total"]) + self.assertEqual(message_id, messages.json()["messages"][0]["id"]) + + filtered = self.client.get( + "/api/v1/postbox/messages", + params={ + "postbox_id": self.postbox_id, + "q": "decision", + "state": "unread", + }, + ) + self.assertEqual(200, filtered.status_code, filtered.text) + self.assertEqual(1, filtered.json()["total"]) + + acknowledged = self.client.patch( + f"/api/v1/postbox/messages/{message_id}/state", + json={"state": "acknowledged"}, + ) + self.assertEqual(200, acknowledged.status_code, acknowledged.text) + self.assertIsNotNone(acknowledged.json()["read_at"]) + self.assertIsNotNone(acknowledged.json()["acknowledged_at"]) + + unread = self.client.get( + "/api/v1/postbox/messages", + params={ + "postbox_id": self.postbox_id, + "state": "unread", + }, + ) + self.assertEqual(200, unread.status_code, unread.text) + self.assertEqual(0, unread.json()["total"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..6d72976 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,664 @@ +from __future__ import annotations + +import unittest +from datetime import timedelta + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.events import EventBus, event_bus_context +from govoplan_core.core.identity import IdentityRef +from govoplan_core.core.idm import ( + OrganizationFunctionAssignmentRef, + OrganizationFunctionIncumbencyRef, +) +from govoplan_core.core.organizations import ( + OrganizationFunctionRef, + OrganizationUnitRef, +) +from govoplan_core.core.postbox import ( + PostboxActorRef, + PostboxDeliveryRequest, + PostboxTargetRef, +) +from govoplan_core.db.base import Base +from govoplan_core.security.time import utc_now +from govoplan_postbox.backend.db.models import ( + Postbox, + PostboxAccessEvent, + PostboxAddress, + PostboxAttachmentReference, + PostboxBinding, + PostboxDelivery, + PostboxGrouping, + PostboxGroupingSource, + PostboxMessage, + PostboxMessageReceipt, + PostboxParticipant, + PostboxRoute, + PostboxTemplate, + PostboxTemplateRevision, +) +from govoplan_postbox.backend.service import PostboxService + + +POSTBOX_TABLES = ( + PostboxTemplate.__table__, + PostboxTemplateRevision.__table__, + PostboxAddress.__table__, + Postbox.__table__, + PostboxBinding.__table__, + PostboxMessage.__table__, + PostboxParticipant.__table__, + PostboxAttachmentReference.__table__, + PostboxDelivery.__table__, + PostboxRoute.__table__, + PostboxMessageReceipt.__table__, + PostboxGrouping.__table__, + PostboxGroupingSource.__table__, + PostboxAccessEvent.__table__, +) + + +class FakeIdentityDirectory: + def get_identity(self, identity_id: str): + return IdentityRef(id=identity_id, primary_account_id="account-1") + + def identity_for_account(self, account_id: str): + return IdentityRef( + id="identity-1", + primary_account_id=account_id, + account_ids=(account_id,), + ) + + def identities_for_accounts(self, account_ids): + return tuple(self.identity_for_account(account_id) for account_id in account_ids) + + def accounts_for_identity(self, identity_id: str): + return () + + +class FakeIdmDirectory: + def __init__(self) -> None: + self.assignments: list[OrganizationFunctionAssignmentRef] = [] + + def get_organization_function_assignment(self, assignment_id: str): + return next( + ( + assignment + for assignment in self.assignments + if assignment.id == assignment_id + ), + None, + ) + + def organization_function_assignments_for_identity( + self, + identity_id: str, + *, + tenant_id: str | None = None, + ): + return tuple( + assignment + for assignment in self.assignments + if assignment.identity_id == identity_id + and (tenant_id is None or assignment.tenant_id == tenant_id) + and assignment.status == "active" + ) + + def organization_function_assignments_for_account( + self, + account_id: str, + *, + tenant_id: str | None = None, + ): + return tuple( + assignment + for assignment in self.assignments + if assignment.account_id == account_id + and (tenant_id is None or assignment.tenant_id == tenant_id) + and assignment.status == "active" + ) + + def organization_function_assignments_for_function( + self, + function_id: str, + *, + tenant_id: str | None = None, + ): + return tuple( + assignment + for assignment in self.assignments + if assignment.function_id == function_id + and (tenant_id is None or assignment.tenant_id == tenant_id) + and assignment.status == "active" + ) + + def organization_function_incumbencies( + self, + function_ids, + *, + tenant_id: str, + effective_at=None, + ): + del effective_at + return { + function_id: OrganizationFunctionIncumbencyRef( + tenant_id=tenant_id, + function_id=function_id, + assignments=self.organization_function_assignments_for_function( + function_id, + tenant_id=tenant_id, + ), + ) + for function_id in function_ids + } + + +class FakeOrganizationDirectory: + def __init__(self) -> None: + self.units = { + "unit-1": OrganizationUnitRef( + id="unit-1", + tenant_id="tenant-1", + slug="district-north", + name="District North", + unit_type_id="district", + ), + "unit-child": OrganizationUnitRef( + id="unit-child", + tenant_id="tenant-1", + slug="service-desk", + name="Service Desk", + unit_type_id="desk", + parent_id="unit-1", + ), + } + self.functions = { + "function-1": OrganizationFunctionRef( + id="function-1", + tenant_id="tenant-1", + organization_unit_id="unit-1", + slug="case-clerk", + name="Case Clerk", + function_type_id="case-clerk-type", + delegable=True, + ), + "function-child": OrganizationFunctionRef( + id="function-child", + tenant_id="tenant-1", + organization_unit_id="unit-child", + slug="case-clerk", + name="Case Clerk", + function_type_id="case-clerk-type", + delegable=True, + ), + } + + def get_organization_unit(self, organization_unit_id: str): + return self.units.get(organization_unit_id) + + def organization_units_for_tenant(self, tenant_id: str): + return tuple( + unit for unit in self.units.values() if unit.tenant_id == tenant_id + ) + + def get_function(self, function_id: str): + return self.functions.get(function_id) + + def functions_for_organization_unit( + self, + organization_unit_id: str, + *, + include_subunits: bool = False, + ): + return tuple( + function + for function in self.functions.values() + if function.organization_unit_id == organization_unit_id + ) + + +class FakeNotificationDispatch: + def __init__(self) -> None: + self.requests = [] + + def enqueue_notification( + self, + session, + request, + *, + enqueue_delivery=True, + ): + del session + self.requests.append((request, enqueue_delivery)) + return {"id": f"notification-{len(self.requests)}"} + + +class PostboxServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(self.engine, tables=POSTBOX_TABLES) + self.idm = FakeIdmDirectory() + self.organizations = FakeOrganizationDirectory() + self.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] + ) + self.assignment = OrganizationFunctionAssignmentRef( + id="assignment-1", + tenant_id="tenant-1", + identity_id="identity-1", + account_id="account-1", + function_id="function-1", + organization_unit_id="unit-1", + source="direct", + ) + self.actor = PostboxActorRef( + account_id="account-1", + identity_id="identity-1", + authorized_actions=frozenset( + {"discover", "read", "send", "acknowledge"} + ), + ) + + def tearDown(self) -> None: + self.engine.dispose() + + def _create_exact(self, session: Session) -> Postbox: + return self.service.create_exact_postbox( + session, + tenant_id="tenant-1", + name="District North / Case Clerk Intake", + organization_unit_id="unit-1", + function_id="function-1", + address_key=None, + description=None, + classification="internal", + actor_id="admin-1", + ) + + def test_access_follows_current_assignment_and_reports_vacancy(self) -> None: + with Session(self.engine) as session: + postbox = self._create_exact(session) + session.commit() + + denied = self.service.explain_access( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + actor=self.actor, + action="read", + ) + self.assertFalse(denied.allowed) + self.assertTrue(denied.vacant) + + self.idm.assignments.append(self.assignment) + allowed = self.service.explain_access( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + actor=self.actor, + action="read", + ) + self.assertTrue(allowed.allowed) + self.assertFalse(allowed.vacant) + self.assertEqual("assignment-1", allowed.selected_assignment_id) + + self.idm.assignments.clear() + revoked = self.service.explain_access( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + actor=self.actor, + action="read", + ) + self.assertFalse(revoked.allowed) + + def test_acting_assignment_requires_selected_context(self) -> None: + acting = OrganizationFunctionAssignmentRef( + id="acting-assignment", + tenant_id="tenant-1", + identity_id="identity-1", + account_id="account-1", + function_id="function-1", + organization_unit_id="unit-1", + source="acting_for", + delegated_from_assignment_id="source-assignment", + acting_for_account_id="represented-account", + ) + self.idm.assignments.append(acting) + with Session(self.engine) as session: + postbox = self._create_exact(session) + session.commit() + + denied = self.service.explain_access( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + actor=self.actor, + action="read", + ) + allowed = self.service.explain_access( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + actor=PostboxActorRef( + account_id="account-1", + identity_id="identity-1", + selected_assignment_id=acting.id, + acting_for_account_id="represented-account", + authorized_actions=frozenset({"read"}), + ), + action="read", + ) + + self.assertFalse(denied.allowed) + self.assertTrue(allowed.allowed) + self.assertEqual("effective_acting_for_assignment", allowed.reason_code) + + def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None: + 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="subtree", + scope_id="unit-1", + name_pattern="{unit_name} / {function_name} Intake", + 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=1, + actor_id="admin-1", + ) + first = self.service.materialize_template( + session, + tenant_id="tenant-1", + template_id=template.id, + organization_unit_id="unit-child", + function_id="function-child", + context_key="case-42", + actor_id="admin-1", + ) + second = self.service.materialize_template( + session, + tenant_id="tenant-1", + template_id=template.id, + organization_unit_id="unit-child", + function_id="function-child", + context_key="case-42", + actor_id="admin-1", + ) + revised = self.service.revise_template( + session, + tenant_id="tenant-1", + template_id=template.id, + function_type_id="case-clerk-type", + scope_kind="subtree", + scope_id="unit-1", + name_pattern="{unit_name} / {function_name} Work", + address_pattern="{template_slug}.{unit_slug}.{function_slug}", + classification="restricted", + allow_vacant_delivery=True, + actor_id="admin-1", + ) + + self.assertEqual(first.id, second.id) + self.assertEqual(2, len(revised.revisions)) + self.assertEqual( + "{unit_name} / {function_name} Intake", + revised.revisions[0].name_pattern, + ) + self.assertEqual( + "{unit_name} / {function_name} Work", + revised.revisions[1].name_pattern, + ) + + def test_delivery_catalog_exposes_exact_and_derived_target_choices( + self, + ) -> None: + with Session(self.engine) as session: + exact = self._create_exact(session) + template = self.service.create_template( + session, + tenant_id="tenant-1", + slug="case-intake", + name="Case intake", + description="Functional intake", + function_type_id="case-clerk-type", + scope_kind="subtree", + scope_id="unit-1", + name_pattern="{unit_name} / {function_name} Intake", + 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", + ) + session.commit() + + catalog = self.service.delivery_catalog( + session, + tenant_id="tenant-1", + ) + + self.assertEqual([exact.id], [item.id for item in catalog.postboxes]) + self.assertEqual( + ["case-intake"], + [item.slug for item in catalog.templates], + ) + north = next( + unit + for unit in catalog.organization_units + if unit.id == "unit-1" + ) + self.assertEqual( + ["function-1"], + [function.id for function in north.functions], + ) + + def test_delivery_is_idempotent_and_receipts_are_per_account(self) -> None: + self.idm.assignments.append(self.assignment) + notifications = FakeNotificationDispatch() + 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] + notifications=notifications, # type: ignore[arg-type] + ) + events = [] + event_bus = EventBus() + event_bus.subscribe("*", events.append) + with Session(self.engine) as session: + postbox = service.create_exact_postbox( + session, + tenant_id="tenant-1", + name="District North / Case Clerk Intake", + organization_unit_id="unit-1", + function_id="function-1", + address_key=None, + description=None, + classification="internal", + actor_id="admin-1", + ) + request = PostboxDeliveryRequest( + tenant_id="tenant-1", + target=PostboxTargetRef(postbox_id=postbox.id), + producer_module="campaigns", + producer_resource_type="campaign_recipient", + producer_resource_id="recipient-1", + idempotency_key="campaign-1:recipient-1:postbox", + subject="Permit decision", + body_text="The decision is available.", + expires_at=utc_now() + timedelta(days=30), + ) + with event_bus_context(event_bus): + first = service.deliver(session, request) + second = service.deliver(session, request) + self.assertEqual( + 1, + service.count_messages( + session, + tenant_id="tenant-1", + postbox_ids=(postbox.id,), + actor=self.actor, + query="permit", + state="unread", + ), + ) + marked = service.mark_message( + session, + tenant_id="tenant-1", + message_id=first.message_id, + actor=self.actor, + state="acknowledged", + ) + service.mark_message( + session, + tenant_id="tenant-1", + message_id=first.message_id, + actor=self.actor, + state="acknowledged", + ) + session.commit() + + self.assertFalse(first.duplicate) + self.assertTrue(second.duplicate) + self.assertEqual(first.message_id, second.message_id) + self.assertIsNotNone(marked.read_at) + self.assertIsNotNone(marked.acknowledged_at) + self.assertEqual( + 1, + session.query(PostboxMessage).count(), + ) + self.assertEqual( + 1, + session.query(PostboxDelivery).count(), + ) + self.assertEqual( + 1, + session.query(PostboxMessageReceipt).count(), + ) + self.assertEqual( + (), + service.list_messages( + session, + tenant_id="tenant-1", + postbox_ids=(postbox.id,), + actor=self.actor, + query="not present", + ), + ) + self.assertEqual( + 1, + service.count_messages( + session, + tenant_id="tenant-1", + postbox_ids=(postbox.id,), + actor=self.actor, + state="acknowledged", + ), + ) + self.assertEqual(1, len(notifications.requests)) + notification, enqueue_delivery = notifications.requests[0] + self.assertEqual("account-1", notification.recipient_id) + self.assertEqual("account", notification.recipient_type) + self.assertEqual( + f"/postbox?message={first.message_id}", + notification.action_url, + ) + self.assertFalse(enqueue_delivery) + self.assertEqual( + [ + "postbox.delivery.accepted.v1", + "postbox.message.acknowledged.v1", + ], + [event.type for event in events], + ) + + def test_grouping_update_retains_temporarily_hidden_sources(self) -> None: + child_assignment = OrganizationFunctionAssignmentRef( + id="assignment-child", + tenant_id="tenant-1", + identity_id="identity-1", + account_id="account-1", + function_id="function-child", + organization_unit_id="unit-child", + source="direct", + ) + self.idm.assignments.extend((self.assignment, child_assignment)) + with Session(self.engine) as session: + parent = self._create_exact(session) + child = self.service.create_exact_postbox( + session, + tenant_id="tenant-1", + name="Service Desk / Case Clerk Intake", + organization_unit_id="unit-child", + function_id="function-child", + address_key=None, + description=None, + classification="internal", + actor_id="admin-1", + ) + grouping = self.service.save_grouping( + session, + tenant_id="tenant-1", + actor=self.actor, + grouping_id=None, + name="Assigned work", + is_default=True, + postbox_ids=(parent.id, child.id), + ) + session.commit() + grouping_id = grouping.id + + self.idm.assignments.remove(child_assignment) + updated = self.service.save_grouping( + session, + tenant_id="tenant-1", + actor=self.actor, + grouping_id=grouping_id, + name="Assigned work", + is_default=True, + postbox_ids=(parent.id,), + ) + session.commit() + + self.assertEqual( + (parent.id, child.id), + tuple(source.postbox_id for source in updated.sources), + ) + + self.idm.assignments.append(child_assignment) + visible_ids = { + item.id + for item in self.service.list_visible_postboxes( + session, + tenant_id="tenant-1", + actor=self.actor, + ) + } + self.assertEqual({parent.id, child.id}, visible_ids) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..a5406cb --- /dev/null +++ b/webui/package.json @@ -0,0 +1,31 @@ +{ + "name": "@govoplan/postbox-webui", + "version": "0.1.1", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/postbox.css": "./src/styles/postbox.css" + }, + "scripts": { + "test:ui-structure": "node scripts/test-postbox-page-structure.mjs" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": ">=7.18.2 <8" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/scripts/test-postbox-page-structure.mjs b/webui/scripts/test-postbox-page-structure.mjs new file mode 100644 index 0000000..a9acb10 --- /dev/null +++ b/webui/scripts/test-postbox-page-structure.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const page = readFileSync( + new URL("../src/features/postbox/PostboxPage.tsx", import.meta.url), + "utf8" +); +const admin = readFileSync( + new URL("../src/features/postbox/PostboxAdminPanel.tsx", import.meta.url), + "utf8" +); +const styles = readFileSync( + new URL("../src/styles/postbox.css", import.meta.url), + "utf8" +); + +assert.match(page, /postbox-shell/); +assert.match(page, /postbox-directory/); +assert.match(page, /postbox-message-list/); +assert.match(page, /postbox-detail/); +assert.match(page, /postbox\.inbox\.directory/); +assert.match(page, /postbox\.inbox\.messages/); +assert.match(admin, /AdminPageLayout/); +assert.match(admin, /Postbox templates/); +assert.match(admin, /Exact postbox/); +assert.match(styles, /\.postbox-shell\s*\{/); +assert.match(styles, /grid-template-columns:/); + +console.log("Postbox WebUI structure checks passed."); diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts new file mode 100644 index 0000000..f17701d --- /dev/null +++ b/webui/src/api/postbox.ts @@ -0,0 +1,362 @@ +import { + apiFetch, + apiPath, + apiPostJson, + type ApiSettings +} from "@govoplan/core-webui"; + +export type PostboxAccessDecision = { + allowed: boolean; + action: string; + postbox_id: string; + reason_code: string; + explanation: string; + organization_unit_id?: string | null; + function_id?: string | null; + assignment_ids: string[]; + assignment_sources: string[]; + selected_assignment_id?: string | null; + holder_count: number; + vacant: boolean; +}; + +export type PostboxDirectoryItem = { + id: string; + tenant_id: string; + address: string; + address_key: string; + name: string; + status: string; + classification: string; + organization_unit_id?: string | null; + organization_unit_name?: string | null; + function_id?: string | null; + function_name?: string | null; + context_key?: string | null; + template_revision_id?: string | null; + holder_count: number; + vacant: boolean; + access?: PostboxAccessDecision | null; +}; + +export type PostboxParticipant = { + kind: string; + reference_type: string; + reference_id?: string | null; + label?: string | null; + address?: string | null; +}; + +export type PostboxAttachment = { + reference_type: string; + reference_id: string; + name?: string | null; + media_type?: string | null; + size_bytes?: number | null; + digest?: string | null; + metadata: Record; +}; + +export type PostboxMessage = { + id: string; + tenant_id: string; + postbox_id: string; + subject: string; + body_text?: string | null; + status: string; + classification: string; + sender_label?: string | null; + delivered_at: string; + read_at?: string | null; + acknowledged_at?: string | null; + expires_at?: string | null; + withdrawn_at?: string | null; + producer_module?: string | null; + producer_resource_type?: string | null; + producer_resource_id?: string | null; + encryption_profile: string; + key_epoch: number; + ciphertext_ref?: string | null; + signed_manifest_ref?: string | null; + participants: PostboxParticipant[]; + attachments: PostboxAttachment[]; + metadata: Record; +}; + +export type PostboxGrouping = { + id: string; + name: string; + is_default: boolean; + postbox_ids: string[]; + created_at: string; + updated_at: string; +}; + +export type PostboxOrganizationFunction = { + id: string; + slug: string; + name: string; + function_type_id?: string | null; + delegable: boolean; + act_in_place_allowed: boolean; +}; + +export type PostboxOrganizationUnit = { + id: string; + slug: string; + name: string; + unit_type_id?: string | null; + parent_id?: string | null; + functions: PostboxOrganizationFunction[]; +}; + +export type PostboxTemplateRevision = { + id: string; + revision: number; + function_type_id?: string | null; + scope_kind: "tenant" | "unit" | "subtree" | "unit_type"; + scope_id?: string | null; + name_pattern: string; + address_pattern: string; + classification: string; + allow_vacant_delivery: boolean; + encryption_profile: string; + history_policy: Record; + routing_policy: Record; + retention_policy: Record; + published_at?: string | null; + created_at: string; +}; + +export type PostboxTemplate = { + id: string; + tenant_id: string; + slug: string; + name: string; + description?: string | null; + status: string; + current_revision: number; + published_revision_id?: string | null; + revisions: PostboxTemplateRevision[]; + created_at: string; + updated_at: string; +}; + +export type PostboxTemplateRevisionPayload = Pick< + PostboxTemplateRevision, + | "function_type_id" + | "scope_kind" + | "scope_id" + | "name_pattern" + | "address_pattern" + | "classification" + | "allow_vacant_delivery" +>; + +export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & { + slug: string; + name: string; + description?: string | null; +}; + +export type PostboxExactCreatePayload = { + name: string; + description?: string | null; + organization_unit_id: string; + function_id: string; + address_key?: string | null; + classification: string; +}; + +export async function listPostboxes(settings: ApiSettings): Promise { + const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>( + settings, + "/api/v1/postbox/directory" + ); + return response.postboxes; +} + +export async function listPostboxMessages( + settings: ApiSettings, + postboxIds: string[], + limit = 100, + offset = 0, + query = "", + state: "all" | "unread" | "read" | "acknowledged" = "all" +): Promise<{ messages: PostboxMessage[]; total: number; limit: number; offset: number }> { + return apiFetch( + settings, + apiPath("/api/v1/postbox/messages", { + postbox_id: postboxIds, + limit, + offset, + q: query || undefined, + state + }) + ); +} + +export function getPostboxMessage( + settings: ApiSettings, + messageId: string +): Promise { + return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`); +} + +export function markPostboxMessage( + settings: ApiSettings, + messageId: string, + state: "read" | "acknowledged" +): Promise { + return apiFetch( + settings, + `/api/v1/postbox/messages/${encodeURIComponent(messageId)}/state`, + { + method: "PATCH", + body: JSON.stringify({ state }) + } + ); +} + +export async function listPostboxGroupings(settings: ApiSettings): Promise { + const response = await apiFetch<{ groupings: PostboxGrouping[] }>( + settings, + "/api/v1/postbox/groupings" + ); + return response.groupings; +} + +export function createPostboxGrouping( + settings: ApiSettings, + payload: Omit +): Promise { + return apiPostJson(settings, "/api/v1/postbox/groupings", payload); +} + +export function updatePostboxGrouping( + settings: ApiSettings, + groupingId: string, + payload: Omit +): Promise { + return apiFetch( + settings, + `/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`, + { + method: "PUT", + body: JSON.stringify(payload) + } + ); +} + +export function deletePostboxGrouping( + settings: ApiSettings, + groupingId: string +): Promise { + return apiFetch( + settings, + `/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`, + { method: "DELETE" } + ); +} + +export async function listAdminPostboxes(settings: ApiSettings): Promise { + const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>( + settings, + "/api/v1/postbox/admin/postboxes" + ); + return response.postboxes; +} + +export async function listPostboxOrganizationTargets( + settings: ApiSettings +): Promise { + const response = await apiFetch<{ units: PostboxOrganizationUnit[] }>( + settings, + "/api/v1/postbox/admin/organization-targets" + ); + return response.units; +} + +export function createExactPostbox( + settings: ApiSettings, + payload: PostboxExactCreatePayload +): Promise { + return apiPostJson(settings, "/api/v1/postbox/admin/postboxes", payload); +} + +export function archivePostbox( + settings: ApiSettings, + postboxId: string +): Promise { + return apiFetch( + settings, + `/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`, + { method: "DELETE" } + ); +} + +export async function listPostboxTemplates(settings: ApiSettings): Promise { + const response = await apiFetch<{ templates: PostboxTemplate[] }>( + settings, + "/api/v1/postbox/admin/templates" + ); + return response.templates; +} + +export function createPostboxTemplate( + settings: ApiSettings, + payload: PostboxTemplateCreatePayload +): Promise { + return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload); +} + +export function revisePostboxTemplate( + settings: ApiSettings, + templateId: string, + payload: PostboxTemplateRevisionPayload +): Promise { + return apiPostJson( + settings, + `/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`, + payload + ); +} + +export function publishPostboxTemplate( + settings: ApiSettings, + templateId: string, + revision?: number +): Promise { + return apiPostJson( + settings, + `/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`, + { revision: revision ?? null } + ); +} + +export function retirePostboxTemplate( + settings: ApiSettings, + templateId: string +): Promise { + return apiPostJson( + settings, + `/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/retire`, + {} + ); +} + +export function materializePostboxTemplate( + settings: ApiSettings, + templateId: string, + payload: { + organization_unit_id: string; + function_id: string; + context_key?: string | null; + } +): Promise { + return apiPostJson( + settings, + `/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/materialize`, + payload + ); +} diff --git a/webui/src/features/postbox/PostboxAdminPanel.tsx b/webui/src/features/postbox/PostboxAdminPanel.tsx new file mode 100644 index 0000000..c475a5b --- /dev/null +++ b/webui/src/features/postbox/PostboxAdminPanel.tsx @@ -0,0 +1,977 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Archive, + Boxes, + Building2, + Inbox, + Pencil, + Plus, + RefreshCw, + Rocket, + Save, + Trash2 +} from "lucide-react"; +import { + AdminPageLayout, + Button, + ConfirmDialog, + Dialog, + FormField, + IconButton, + SegmentedControl, + SelectionList, + SelectionListItem, + StatusBadge, + ToggleSwitch, + type ApiSettings +} from "@govoplan/core-webui"; +import { + archivePostbox, + createExactPostbox, + createPostboxTemplate, + listAdminPostboxes, + listPostboxOrganizationTargets, + listPostboxTemplates, + materializePostboxTemplate, + publishPostboxTemplate, + retirePostboxTemplate, + revisePostboxTemplate, + type PostboxDirectoryItem, + type PostboxExactCreatePayload, + type PostboxOrganizationFunction, + type PostboxOrganizationUnit, + type PostboxTemplate, + type PostboxTemplateCreatePayload, + type PostboxTemplateRevisionPayload +} from "../../api/postbox"; + + +type AdminMode = "templates" | "postboxes"; +type TemplateDraft = PostboxTemplateCreatePayload & { templateId: string }; +type ExactDraft = PostboxExactCreatePayload; +type MaterializeDraft = { + templateId: string; + organization_unit_id: string; + function_id: string; + context_key: string; +}; + +const templateDefaults = (): TemplateDraft => ({ + templateId: "", + slug: "", + name: "", + description: "", + function_type_id: null, + scope_kind: "tenant", + scope_id: null, + name_pattern: "{unit_name} / {function_name}", + address_pattern: "{template_slug}.{unit_slug}.{function_slug}", + classification: "internal", + allow_vacant_delivery: true +}); + +const exactDefaults = (): ExactDraft => ({ + name: "", + description: "", + organization_unit_id: "", + function_id: "", + address_key: "", + classification: "internal" +}); + +export default function PostboxAdminPanel({ + settings, + canManageBindings, + canManageTemplates +}: { + settings: ApiSettings; + canManageBindings: boolean; + canManageTemplates: boolean; +}) { + const [mode, setMode] = useState( + canManageTemplates ? "templates" : "postboxes" + ); + const [templates, setTemplates] = useState([]); + const [postboxes, setPostboxes] = useState([]); + const [units, setUnits] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [selectedPostboxId, setSelectedPostboxId] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [templateDialogOpen, setTemplateDialogOpen] = useState(false); + const [templateDraft, setTemplateDraft] = useState(templateDefaults); + const [exactDialogOpen, setExactDialogOpen] = useState(false); + const [exactDraft, setExactDraft] = useState(exactDefaults); + const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false); + const [materializeDraft, setMaterializeDraft] = useState({ + templateId: "", + organization_unit_id: "", + function_id: "", + context_key: "" + }); + const [archiveTarget, setArchiveTarget] = useState(null); + + const selectedTemplate = useMemo( + () => templates.find((template) => template.id === selectedTemplateId) ?? templates[0] ?? null, + [templates, selectedTemplateId] + ); + const selectedPostbox = useMemo( + () => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? postboxes[0] ?? null, + [postboxes, selectedPostboxId] + ); + const functionTypes = useMemo(() => { + const values = new Map(); + for (const unit of units) { + for (const fn of unit.functions) { + if (fn.function_type_id && !values.has(fn.function_type_id)) { + values.set(fn.function_type_id, fn.name); + } + } + } + return [...values].map(([id, name]) => ({ id, name })); + }, [units]); + const unitTypes = useMemo(() => { + const values = new Map(); + for (const unit of units) { + if (unit.unit_type_id && !values.has(unit.unit_type_id)) { + values.set(unit.unit_type_id, unit.name); + } + } + return [...values].map(([id, example]) => ({ id, example })); + }, [units]); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [nextTemplates, nextPostboxes, nextUnits] = await Promise.all([ + canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]), + canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]), + listPostboxOrganizationTargets(settings) + ]); + setTemplates(nextTemplates); + setPostboxes(nextPostboxes); + setUnits(nextUnits); + setSelectedTemplateId((current) => + current && nextTemplates.some((template) => template.id === current) + ? current + : nextTemplates[0]?.id ?? "" + ); + setSelectedPostboxId((current) => + current && nextPostboxes.some((postbox) => postbox.id === current) + ? current + : nextPostboxes[0]?.id ?? "" + ); + } catch (loadError) { + setError(errorMessage(loadError)); + } finally { + setLoading(false); + } + }, [canManageBindings, canManageTemplates, settings]); + + useEffect(() => { + void load(); + }, [load]); + + function openNewTemplate() { + setTemplateDraft(templateDefaults()); + setTemplateDialogOpen(true); + } + + function openTemplateRevision(template: PostboxTemplate) { + const revision = currentRevision(template); + if (!revision) return; + setTemplateDraft({ + templateId: template.id, + slug: template.slug, + name: template.name, + description: template.description || "", + function_type_id: revision.function_type_id ?? null, + scope_kind: revision.scope_kind, + scope_id: revision.scope_id ?? null, + name_pattern: revision.name_pattern, + address_pattern: revision.address_pattern, + classification: revision.classification, + allow_vacant_delivery: revision.allow_vacant_delivery + }); + setTemplateDialogOpen(true); + } + + async function saveTemplate() { + setBusy(true); + setError(""); + setSuccess(""); + try { + if (templateDraft.templateId) { + await revisePostboxTemplate( + settings, + templateDraft.templateId, + revisionPayload(templateDraft) + ); + setSuccess("A new immutable template revision was created."); + } else { + await createPostboxTemplate(settings, { + slug: templateDraft.slug, + name: templateDraft.name, + description: templateDraft.description || null, + ...revisionPayload(templateDraft) + }); + setSuccess("Postbox template created as a draft."); + } + setTemplateDialogOpen(false); + await load(); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + async function publishSelected() { + if (!selectedTemplate) return; + setBusy(true); + setError(""); + setSuccess(""); + try { + await publishPostboxTemplate( + settings, + selectedTemplate.id, + selectedTemplate.current_revision + ); + setSuccess(`Published revision ${selectedTemplate.current_revision}.`); + await load(); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + async function retireSelected() { + if (!selectedTemplate) return; + setBusy(true); + setError(""); + setSuccess(""); + try { + await retirePostboxTemplate(settings, selectedTemplate.id); + setSuccess("Postbox template retired. Existing addresses remain durable."); + await load(); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + function openExact() { + const unit = units.find((item) => item.functions.length); + setExactDraft({ + ...exactDefaults(), + organization_unit_id: unit?.id ?? "", + function_id: unit?.functions[0]?.id ?? "" + }); + setExactDialogOpen(true); + } + + async function saveExact() { + setBusy(true); + setError(""); + setSuccess(""); + try { + await createExactPostbox(settings, { + ...exactDraft, + description: exactDraft.description || null, + address_key: exactDraft.address_key || null + }); + setExactDialogOpen(false); + setSuccess("Exact function-bound Postbox created."); + await load(); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + function openMaterialize(template: PostboxTemplate) { + const revision = currentRevision(template); + const compatible = compatibleTargets(units, revision?.function_type_id); + const unit = compatible[0]; + setMaterializeDraft({ + templateId: template.id, + organization_unit_id: unit?.id ?? "", + function_id: unit?.functions[0]?.id ?? "", + context_key: "" + }); + setMaterializeDialogOpen(true); + } + + async function materialize() { + setBusy(true); + setError(""); + setSuccess(""); + try { + await materializePostboxTemplate(settings, materializeDraft.templateId, { + organization_unit_id: materializeDraft.organization_unit_id, + function_id: materializeDraft.function_id, + context_key: materializeDraft.context_key || null + }); + setMaterializeDialogOpen(false); + setSuccess("Stable Postbox address resolved and materialized."); + await load(); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + async function confirmArchive() { + if (!archiveTarget) return; + setBusy(true); + setError(""); + setSuccess(""); + try { + await archivePostbox(settings, archiveTarget.id); + setSuccess("Postbox archived. Messages and delivery evidence were retained."); + setArchiveTarget(null); + await load(); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + return ( + + } + onClick={() => void load()} + disabled={busy} + /> + {mode === "templates" && canManageTemplates ? ( + + ) : null} + {mode === "postboxes" && canManageBindings ? ( + + ) : null} + + } + > + setMode(value as AdminMode)} + options={[ + ...(canManageTemplates ? [{ id: "templates", label: "Templates" }] : []), + ...(canManageBindings ? [{ id: "postboxes", label: "Postboxes" }] : []) + ]} + ariaLabel="Postbox administration section" + /> + {mode === "templates" ? ( + void publishSelected()} + onRetire={() => void retireSelected()} + onMaterialize={openMaterialize} + busy={busy} + canManageBindings={canManageBindings} + /> + ) : ( + + )} + + setTemplateDialogOpen(false)} + onSave={() => void saveTemplate()} + /> + setExactDialogOpen(false)} + onSave={() => void saveExact()} + /> + item.id === materializeDraft.templateId) ?? null} + units={units} + busy={busy} + onChange={setMaterializeDraft} + onClose={() => setMaterializeDialogOpen(false)} + onSave={() => void materialize()} + /> + void confirmArchive()} + onCancel={() => setArchiveTarget(null)} + /> + + ); +} + +function TemplateWorkspace({ + templates, + selected, + onSelect, + onRevise, + onPublish, + onRetire, + onMaterialize, + busy, + canManageBindings +}: { + templates: PostboxTemplate[]; + selected: PostboxTemplate | null; + onSelect: (id: string) => void; + onRevise: (template: PostboxTemplate) => void; + onPublish: () => void; + onRetire: () => void; + onMaterialize: (template: PostboxTemplate) => void; + busy: boolean; + canManageBindings: boolean; +}) { + const revision = selected ? currentRevision(selected) : null; + return ( +
+ +
+ {selected && revision ? ( + <> +
+
+ Template +

{selected.name}

+

{selected.description || "No description."}

+
+
+ + + {canManageBindings ? ( + + ) : null} + +
+
+
+
Revision
{revision.revision}{revision.published_at ? " · published" : " · draft"}
+
Function type
{revision.function_type_id || "Any function type"}
+
Scope
{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}
+
Classification
{revision.classification}
+
Vacant delivery
{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}
+
Encryption
{revision.encryption_profile}
+
Name pattern
{revision.name_pattern}
+
Address pattern
{revision.address_pattern}
+
+
+

Immutable revisions

+ {selected.revisions.map((item) => ( +
+ Revision {item.revision} + {item.classification} · {item.scope_kind} + +
+ ))} +
+ + ) : ( +
+ + Select a template +

Published revisions lazily resolve stable unit-specific addresses.

+
+ )} +
+
+ ); +} + +function PostboxWorkspace({ + postboxes, + selected, + onSelect, + onArchive, + busy +}: { + postboxes: PostboxDirectoryItem[]; + selected: PostboxDirectoryItem | null; + onSelect: (id: string) => void; + onArchive: (postbox: PostboxDirectoryItem) => void; + busy: boolean; +}) { + return ( +
+ +
+ {selected ? ( + <> +
+
+ Postbox +

{selected.name}

+

{selected.address}

+
+ +
+
+
Organization unit
{selected.organization_unit_name || "None"}
+
Function
{selected.function_name || "None"}
+
Address key
{selected.address_key}
+
Classification
{selected.classification}
+
Current holders
{selected.holder_count}
+
Vacancy
{selected.vacant ? "Vacant" : "Staffed"}
+
Context
{selected.context_key || "None"}
+
Template revision
{selected.template_revision_id || "Exact Postbox"}
+
+ + ) : ( +
+ + Select a Postbox +

Materialized Postboxes remain durable through vacancy and reassignment.

+
+ )} +
+
+ ); +} + +function TemplateDialog({ + open, + draft, + units, + functionTypes, + unitTypes, + busy, + onChange, + onClose, + onSave +}: { + open: boolean; + draft: TemplateDraft; + units: PostboxOrganizationUnit[]; + functionTypes: Array<{ id: string; name: string }>; + unitTypes: Array<{ id: string; example: string }>; + busy: boolean; + onChange: (draft: TemplateDraft) => void; + onClose: () => void; + onSave: () => void; +}) { + const isRevision = Boolean(draft.templateId); + 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 valid = + draft.name.trim() && + draft.slug.trim() && + draft.name_pattern.trim() && + draft.address_pattern.trim() && + (draft.scope_kind === "tenant" || Boolean(draft.scope_id)); + return ( + + + + + } + > +
+ + onChange({ ...draft, name: event.target.value })} + /> + + + onChange({ ...draft, slug: event.target.value })} + /> + + + onChange({ ...draft, description: event.target.value })} + /> + + + + + + + + {draft.scope_kind !== "tenant" ? ( + + + + ) :
} + + onChange({ ...draft, name_pattern: event.target.value })} + /> + + + onChange({ ...draft, address_pattern: event.target.value })} + /> + + + onChange({ ...draft, classification: event.target.value })} + /> + +
+ onChange({ ...draft, allow_vacant_delivery: checked })} + /> +
+
+

+ Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable. +

+
+ ); +} + +function ExactPostboxDialog({ + open, + draft, + units, + busy, + onChange, + onClose, + onSave +}: { + open: boolean; + draft: ExactDraft; + units: PostboxOrganizationUnit[]; + busy: boolean; + onChange: (draft: ExactDraft) => void; + onClose: () => void; + onSave: () => void; +}) { + const unit = units.find((item) => item.id === draft.organization_unit_id); + return ( + + + + + } + > +
+ + onChange({ ...draft, name: event.target.value })} /> + + + onChange({ ...draft, address_key: event.target.value })} /> + + + + + + + + + onChange({ ...draft, classification: event.target.value })} /> + + + onChange({ ...draft, description: event.target.value })} /> + +
+
+ ); +} + +function MaterializeDialog({ + open, + draft, + template, + units, + busy, + onChange, + onClose, + onSave +}: { + open: boolean; + draft: MaterializeDraft; + template: PostboxTemplate | null; + units: PostboxOrganizationUnit[]; + busy: boolean; + onChange: (draft: MaterializeDraft) => void; + onClose: () => void; + onSave: () => void; +}) { + const revision = template ? currentRevision(template) : null; + const compatible = compatibleTargets(units, revision?.function_type_id); + const unit = compatible.find((item) => item.id === draft.organization_unit_id); + return ( + + + + + } + > +
+ + + + + + + + onChange({ ...draft, context_key: event.target.value })} /> + +
+
+ ); +} + +function currentRevision(template: PostboxTemplate) { + return template.revisions.find((revision) => revision.revision === template.current_revision) + ?? template.revisions.at(-1) + ?? null; +} + +function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload { + return { + function_type_id: draft.function_type_id || null, + scope_kind: draft.scope_kind, + scope_id: draft.scope_kind === "tenant" ? null : draft.scope_id || null, + name_pattern: draft.name_pattern, + address_pattern: draft.address_pattern, + classification: draft.classification, + allow_vacant_delivery: draft.allow_vacant_delivery + }; +} + +function compatibleTargets( + units: PostboxOrganizationUnit[], + functionTypeId?: string | null +): PostboxOrganizationUnit[] { + return units + .map((unit) => ({ + ...unit, + functions: functionTypeId + ? unit.functions.filter((fn) => fn.function_type_id === functionTypeId) + : unit.functions + })) + .filter((unit) => unit.functions.length); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Postbox request failed"; +} diff --git a/webui/src/features/postbox/PostboxInboxWidget.tsx b/webui/src/features/postbox/PostboxInboxWidget.tsx new file mode 100644 index 0000000..2b7a5cc --- /dev/null +++ b/webui/src/features/postbox/PostboxInboxWidget.tsx @@ -0,0 +1,90 @@ +import { useCallback } from "react"; +import { Inbox, Paperclip } from "lucide-react"; +import { Link } from "react-router-dom"; +import { + DashboardWidgetList, + DismissibleAlert, + LoadingFrame, + useDashboardWidgetData, + type ApiSettings, + type DashboardWidgetConfiguration +} from "@govoplan/core-webui"; +import { + listPostboxMessages, + listPostboxes +} from "../../api/postbox"; + +export default function PostboxInboxWidget({ + settings, + refreshKey, + configuration +}: { + settings: ApiSettings; + refreshKey: number; + configuration: DashboardWidgetConfiguration; +}) { + const maxItems = numberSetting(configuration.maxItems, 5, 1, 12); + const load = useCallback(async () => { + const postboxes = await listPostboxes(settings); + if (!postboxes.length) { + return { messages: [], total: 0 }; + } + return listPostboxMessages( + settings, + postboxes.map((postbox) => postbox.id), + maxItems, + 0, + "", + "unread" + ); + }, [maxItems, settings]); + const { data, loading, error } = useDashboardWidgetData(load, refreshKey); + + return ( + + {error && ( + + {error} + + )} + ({ + id: message.id, + title: message.subject, + detail: message.sender_label || message.producer_module || "Postbox", + meta: new Intl.DateTimeFormat(undefined, { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit" + }).format(new Date(message.delivered_at)), + leading: + ); +} + +function numberSetting( + value: unknown, + fallback: number, + minimum: number, + maximum: number +): number { + const numeric = typeof value === "number" ? value : Number(value); + return Number.isFinite(numeric) + ? Math.max(minimum, Math.min(maximum, Math.floor(numeric))) + : fallback; +} diff --git a/webui/src/features/postbox/PostboxPage.tsx b/webui/src/features/postbox/PostboxPage.tsx new file mode 100644 index 0000000..a9d82c3 --- /dev/null +++ b/webui/src/features/postbox/PostboxPage.tsx @@ -0,0 +1,800 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Archive, + Building2, + CheckCheck, + Inbox, + Layers3, + MailOpen, + Paperclip, + Pencil, + Plus, + RefreshCw, + Search, + Trash2, + UserRoundCheck, + X +} from "lucide-react"; +import { + Button, + DataGridPaginationBar, + Dialog, + DismissibleAlert, + FormField, + IconButton, + SegmentedControl, + SelectionList, + SelectionListItem, + StatusBadge, + ToggleSwitch, + hasScope, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + createPostboxGrouping, + deletePostboxGrouping, + getPostboxMessage, + listPostboxGroupings, + listPostboxMessages, + listPostboxes, + markPostboxMessage, + updatePostboxGrouping, + type PostboxDirectoryItem, + type PostboxGrouping, + type PostboxMessage +} from "../../api/postbox"; + + +type GroupingDraft = { + id: string; + name: string; + is_default: boolean; + postbox_ids: string[]; +}; + +type MessageStateFilter = "all" | "unread" | "read" | "acknowledged"; + +const emptyGrouping = (): GroupingDraft => ({ + id: "", + name: "", + is_default: false, + postbox_ids: [] +}); + +export default function PostboxPage({ + settings, + auth +}: { + settings: ApiSettings; + auth: AuthInfo; +}) { + const requestedMessageId = useRef( + new URLSearchParams(window.location.search).get("message") ?? "" + ); + const requestedMessageLoaded = useRef(false); + const [postboxes, setPostboxes] = useState([]); + const [groupings, setGroupings] = useState([]); + const [selectedScope, setSelectedScope] = useState("all"); + const [selectedPostboxId, setSelectedPostboxId] = useState(""); + const [messages, setMessages] = useState([]); + const [selectedMessageId, setSelectedMessageId] = useState(""); + const [selectedMessage, setSelectedMessage] = useState(null); + const [total, setTotal] = useState(0); + const [messageState, setMessageState] = useState("all"); + const [searchDraft, setSearchDraft] = useState(""); + const [messageQuery, setMessageQuery] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [loadingDirectory, setLoadingDirectory] = useState(true); + const [loadingMessages, setLoadingMessages] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [groupingDialogOpen, setGroupingDialogOpen] = useState(false); + const [groupingDraft, setGroupingDraft] = useState(emptyGrouping); + + const canAcknowledge = hasScope(auth, "postbox:message:acknowledge"); + const selectedPostbox = useMemo( + () => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null, + [postboxes, selectedPostboxId] + ); + const selectedGrouping = useMemo( + () => groupings.find((grouping) => grouping.id === selectedScope) ?? null, + [groupings, selectedScope] + ); + const scopePostboxIds = useMemo(() => { + if (selectedPostboxId) return [selectedPostboxId]; + if (selectedGrouping) { + const visible = new Set(postboxes.map((postbox) => postbox.id)); + return selectedGrouping.postbox_ids.filter((postboxId) => visible.has(postboxId)); + } + return postboxes.map((postbox) => postbox.id); + }, [postboxes, selectedGrouping, selectedPostboxId]); + const scopeKey = scopePostboxIds.join("|"); + + const loadDirectory = useCallback(async () => { + setLoadingDirectory(true); + setError(""); + try { + const [nextPostboxes, nextGroupings] = await Promise.all([ + listPostboxes(settings), + listPostboxGroupings(settings) + ]); + setPostboxes(nextPostboxes); + setGroupings(nextGroupings); + setSelectedScope((current) => { + if (current === "all" || nextGroupings.some((grouping) => grouping.id === current)) { + return current; + } + return nextGroupings.find((grouping) => grouping.is_default)?.id ?? "all"; + }); + setSelectedPostboxId((current) => + current && nextPostboxes.some((postbox) => postbox.id === current) + ? current + : "" + ); + } catch (loadError) { + setError(errorMessage(loadError)); + } finally { + setLoadingDirectory(false); + } + }, [settings]); + + const loadMessages = useCallback(async () => { + if (!scopePostboxIds.length) { + setMessages([]); + setSelectedMessageId(""); + setSelectedMessage(null); + setTotal(0); + return; + } + setLoadingMessages(true); + setError(""); + try { + const response = await listPostboxMessages( + settings, + scopePostboxIds, + pageSize, + (page - 1) * pageSize, + messageQuery, + messageState + ); + setMessages(response.messages); + setTotal(response.total); + setSelectedMessageId((current) => + current && + ( + response.messages.some((message) => message.id === current) || + current === requestedMessageId.current + ) + ? current + : "" + ); + if (!response.messages.length) setSelectedMessage(null); + } catch (loadError) { + setError(errorMessage(loadError)); + } finally { + setLoadingMessages(false); + } + }, [messageQuery, messageState, page, pageSize, scopeKey, settings]); + + useEffect(() => { + void loadDirectory(); + }, [loadDirectory]); + + useEffect(() => { + void loadMessages(); + }, [loadMessages]); + + useEffect(() => { + const messageId = requestedMessageId.current; + if ( + requestedMessageLoaded.current || + !messageId || + loadingDirectory || + !postboxes.length + ) { + return; + } + requestedMessageLoaded.current = true; + let cancelled = false; + void (async () => { + try { + const message = await getPostboxMessage(settings, messageId); + if (cancelled) return; + if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) { + setError("The linked message is not available in your current Postbox assignments."); + return; + } + setSelectedScope("all"); + setSelectedPostboxId(message.postbox_id); + setSelectedMessageId(message.id); + setSelectedMessage(message); + } catch (loadError) { + if (!cancelled) setError(errorMessage(loadError)); + } + })(); + return () => { + cancelled = true; + }; + }, [loadingDirectory, postboxes, settings]); + + useEffect(() => { + if (!selectedMessageId) return; + let cancelled = false; + void (async () => { + try { + let message = await getPostboxMessage(settings, selectedMessageId); + if (!message.read_at) { + message = await markPostboxMessage(settings, selectedMessageId, "read"); + } + if (cancelled) return; + setSelectedMessage(message); + setMessages((items) => + items.map((item) => (item.id === message.id ? message : item)) + ); + } catch (loadError) { + if (!cancelled) setError(errorMessage(loadError)); + } + })(); + return () => { + cancelled = true; + }; + }, [selectedMessageId, settings]); + + async function acknowledgeSelected() { + if (!selectedMessage || !canAcknowledge) return; + setBusy(true); + setError(""); + try { + const next = await markPostboxMessage( + settings, + selectedMessage.id, + "acknowledged" + ); + setSelectedMessage(next); + setMessages((items) => + items.map((item) => (item.id === next.id ? next : item)) + ); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + function selectPostbox(postboxId: string) { + setSelectedPostboxId(postboxId); + setPage(1); + setSelectedMessageId(""); + setSelectedMessage(null); + } + + function selectScope(scopeId: string) { + setSelectedScope(scopeId); + setSelectedPostboxId(""); + setPage(1); + setSelectedMessageId(""); + setSelectedMessage(null); + } + + function openNewGrouping() { + setGroupingDraft(emptyGrouping()); + setGroupingDialogOpen(true); + } + + function openGrouping(grouping: PostboxGrouping) { + setGroupingDraft({ + id: grouping.id, + name: grouping.name, + is_default: grouping.is_default, + postbox_ids: [...grouping.postbox_ids] + }); + setGroupingDialogOpen(true); + } + + async function saveGrouping() { + if (!groupingDraft.name.trim()) return; + setBusy(true); + setError(""); + const payload = { + name: groupingDraft.name.trim(), + is_default: groupingDraft.is_default, + postbox_ids: groupingDraft.postbox_ids + }; + try { + const saved = groupingDraft.id + ? await updatePostboxGrouping(settings, groupingDraft.id, payload) + : await createPostboxGrouping(settings, payload); + await loadDirectory(); + setSelectedScope(saved.id); + setSelectedPostboxId(""); + setGroupingDialogOpen(false); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + async function removeGrouping() { + if (!groupingDraft.id) return; + setBusy(true); + setError(""); + try { + await deletePostboxGrouping(settings, groupingDraft.id); + setSelectedScope("all"); + setSelectedPostboxId(""); + setGroupingDialogOpen(false); + await loadDirectory(); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + +
+
+
+ {selectedPostbox ? ( + <> +
+ } + onClick={() => void loadMessages()} + disabled={loadingMessages || busy} + /> +
+
+
+ setSearchDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + setMessageQuery(searchDraft.trim()); + setPage(1); + setSelectedMessageId(""); + setSelectedMessage(null); + }} + /> + {searchDraft || messageQuery ? ( + } + onClick={() => { + setSearchDraft(""); + setMessageQuery(""); + setPage(1); + setSelectedMessageId(""); + setSelectedMessage(null); + }} + /> + ) : null} + } + onClick={() => { + setMessageQuery(searchDraft.trim()); + setPage(1); + setSelectedMessageId(""); + setSelectedMessage(null); + }} + /> +
+ + ariaLabel="Message state" + width="fill" + options={[ + { id: "all", label: "All" }, + { id: "unread", label: "Unread" }, + { id: "read", label: "Read" }, + { id: "acknowledged", label: "Acknowledged" } + ]} + value={messageState} + onChange={(next) => { + setMessageState(next); + setPage(1); + setSelectedMessageId(""); + setSelectedMessage(null); + }} + /> +
+ {error ? ( + + {error} + + ) : null} +
+ {loadingMessages ?

Loading messages

: null} + {!loadingMessages && !messages.length ? ( +
+ + No messages +

This view has no delivered Postbox messages.

+
+ ) : null} + {messages.length ? ( + + {messages.map((message) => ( + setSelectedMessageId(message.id)} + > + + {message.subject} + + + + {message.sender_label || message.producer_module || "Platform"} + + + {sourceName(postboxes, message.postbox_id)} + {message.attachments.length ? ( + {message.attachments.length} + ) : null} + {message.acknowledged_at ? ( + Acknowledged + ) : null} + + + ))} + + ) : null} +
+ { + setPage(next); + setSelectedMessageId(""); + setSelectedMessage(null); + }} + onPageSizeChange={(next) => { + setPageSize(next); + setPage(1); + setSelectedMessageId(""); + setSelectedMessage(null); + }} + /> +
+ +
+
+
+
+ +
+ {selectedMessage ? ( + item.id === selectedMessage.postbox_id)} + /> + ) : ( +
+ + Select a message +

Source, function context, content, and evidence remain attached to the originating postbox.

+
+ )} +
+
+ + setGroupingDialogOpen(false)} + closeDisabled={busy} + footer={ +
+ {groupingDraft.id ? ( + + ) : } +
+ + +
+
+ } + > +
+ + + setGroupingDraft((current) => ({ + ...current, + name: event.target.value + })) + } + /> + +
+ + setGroupingDraft((current) => ({ + ...current, + is_default: checked + })) + } + /> +
+
+
+ Source postboxes + {postboxes.map((postbox) => ( + + ))} +
+
+
+ ); +} + +function MessageDetail({ + message, + postbox +}: { + message: PostboxMessage; + postbox?: PostboxDirectoryItem; +}) { + return ( +
+
+
+ + + {message.acknowledged_at ? ( + + ) : null} +
+

{message.subject}

+
+ {message.sender_label || message.producer_module || "Platform"} + +
+
+
+

Source and responsibility

+
+
Postbox
{postbox?.name || message.postbox_id}
+
Organization
{postbox?.organization_unit_name || "Not recorded"}
+
Function
{postbox?.function_name || "Not recorded"}
+
Address
{postbox?.address || "Not loaded"}
+
Producer
{producerLabel(message)}
+
Encryption profile
{message.encryption_profile} · epoch {message.key_epoch}
+
+
+
+

{message.body_text || "No plaintext body is available for this message."}

+
+ {message.participants.length ? ( +
+

Participants

+ {message.participants.map((participant, index) => ( +
+ {participant.kind} + {participant.label || participant.address || participant.reference_id || participant.reference_type} +
+ ))} +
+ ) : null} +
+

Evidence and attachments

+ {!message.attachments.length ?

No attachment references.

: null} + {message.attachments.map((attachment) => ( +
+ + + {attachment.name || attachment.reference_id} + {attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""} + +
+ ))} +
+ {postbox?.access ? ( +
+ +
+ Current access +

{postbox.access.explanation}

+
+
+ ) : null} +
+ ); +} + +function sourceName( + postboxes: PostboxDirectoryItem[], + postboxId: string +): string { + return postboxes.find((postbox) => postbox.id === postboxId)?.name ?? "Postbox"; +} + +function producerLabel(message: PostboxMessage): string { + const resource = [ + message.producer_module, + message.producer_resource_type, + message.producer_resource_id + ].filter(Boolean); + return resource.length ? resource.join(" / ") : "Platform-native message"; +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }).format(date); +} + +function formatLongDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "long", + timeStyle: "short" + }).format(date); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Postbox request failed"; +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..771634a --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,3 @@ +export { postboxModule as default, postboxModule } from "./module"; +export { default as PostboxPage } from "./features/postbox/PostboxPage"; +export { default as PostboxAdminPanel } from "./features/postbox/PostboxAdminPanel"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..a785af2 --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,151 @@ +import { createElement, lazy } from "react"; +import { + hasScope, + type AdminSectionsUiCapability, + type DashboardWidgetsUiCapability, + type PlatformWebModule +} from "@govoplan/core-webui"; +import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget"; +import "./styles/postbox.css"; + + +const PostboxPage = lazy(() => import("./features/postbox/PostboxPage")); +const PostboxAdminPanel = lazy( + () => import("./features/postbox/PostboxAdminPanel") +); + +const readScope = ["postbox:postbox:read"]; +const postboxDashboardWidgets: DashboardWidgetsUiCapability = { + widgets: [ + { + id: "postbox.inbox", + surfaceId: "postbox.widget.inbox", + title: "Postbox inbox", + description: "Unread messages across accessible Postboxes.", + moduleId: "postbox", + category: "Communication", + order: 55, + defaultVisible: false, + defaultSize: "medium", + supportedSizes: ["medium", "wide"], + anyOf: readScope, + refreshIntervalMs: 30_000, + defaultConfiguration: { + maxItems: 5 + }, + configurationFields: [ + { + id: "maxItems", + label: "Maximum messages", + kind: "number", + min: 1, + max: 12, + step: 1, + required: true + } + ], + render: ({ settings, refreshKey, configuration }) => + createElement(PostboxInboxWidget, { + settings, + refreshKey, + configuration + }) + } + ] +}; + +const postboxAdminSections: AdminSectionsUiCapability = { + sections: [ + { + id: "postbox", + label: "Postboxes", + group: "TENANT", + order: 45, + surfaceId: "postbox.admin.templates", + anyOf: [ + "postbox:binding:admin", + "postbox:template:admin" + ], + render: ({ settings, auth }) => + createElement(PostboxAdminPanel, { + settings, + canManageBindings: hasScope(auth, "postbox:binding:admin"), + canManageTemplates: hasScope(auth, "postbox:template:admin") + }) + } + ] +}; + +export const postboxModule: PlatformWebModule = { + id: "postbox", + label: "Postbox", + version: "0.1.0", + dependencies: ["identity", "organizations", "idm"], + optionalDependencies: [ + "access", + "audit", + "campaigns", + "files", + "mail", + "notifications", + "policy", + "portal", + "views", + "workflow" + ], + navItems: [ + { + to: "/postbox", + label: "Postbox", + iconName: "inbox", + anyOf: readScope, + order: 58 + } + ], + routes: [ + { + path: "/postbox", + anyOf: readScope, + order: 58, + surfaceId: "postbox.inbox.messages", + render: ({ settings, auth }) => + createElement(PostboxPage, { settings, auth }) + } + ], + viewSurfaces: [ + { + id: "postbox.inbox.directory", + moduleId: "postbox", + kind: "section", + label: "Postbox directory", + order: 10 + }, + { + id: "postbox.inbox.messages", + moduleId: "postbox", + kind: "section", + label: "Postbox messages", + order: 20 + }, + { + id: "postbox.widget.inbox", + moduleId: "postbox", + kind: "section", + label: "Postbox inbox widget", + order: 25 + }, + { + id: "postbox.admin.templates", + moduleId: "postbox", + kind: "section", + label: "Postbox templates and bindings", + order: 30 + } + ], + uiCapabilities: { + "admin.sections": postboxAdminSections, + "dashboard.widgets": postboxDashboardWidgets + } +}; + +export default postboxModule; diff --git a/webui/src/styles/postbox.css b/webui/src/styles/postbox.css new file mode 100644 index 0000000..5e7f209 --- /dev/null +++ b/webui/src/styles/postbox.css @@ -0,0 +1,640 @@ +.postbox-page { + position: relative; + display: grid; + grid-template-rows: minmax(0, 1fr); + height: calc(100vh - 115px); + min-height: 0; + overflow: hidden; + padding: 0; + color: var(--text); + background: var(--bg); +} + +.postbox-page *, +.postbox-page *::before, +.postbox-page *::after, +.postbox-admin-page *, +.postbox-admin-page *::before, +.postbox-admin-page *::after { + box-sizing: border-box; +} + +.postbox-shell { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: + minmax(250px, 310px) + minmax(290px, 370px) + minmax(360px, 1fr); + height: 100%; + overflow: hidden; + background: var(--panel); +} + +.postbox-directory, +.postbox-message-list, +.postbox-detail { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--panel); +} + +.postbox-directory, +.postbox-message-list { + border-right: var(--border-line); +} + +.postbox-directory { + background: var(--panel-soft); +} + +.postbox-bar, +.postbox-bar-title, +.postbox-icon-actions, +.postbox-scope-row, +.postbox-item-title, +.postbox-message-heading, +.postbox-message-meta, +.postbox-detail-status, +.postbox-detail-byline, +.postbox-access-explanation, +.postbox-attachments > div, +.postbox-participants > div { + display: flex; + align-items: center; +} + +.postbox-bar { + flex: 0 0 auto; + min-height: 54px; + justify-content: space-between; + gap: 10px; + border-bottom: var(--border-line); + background: var(--panel-header); + padding: 8px 12px; +} + +.postbox-bar-title { + min-width: 0; + gap: 8px; + color: var(--text-strong); +} + +.postbox-bar-title strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.postbox-icon-actions { + gap: 5px; +} + +.postbox-total { + min-width: 24px; + height: 22px; + display: inline-grid; + place-items: center; + border-radius: 999px; + background: var(--line); + color: var(--text-strong); + font-size: 12px; + font-weight: 800; +} + +.postbox-scope-control { + flex: 0 0 auto; + border-bottom: var(--border-line); + padding: 9px 10px; +} + +.postbox-scope-control > label { + display: block; + margin-bottom: 5px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.postbox-scope-row { + gap: 6px; +} + +.postbox-scope-row select { + min-width: 0; + flex: 1; +} + +.postbox-directory-list, +.postbox-messages { + min-height: 0; + overflow: auto; + padding: 7px; +} + +.postbox-directory-list, +.postbox-messages { + flex: 1 1 auto; +} + +.postbox-message-filters { + flex: 0 0 auto; + display: grid; + gap: 7px; + border-bottom: var(--border-line); + padding: 8px 9px; +} + +.postbox-search-row { + display: flex; + align-items: center; + gap: 5px; +} + +.postbox-search-row input { + min-width: 0; + flex: 1; +} + +.postbox-message-list > .data-grid-pagination { + flex: 0 0 auto; + flex-wrap: wrap; + gap: 7px 12px; + border-top: var(--border-line); +} + +.postbox-message-list > .data-grid-pagination .data-grid-page-controls { + width: 100%; + justify-content: center; +} + +.postbox-directory-list .selection-list, +.postbox-messages .selection-list, +.postbox-admin-list .selection-list { + gap: 4px; +} + +.postbox-directory-item, +.postbox-message-item, +.postbox-admin-list .selection-list-item { + display: grid; + min-height: 64px; + gap: 4px; + align-content: center; + text-align: left; +} + +.postbox-item-title, +.postbox-message-heading { + min-width: 0; + justify-content: space-between; + gap: 10px; +} + +.postbox-item-title strong, +.postbox-message-heading strong { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + text-overflow: ellipsis; + white-space: nowrap; +} + +.postbox-item-context, +.postbox-item-address, +.postbox-message-preview, +.postbox-message-meta { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.postbox-message-item.is-read strong { + font-weight: 550; +} + +.postbox-message-item.is-unread strong { + font-weight: 800; +} + +.postbox-message-heading time { + flex: 0 0 auto; + color: var(--muted); + font-size: 11px; +} + +.postbox-message-meta { + justify-content: flex-start; + gap: 10px; +} + +.postbox-message-meta span { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 3px; +} + +.postbox-message-list > .alert { + flex: 0 0 auto; + margin: 8px 10px 0; +} + +.postbox-message-detail { + min-height: 0; + overflow: auto; + padding: 20px 24px 28px; +} + +.postbox-message-detail > header, +.postbox-provenance, +.postbox-body, +.postbox-participants, +.postbox-attachments { + border-bottom: var(--border-line); + padding-bottom: 18px; + margin-bottom: 18px; +} + +.postbox-detail-status { + flex-wrap: wrap; + gap: 6px; +} + +.postbox-message-detail h1 { + max-width: 900px; + margin: 12px 0 9px; + color: var(--text-strong); + font-size: 24px; + font-weight: 650; + overflow-wrap: anywhere; +} + +.postbox-detail-byline { + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 12px; +} + +.postbox-provenance h2, +.postbox-participants h2, +.postbox-attachments h2 { + margin: 0 0 10px; + color: var(--text-strong); + font-size: 14px; +} + +.postbox-provenance dl, +.postbox-admin-properties { + display: grid; + grid-template-columns: repeat(2, minmax(170px, 1fr)); + gap: 10px 18px; + margin: 0; +} + +.postbox-provenance dt, +.postbox-admin-properties dt { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.postbox-provenance dd, +.postbox-admin-properties dd { + min-width: 0; + margin: 3px 0 0; + overflow-wrap: anywhere; +} + +.postbox-body p { + max-width: 900px; + margin: 0; + line-height: 1.6; + white-space: pre-wrap; +} + +.postbox-participants, +.postbox-attachments { + display: grid; + gap: 7px; +} + +.postbox-participants > div, +.postbox-attachments > div { + justify-content: flex-start; + gap: 10px; + border: var(--border-line); + border-radius: var(--radius-sm); + background: var(--surface); + padding: 9px 10px; +} + +.postbox-participants > div strong { + min-width: 90px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; +} + +.postbox-attachments > p { + margin: 0; + color: var(--muted); +} + +.postbox-attachments > div span { + min-width: 0; + display: grid; + gap: 2px; +} + +.postbox-attachments > div strong, +.postbox-attachments > div small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.postbox-attachments > div small { + color: var(--muted); +} + +.postbox-access-explanation { + align-items: flex-start; + gap: 10px; + border-left: 3px solid var(--green); + background: var(--success-soft); + padding: 10px 12px; +} + +.postbox-access-explanation p { + margin: 3px 0 0; + color: var(--muted); + line-height: 1.4; +} + +.postbox-empty { + min-height: 100%; + display: grid; + place-items: center; + align-content: center; + gap: 7px; + padding: 30px; + color: var(--muted); + text-align: center; +} + +.postbox-empty.compact { + min-height: 180px; + padding: 20px; +} + +.postbox-empty strong { + color: var(--text-strong); +} + +.postbox-empty p, +.postbox-note { + max-width: 470px; + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.postbox-dialog { + width: min(720px, calc(100vw - 32px)); + max-width: none; +} + +.postbox-template-dialog { + width: min(900px, calc(100vw - 32px)); +} + +.postbox-form-grid { + display: grid; + gap: 14px; +} + +.postbox-form-grid.two-columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.postbox-form-grid input, +.postbox-form-grid select { + width: 100%; +} + +.postbox-toggle-field { + min-height: 62px; + display: flex; + align-items: flex-end; + padding-bottom: 7px; +} + +.postbox-form-note { + margin: 15px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.postbox-dialog-actions { + width: 100%; + display: flex; + justify-content: space-between; + gap: 12px; +} + +.postbox-source-selector { + max-height: 310px; + display: grid; + gap: 5px; + overflow: auto; + border: var(--border-line); + margin: 16px 0 0; + padding: 10px; +} + +.postbox-source-selector legend { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.postbox-source-selector label { + display: flex; + align-items: center; + gap: 9px; + border-radius: var(--radius-sm); + padding: 7px 8px; +} + +.postbox-source-selector label:hover { + background: var(--sidebar-hover-bg); +} + +.postbox-source-selector label span { + min-width: 0; + display: grid; + gap: 2px; +} + +.postbox-source-selector small { + color: var(--muted); +} + +.postbox-admin-page > .segmented-control { + margin-bottom: 14px; +} + +.postbox-admin-workspace { + min-height: 520px; + display: grid; + grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); + border: var(--border-line); + background: var(--panel); + overflow: hidden; +} + +.postbox-admin-list { + min-width: 0; + min-height: 0; + max-height: 680px; + overflow: auto; + border-right: var(--border-line); + background: var(--panel-soft); + padding: 8px; +} + +.postbox-admin-detail { + min-width: 0; + min-height: 0; + max-height: 680px; + overflow: auto; + padding: 20px 24px 26px; +} + +.postbox-admin-detail-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + border-bottom: var(--border-line); + padding-bottom: 17px; + margin-bottom: 18px; +} + +.postbox-admin-detail-heading h2 { + margin: 2px 0 4px; + color: var(--text-strong); + font-size: 20px; +} + +.postbox-admin-detail-heading p { + margin: 0; + color: var(--muted); +} + +.postbox-detail-kicker { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.postbox-admin-properties { + border-bottom: var(--border-line); + padding-bottom: 18px; +} + +.postbox-revision-history { + margin-top: 18px; +} + +.postbox-revision-history h3 { + margin: 0 0 9px; + color: var(--text-strong); + font-size: 14px; +} + +.postbox-revision-history > div { + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto; + align-items: center; + gap: 10px; + border-top: var(--border-line); + padding: 9px 0; +} + +.postbox-revision-history > div span:not(.status-badge) { + color: var(--muted); +} + +@media (max-width: 1180px) { + .postbox-shell { + grid-template-columns: minmax(230px, 290px) minmax(280px, 340px) minmax(330px, 1fr); + overflow-x: auto; + } +} + +@media (max-width: 900px) { + .postbox-page { + height: auto; + min-height: calc(100vh - 115px); + overflow: auto; + } + + .postbox-shell { + grid-template-columns: 1fr; + height: auto; + overflow: visible; + } + + .postbox-directory, + .postbox-message-list { + min-height: 260px; + max-height: 42vh; + border-right: 0; + border-bottom: var(--border-line); + } + + .postbox-detail { + min-height: 440px; + } + + .postbox-admin-workspace { + grid-template-columns: 1fr; + } + + .postbox-admin-list { + max-height: 260px; + border-right: 0; + border-bottom: var(--border-line); + } + + .postbox-admin-detail-heading { + flex-direction: column; + } +} + +@media (max-width: 640px) { + .postbox-form-grid.two-columns, + .postbox-provenance dl, + .postbox-admin-properties { + grid-template-columns: 1fr; + } + + .postbox-message-detail { + padding: 16px; + } +} diff --git a/webui/src/vite-env.d.ts b/webui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/webui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/webui/tsconfig.json b/webui/tsconfig.json new file mode 100644 index 0000000..674c0a3 --- /dev/null +++ b/webui/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src", "tests"] +}