feat: initialize governed postbox module
This commit is contained in:
23
README.md
23
README.md
@@ -18,7 +18,7 @@ This repository owns:
|
|||||||
normalized Identity, IDM, Organizations, and Core/Access contracts
|
normalized Identity, IDM, Organizations, and Core/Access contracts
|
||||||
- API routes for postbox directory, messages, access checks, and administration
|
- API routes for postbox directory, messages, access checks, and administration
|
||||||
- optional integration capabilities for campaign, files, portal, notification, and mail-facing workflows
|
- 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
|
Core owns auth, tenants, RBAC evaluation, database/session primitives, module
|
||||||
discovery, migrations, CSRF/API helpers, and shell layout. Identity owns
|
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/`.
|
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
|
||||||
|
```
|
||||||
|
|||||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -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"
|
||||||
3
src/govoplan_postbox/__init__.py
Normal file
3
src/govoplan_postbox/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN Postbox module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
1
src/govoplan_postbox/backend/__init__.py
Normal file
1
src/govoplan_postbox/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Backend implementation for GovOPlaN Postbox."""
|
||||||
33
src/govoplan_postbox/backend/db/__init__.py
Normal file
33
src/govoplan_postbox/backend/db/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
873
src/govoplan_postbox/backend/db/models.py
Normal file
873
src/govoplan_postbox/backend/db/models.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
359
src/govoplan_postbox/backend/manifest.py
Normal file
359
src/govoplan_postbox/backend/manifest.py
Normal file
@@ -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
|
||||||
1
src/govoplan_postbox/backend/migrations/__init__.py
Normal file
1
src/govoplan_postbox/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Postbox-owned Alembic migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Postbox release migration lineage."""
|
||||||
@@ -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")
|
||||||
732
src/govoplan_postbox/backend/router.py
Normal file
732
src/govoplan_postbox/backend/router.py
Normal file
@@ -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)
|
||||||
31
src/govoplan_postbox/backend/runtime.py
Normal file
31
src/govoplan_postbox/backend/runtime.py
Normal file
@@ -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
|
||||||
260
src/govoplan_postbox/backend/schemas.py
Normal file
260
src/govoplan_postbox/backend/schemas.py
Normal file
@@ -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]
|
||||||
2428
src/govoplan_postbox/backend/service.py
Normal file
2428
src/govoplan_postbox/backend/service.py
Normal file
File diff suppressed because it is too large
Load Diff
1
src/govoplan_postbox/py.typed
Normal file
1
src/govoplan_postbox/py.typed
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
43
tests/test_manifest.py
Normal file
43
tests/test_manifest.py
Normal file
@@ -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()
|
||||||
61
tests/test_migration.py
Normal file
61
tests/test_migration.py
Normal file
@@ -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()
|
||||||
37
tests/test_module_boundary.py
Normal file
37
tests/test_module_boundary.py
Normal file
@@ -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()
|
||||||
302
tests/test_router.py
Normal file
302
tests/test_router.py
Normal file
@@ -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()
|
||||||
664
tests/test_service.py
Normal file
664
tests/test_service.py
Normal file
@@ -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()
|
||||||
31
webui/package.json
Normal file
31
webui/package.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
webui/scripts/test-postbox-page-structure.mjs
Normal file
29
webui/scripts/test-postbox-page-structure.mjs
Normal file
@@ -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.");
|
||||||
362
webui/src/api/postbox.ts
Normal file
362
webui/src/api/postbox.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
routing_policy: Record<string, unknown>;
|
||||||
|
retention_policy: Record<string, unknown>;
|
||||||
|
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<PostboxDirectoryItem[]> {
|
||||||
|
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<PostboxMessage> {
|
||||||
|
return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPostboxMessage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
messageId: string,
|
||||||
|
state: "read" | "acknowledged"
|
||||||
|
): Promise<PostboxMessage> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/state`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ state })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> {
|
||||||
|
const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/postbox/groupings"
|
||||||
|
);
|
||||||
|
return response.groupings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPostboxGrouping(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
|
||||||
|
): Promise<PostboxGrouping> {
|
||||||
|
return apiPostJson(settings, "/api/v1/postbox/groupings", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePostboxGrouping(
|
||||||
|
settings: ApiSettings,
|
||||||
|
groupingId: string,
|
||||||
|
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
|
||||||
|
): Promise<PostboxGrouping> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePostboxGrouping(
|
||||||
|
settings: ApiSettings,
|
||||||
|
groupingId: string
|
||||||
|
): Promise<void> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
|
||||||
|
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/postbox/admin/postboxes"
|
||||||
|
);
|
||||||
|
return response.postboxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPostboxOrganizationTargets(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<PostboxOrganizationUnit[]> {
|
||||||
|
const response = await apiFetch<{ units: PostboxOrganizationUnit[] }>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/postbox/admin/organization-targets"
|
||||||
|
);
|
||||||
|
return response.units;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExactPostbox(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: PostboxExactCreatePayload
|
||||||
|
): Promise<PostboxDirectoryItem> {
|
||||||
|
return apiPostJson(settings, "/api/v1/postbox/admin/postboxes", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archivePostbox(
|
||||||
|
settings: ApiSettings,
|
||||||
|
postboxId: string
|
||||||
|
): Promise<PostboxDirectoryItem> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPostboxTemplates(settings: ApiSettings): Promise<PostboxTemplate[]> {
|
||||||
|
const response = await apiFetch<{ templates: PostboxTemplate[] }>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/postbox/admin/templates"
|
||||||
|
);
|
||||||
|
return response.templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPostboxTemplate(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: PostboxTemplateCreatePayload
|
||||||
|
): Promise<PostboxTemplate> {
|
||||||
|
return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revisePostboxTemplate(
|
||||||
|
settings: ApiSettings,
|
||||||
|
templateId: string,
|
||||||
|
payload: PostboxTemplateRevisionPayload
|
||||||
|
): Promise<PostboxTemplate> {
|
||||||
|
return apiPostJson(
|
||||||
|
settings,
|
||||||
|
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPostboxTemplate(
|
||||||
|
settings: ApiSettings,
|
||||||
|
templateId: string,
|
||||||
|
revision?: number
|
||||||
|
): Promise<PostboxTemplate> {
|
||||||
|
return apiPostJson(
|
||||||
|
settings,
|
||||||
|
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`,
|
||||||
|
{ revision: revision ?? null }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retirePostboxTemplate(
|
||||||
|
settings: ApiSettings,
|
||||||
|
templateId: string
|
||||||
|
): Promise<PostboxTemplate> {
|
||||||
|
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<PostboxDirectoryItem> {
|
||||||
|
return apiPostJson(
|
||||||
|
settings,
|
||||||
|
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/materialize`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
977
webui/src/features/postbox/PostboxAdminPanel.tsx
Normal file
977
webui/src/features/postbox/PostboxAdminPanel.tsx
Normal file
@@ -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<AdminMode>(
|
||||||
|
canManageTemplates ? "templates" : "postboxes"
|
||||||
|
);
|
||||||
|
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
|
||||||
|
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
|
||||||
|
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
|
||||||
|
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<TemplateDraft>(templateDefaults);
|
||||||
|
const [exactDialogOpen, setExactDialogOpen] = useState(false);
|
||||||
|
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
|
||||||
|
const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false);
|
||||||
|
const [materializeDraft, setMaterializeDraft] = useState<MaterializeDraft>({
|
||||||
|
templateId: "",
|
||||||
|
organization_unit_id: "",
|
||||||
|
function_id: "",
|
||||||
|
context_key: ""
|
||||||
|
});
|
||||||
|
const [archiveTarget, setArchiveTarget] = useState<PostboxDirectoryItem | null>(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<string, string>();
|
||||||
|
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<string, string>();
|
||||||
|
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 (
|
||||||
|
<AdminPageLayout
|
||||||
|
title="Postboxes"
|
||||||
|
description="Manage durable organization-function addresses and reusable templates."
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
className="postbox-admin-page"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
label="Refresh"
|
||||||
|
icon={<RefreshCw size={16} />}
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{mode === "templates" && canManageTemplates ? (
|
||||||
|
<Button variant="primary" onClick={openNewTemplate}>
|
||||||
|
<Plus size={16} /> New template
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{mode === "postboxes" && canManageBindings ? (
|
||||||
|
<Button variant="primary" onClick={openExact}>
|
||||||
|
<Plus size={16} /> Exact postbox
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SegmentedControl
|
||||||
|
value={mode}
|
||||||
|
onChange={(value) => setMode(value as AdminMode)}
|
||||||
|
options={[
|
||||||
|
...(canManageTemplates ? [{ id: "templates", label: "Templates" }] : []),
|
||||||
|
...(canManageBindings ? [{ id: "postboxes", label: "Postboxes" }] : [])
|
||||||
|
]}
|
||||||
|
ariaLabel="Postbox administration section"
|
||||||
|
/>
|
||||||
|
{mode === "templates" ? (
|
||||||
|
<TemplateWorkspace
|
||||||
|
templates={templates}
|
||||||
|
selected={selectedTemplate}
|
||||||
|
onSelect={setSelectedTemplateId}
|
||||||
|
onRevise={openTemplateRevision}
|
||||||
|
onPublish={() => void publishSelected()}
|
||||||
|
onRetire={() => void retireSelected()}
|
||||||
|
onMaterialize={openMaterialize}
|
||||||
|
busy={busy}
|
||||||
|
canManageBindings={canManageBindings}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PostboxWorkspace
|
||||||
|
postboxes={postboxes}
|
||||||
|
selected={selectedPostbox}
|
||||||
|
onSelect={setSelectedPostboxId}
|
||||||
|
onArchive={setArchiveTarget}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TemplateDialog
|
||||||
|
open={templateDialogOpen}
|
||||||
|
draft={templateDraft}
|
||||||
|
units={units}
|
||||||
|
functionTypes={functionTypes}
|
||||||
|
unitTypes={unitTypes}
|
||||||
|
busy={busy}
|
||||||
|
onChange={setTemplateDraft}
|
||||||
|
onClose={() => setTemplateDialogOpen(false)}
|
||||||
|
onSave={() => void saveTemplate()}
|
||||||
|
/>
|
||||||
|
<ExactPostboxDialog
|
||||||
|
open={exactDialogOpen}
|
||||||
|
draft={exactDraft}
|
||||||
|
units={units}
|
||||||
|
busy={busy}
|
||||||
|
onChange={setExactDraft}
|
||||||
|
onClose={() => setExactDialogOpen(false)}
|
||||||
|
onSave={() => void saveExact()}
|
||||||
|
/>
|
||||||
|
<MaterializeDialog
|
||||||
|
open={materializeDialogOpen}
|
||||||
|
draft={materializeDraft}
|
||||||
|
template={templates.find((item) => item.id === materializeDraft.templateId) ?? null}
|
||||||
|
units={units}
|
||||||
|
busy={busy}
|
||||||
|
onChange={setMaterializeDraft}
|
||||||
|
onClose={() => setMaterializeDialogOpen(false)}
|
||||||
|
onSave={() => void materialize()}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(archiveTarget)}
|
||||||
|
title="Archive Postbox"
|
||||||
|
message={
|
||||||
|
archiveTarget
|
||||||
|
? `Archive "${archiveTarget.name}"? Its messages and delivery evidence remain retained, but the address stops accepting new delivery.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmLabel="Archive"
|
||||||
|
tone="danger"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => void confirmArchive()}
|
||||||
|
onCancel={() => setArchiveTarget(null)}
|
||||||
|
/>
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="postbox-admin-workspace">
|
||||||
|
<aside className="postbox-admin-list">
|
||||||
|
{!templates.length ? <p className="postbox-note">No Postbox templates.</p> : null}
|
||||||
|
{templates.length ? (
|
||||||
|
<SelectionList label="Postbox templates">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<SelectionListItem
|
||||||
|
key={template.id}
|
||||||
|
selected={selected?.id === template.id}
|
||||||
|
onClick={() => onSelect(template.id)}
|
||||||
|
>
|
||||||
|
<span className="postbox-item-title">
|
||||||
|
<strong>{template.name}</strong>
|
||||||
|
<StatusBadge status={template.status} />
|
||||||
|
</span>
|
||||||
|
<span className="postbox-item-context">
|
||||||
|
{template.slug} · revision {template.current_revision}
|
||||||
|
</span>
|
||||||
|
</SelectionListItem>
|
||||||
|
))}
|
||||||
|
</SelectionList>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
<section className="postbox-admin-detail">
|
||||||
|
{selected && revision ? (
|
||||||
|
<>
|
||||||
|
<div className="postbox-admin-detail-heading">
|
||||||
|
<div>
|
||||||
|
<span className="postbox-detail-kicker">Template</span>
|
||||||
|
<h2>{selected.name}</h2>
|
||||||
|
<p>{selected.description || "No description."}</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={() => onRevise(selected)} disabled={busy || selected.status === "retired"}>
|
||||||
|
<Pencil size={16} /> New revision
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onPublish} disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}>
|
||||||
|
<Save size={16} /> Publish
|
||||||
|
</Button>
|
||||||
|
{canManageBindings ? (
|
||||||
|
<Button onClick={() => onMaterialize(selected)} disabled={busy || selected.status !== "published"}>
|
||||||
|
<Rocket size={16} /> Resolve address
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="danger" onClick={onRetire} disabled={busy || selected.status === "retired"}>
|
||||||
|
<Trash2 size={16} /> Retire
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="postbox-admin-properties">
|
||||||
|
<div><dt>Revision</dt><dd>{revision.revision}{revision.published_at ? " · published" : " · draft"}</dd></div>
|
||||||
|
<div><dt>Function type</dt><dd>{revision.function_type_id || "Any function type"}</dd></div>
|
||||||
|
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
|
||||||
|
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
|
||||||
|
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
|
||||||
|
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
|
||||||
|
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
|
||||||
|
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div className="postbox-revision-history">
|
||||||
|
<h3>Immutable revisions</h3>
|
||||||
|
{selected.revisions.map((item) => (
|
||||||
|
<div key={item.id}>
|
||||||
|
<strong>Revision {item.revision}</strong>
|
||||||
|
<span>{item.classification} · {item.scope_kind}</span>
|
||||||
|
<StatusBadge
|
||||||
|
status={item.published_at ? "published" : "draft"}
|
||||||
|
label={item.published_at ? "Published" : "Draft"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="postbox-empty">
|
||||||
|
<Boxes size={24} />
|
||||||
|
<strong>Select a template</strong>
|
||||||
|
<p>Published revisions lazily resolve stable unit-specific addresses.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostboxWorkspace({
|
||||||
|
postboxes,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onArchive,
|
||||||
|
busy
|
||||||
|
}: {
|
||||||
|
postboxes: PostboxDirectoryItem[];
|
||||||
|
selected: PostboxDirectoryItem | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onArchive: (postbox: PostboxDirectoryItem) => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="postbox-admin-workspace">
|
||||||
|
<aside className="postbox-admin-list">
|
||||||
|
{!postboxes.length ? <p className="postbox-note">No materialized Postboxes.</p> : null}
|
||||||
|
{postboxes.length ? (
|
||||||
|
<SelectionList label="Materialized Postboxes">
|
||||||
|
{postboxes.map((postbox) => (
|
||||||
|
<SelectionListItem
|
||||||
|
key={postbox.id}
|
||||||
|
selected={selected?.id === postbox.id}
|
||||||
|
onClick={() => onSelect(postbox.id)}
|
||||||
|
>
|
||||||
|
<span className="postbox-item-title">
|
||||||
|
<strong>{postbox.name}</strong>
|
||||||
|
<StatusBadge status={postbox.status} />
|
||||||
|
</span>
|
||||||
|
<span className="postbox-item-context">
|
||||||
|
{postbox.organization_unit_name} · {postbox.function_name}
|
||||||
|
</span>
|
||||||
|
</SelectionListItem>
|
||||||
|
))}
|
||||||
|
</SelectionList>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
<section className="postbox-admin-detail">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className="postbox-admin-detail-heading">
|
||||||
|
<div>
|
||||||
|
<span className="postbox-detail-kicker">Postbox</span>
|
||||||
|
<h2>{selected.name}</h2>
|
||||||
|
<p>{selected.address}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => onArchive(selected)}
|
||||||
|
disabled={busy || selected.status !== "active"}
|
||||||
|
>
|
||||||
|
<Archive size={16} /> Archive
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<dl className="postbox-admin-properties">
|
||||||
|
<div><dt>Organization unit</dt><dd>{selected.organization_unit_name || "None"}</dd></div>
|
||||||
|
<div><dt>Function</dt><dd>{selected.function_name || "None"}</dd></div>
|
||||||
|
<div><dt>Address key</dt><dd>{selected.address_key}</dd></div>
|
||||||
|
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
|
||||||
|
<div><dt>Current holders</dt><dd>{selected.holder_count}</dd></div>
|
||||||
|
<div><dt>Vacancy</dt><dd>{selected.vacant ? "Vacant" : "Staffed"}</dd></div>
|
||||||
|
<div><dt>Context</dt><dd>{selected.context_key || "None"}</dd></div>
|
||||||
|
<div><dt>Template revision</dt><dd>{selected.template_revision_id || "Exact Postbox"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="postbox-empty">
|
||||||
|
<Inbox size={24} />
|
||||||
|
<strong>Select a Postbox</strong>
|
||||||
|
<p>Materialized Postboxes remain durable through vacancy and reassignment.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={isRevision ? "Create template revision" : "New Postbox template"}
|
||||||
|
className="postbox-dialog postbox-template-dialog"
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={onSave} disabled={busy || !valid}>
|
||||||
|
{isRevision ? "Create revision" : "Create draft"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="postbox-form-grid two-columns">
|
||||||
|
<FormField label="Name">
|
||||||
|
<input
|
||||||
|
value={draft.name}
|
||||||
|
disabled={isRevision}
|
||||||
|
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Slug">
|
||||||
|
<input
|
||||||
|
value={draft.slug}
|
||||||
|
disabled={isRevision}
|
||||||
|
onChange={(event) => onChange({ ...draft, slug: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description">
|
||||||
|
<input
|
||||||
|
value={draft.description || ""}
|
||||||
|
disabled={isRevision}
|
||||||
|
onChange={(event) => onChange({ ...draft, description: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Function type">
|
||||||
|
<select
|
||||||
|
value={draft.function_type_id || ""}
|
||||||
|
onChange={(event) => onChange({
|
||||||
|
...draft,
|
||||||
|
function_type_id: event.target.value || null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">Any function type</option>
|
||||||
|
{functionTypes.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name} · {item.id}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Scope">
|
||||||
|
<select
|
||||||
|
value={draft.scope_kind}
|
||||||
|
onChange={(event) => {
|
||||||
|
const scope_kind = event.target.value as TemplateDraft["scope_kind"];
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
scope_kind,
|
||||||
|
scope_id: scope_kind === "tenant" ? null : ""
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="tenant">Tenant</option>
|
||||||
|
<option value="unit">One unit</option>
|
||||||
|
<option value="subtree">Unit subtree</option>
|
||||||
|
<option value="unit_type">Unit type</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{draft.scope_kind !== "tenant" ? (
|
||||||
|
<FormField label="Scope target">
|
||||||
|
<select
|
||||||
|
value={draft.scope_id || ""}
|
||||||
|
onChange={(event) => onChange({ ...draft, scope_id: event.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Select target</option>
|
||||||
|
{scopeOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
) : <div />}
|
||||||
|
<FormField label="Name pattern">
|
||||||
|
<input
|
||||||
|
value={draft.name_pattern}
|
||||||
|
onChange={(event) => onChange({ ...draft, name_pattern: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Address pattern">
|
||||||
|
<input
|
||||||
|
value={draft.address_pattern}
|
||||||
|
onChange={(event) => onChange({ ...draft, address_pattern: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Classification">
|
||||||
|
<input
|
||||||
|
value={draft.classification}
|
||||||
|
onChange={(event) => onChange({ ...draft, classification: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="postbox-toggle-field">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Accept delivery while vacant"
|
||||||
|
checked={draft.allow_vacant_delivery}
|
||||||
|
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="postbox-form-note">
|
||||||
|
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
|
||||||
|
</p>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="New exact Postbox"
|
||||||
|
className="postbox-dialog"
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={busy || !draft.name.trim() || !draft.organization_unit_id || !draft.function_id}
|
||||||
|
>
|
||||||
|
Create Postbox
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="postbox-form-grid two-columns">
|
||||||
|
<FormField label="Name">
|
||||||
|
<input value={draft.name} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Address key">
|
||||||
|
<input value={draft.address_key || ""} placeholder="Generated when empty" onChange={(event) => onChange({ ...draft, address_key: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Organization unit">
|
||||||
|
<select
|
||||||
|
value={draft.organization_unit_id}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextUnit = units.find((item) => item.id === event.target.value);
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
organization_unit_id: event.target.value,
|
||||||
|
function_id: nextUnit?.functions[0]?.id ?? ""
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select unit</option>
|
||||||
|
{units.filter((item) => item.functions.length).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Function">
|
||||||
|
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
|
||||||
|
<option value="">Select function</option>
|
||||||
|
{(unit?.functions || []).map((fn) => (
|
||||||
|
<option key={fn.id} value={fn.id}>{fn.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Classification">
|
||||||
|
<input value={draft.classification} onChange={(event) => onChange({ ...draft, classification: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description">
|
||||||
|
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={`Resolve address${template ? ` · ${template.name}` : ""}`}
|
||||||
|
className="postbox-dialog"
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={onSave} disabled={busy || !draft.organization_unit_id || !draft.function_id}>
|
||||||
|
Resolve address
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="postbox-form-grid">
|
||||||
|
<FormField label="Organization unit">
|
||||||
|
<select
|
||||||
|
value={draft.organization_unit_id}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextUnit = compatible.find((item) => item.id === event.target.value);
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
organization_unit_id: event.target.value,
|
||||||
|
function_id: nextUnit?.functions[0]?.id ?? ""
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select unit</option>
|
||||||
|
{compatible.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Function">
|
||||||
|
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
|
||||||
|
<option value="">Select function</option>
|
||||||
|
{(unit?.functions || []).map((fn) => (
|
||||||
|
<option key={fn.id} value={fn.id}>{fn.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Optional case or service context">
|
||||||
|
<input value={draft.context_key} onChange={(event) => onChange({ ...draft, context_key: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
90
webui/src/features/postbox/PostboxInboxWidget.tsx
Normal file
90
webui/src/features/postbox/PostboxInboxWidget.tsx
Normal file
@@ -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 (
|
||||||
|
<LoadingFrame loading={loading} label="Loading unread Postbox messages">
|
||||||
|
{error && (
|
||||||
|
<DismissibleAlert tone="warning" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
<DashboardWidgetList
|
||||||
|
emptyText="No unread Postbox messages."
|
||||||
|
items={(data?.messages ?? []).map((message) => ({
|
||||||
|
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: <Inbox size={17} aria-hidden="true" />,
|
||||||
|
trailing: message.attachments.length ? (
|
||||||
|
<span title={`${message.attachments.length} attachments`}>
|
||||||
|
<Paperclip size={15} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : undefined,
|
||||||
|
to: `/postbox?message=${encodeURIComponent(message.id)}`
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="dashboard-contribution-footer">
|
||||||
|
<Link className="btn btn-secondary" to="/postbox">
|
||||||
|
{data?.total ? `Open Postbox (${data.total} unread)` : "Open Postbox"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
800
webui/src/features/postbox/PostboxPage.tsx
Normal file
800
webui/src/features/postbox/PostboxPage.tsx
Normal file
@@ -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<PostboxDirectoryItem[]>([]);
|
||||||
|
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
|
||||||
|
const [selectedScope, setSelectedScope] = useState("all");
|
||||||
|
const [selectedPostboxId, setSelectedPostboxId] = useState("");
|
||||||
|
const [messages, setMessages] = useState<PostboxMessage[]>([]);
|
||||||
|
const [selectedMessageId, setSelectedMessageId] = useState("");
|
||||||
|
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [messageState, setMessageState] = useState<MessageStateFilter>("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<GroupingDraft>(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 (
|
||||||
|
<main className="workspace-data-page module-entry-page postbox-page">
|
||||||
|
<div className="postbox-shell">
|
||||||
|
<aside className="postbox-directory" data-view-surface="postbox.inbox.directory">
|
||||||
|
<div className="postbox-bar">
|
||||||
|
<div className="postbox-bar-title">
|
||||||
|
<Inbox size={17} aria-hidden="true" />
|
||||||
|
<strong>Postbox</strong>
|
||||||
|
</div>
|
||||||
|
<div className="postbox-icon-actions">
|
||||||
|
<IconButton
|
||||||
|
label="New unified view"
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
onClick={openNewGrouping}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Refresh"
|
||||||
|
icon={<RefreshCw size={16} />}
|
||||||
|
onClick={() => void loadDirectory()}
|
||||||
|
disabled={loadingDirectory || busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="postbox-scope-control">
|
||||||
|
<label htmlFor="postbox-scope">Inbox view</label>
|
||||||
|
<div className="postbox-scope-row">
|
||||||
|
<select
|
||||||
|
id="postbox-scope"
|
||||||
|
value={selectedScope}
|
||||||
|
onChange={(event) => selectScope(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All postboxes</option>
|
||||||
|
{groupings.map((grouping) => (
|
||||||
|
<option key={grouping.id} value={grouping.id}>
|
||||||
|
{grouping.name}{grouping.is_default ? " (default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedGrouping ? (
|
||||||
|
<IconButton
|
||||||
|
label="Edit unified view"
|
||||||
|
icon={<Pencil size={15} />}
|
||||||
|
onClick={() => openGrouping(selectedGrouping)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="postbox-directory-list">
|
||||||
|
{loadingDirectory ? <p className="postbox-note">Loading postboxes</p> : null}
|
||||||
|
{!loadingDirectory && !postboxes.length ? (
|
||||||
|
<div className="postbox-empty compact">
|
||||||
|
<Archive size={20} />
|
||||||
|
<strong>No assigned postboxes</strong>
|
||||||
|
<p>Postboxes appear when your account has a current matching function assignment.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{postboxes.length ? (
|
||||||
|
<SelectionList label="Assigned postboxes">
|
||||||
|
{postboxes.map((postbox) => (
|
||||||
|
<SelectionListItem
|
||||||
|
key={postbox.id}
|
||||||
|
selected={selectedPostboxId === postbox.id}
|
||||||
|
className="postbox-directory-item"
|
||||||
|
onClick={() => selectPostbox(postbox.id)}
|
||||||
|
>
|
||||||
|
<span className="postbox-item-title">
|
||||||
|
<strong>{postbox.name}</strong>
|
||||||
|
{postbox.vacant ? (
|
||||||
|
<StatusBadge status="warning" label="Vacant" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="postbox-item-context">
|
||||||
|
{postbox.organization_unit_name || "No organization"} ·{" "}
|
||||||
|
{postbox.function_name || "No function"}
|
||||||
|
</span>
|
||||||
|
<span className="postbox-item-address">{postbox.address}</span>
|
||||||
|
</SelectionListItem>
|
||||||
|
))}
|
||||||
|
</SelectionList>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="postbox-message-list" data-view-surface="postbox.inbox.messages">
|
||||||
|
<div className="postbox-bar">
|
||||||
|
<div className="postbox-bar-title">
|
||||||
|
{selectedPostbox ? (
|
||||||
|
<>
|
||||||
|
<Building2 size={17} aria-hidden="true" />
|
||||||
|
<strong>{selectedPostbox.name}</strong>
|
||||||
|
</>
|
||||||
|
) : selectedGrouping ? (
|
||||||
|
<>
|
||||||
|
<Layers3 size={17} aria-hidden="true" />
|
||||||
|
<strong>{selectedGrouping.name}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Layers3 size={17} aria-hidden="true" />
|
||||||
|
<strong>All postboxes</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="postbox-total">{total}</span>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
label="Refresh messages"
|
||||||
|
icon={<RefreshCw size={16} />}
|
||||||
|
onClick={() => void loadMessages()}
|
||||||
|
disabled={loadingMessages || busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="postbox-message-filters">
|
||||||
|
<div className="postbox-search-row">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchDraft}
|
||||||
|
placeholder="Search messages"
|
||||||
|
aria-label="Search Postbox messages"
|
||||||
|
onChange={(event) => setSearchDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
setMessageQuery(searchDraft.trim());
|
||||||
|
setPage(1);
|
||||||
|
setSelectedMessageId("");
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{searchDraft || messageQuery ? (
|
||||||
|
<IconButton
|
||||||
|
label="Clear message search"
|
||||||
|
icon={<X size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchDraft("");
|
||||||
|
setMessageQuery("");
|
||||||
|
setPage(1);
|
||||||
|
setSelectedMessageId("");
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<IconButton
|
||||||
|
label="Search messages"
|
||||||
|
icon={<Search size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
setMessageQuery(searchDraft.trim());
|
||||||
|
setPage(1);
|
||||||
|
setSelectedMessageId("");
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl<MessageStateFilter>
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<DismissibleAlert tone="danger" compact resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
<div className="postbox-messages">
|
||||||
|
{loadingMessages ? <p className="postbox-note">Loading messages</p> : null}
|
||||||
|
{!loadingMessages && !messages.length ? (
|
||||||
|
<div className="postbox-empty">
|
||||||
|
<MailOpen size={24} />
|
||||||
|
<strong>No messages</strong>
|
||||||
|
<p>This view has no delivered Postbox messages.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{messages.length ? (
|
||||||
|
<SelectionList label="Postbox messages">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<SelectionListItem
|
||||||
|
key={message.id}
|
||||||
|
selected={selectedMessageId === message.id}
|
||||||
|
className={`postbox-message-item ${message.read_at ? "is-read" : "is-unread"}`}
|
||||||
|
onClick={() => setSelectedMessageId(message.id)}
|
||||||
|
>
|
||||||
|
<span className="postbox-message-heading">
|
||||||
|
<strong>{message.subject}</strong>
|
||||||
|
<time>{formatDate(message.delivered_at)}</time>
|
||||||
|
</span>
|
||||||
|
<span className="postbox-message-preview">
|
||||||
|
{message.sender_label || message.producer_module || "Platform"}
|
||||||
|
</span>
|
||||||
|
<span className="postbox-message-meta">
|
||||||
|
<span>{sourceName(postboxes, message.postbox_id)}</span>
|
||||||
|
{message.attachments.length ? (
|
||||||
|
<span><Paperclip size={13} /> {message.attachments.length}</span>
|
||||||
|
) : null}
|
||||||
|
{message.acknowledged_at ? (
|
||||||
|
<span><CheckCheck size={13} /> Acknowledged</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</SelectionListItem>
|
||||||
|
))}
|
||||||
|
</SelectionList>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DataGridPaginationBar
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalRows={total}
|
||||||
|
pageSizeOptions={[25, 50, 100, 200]}
|
||||||
|
disabled={loadingMessages}
|
||||||
|
ariaLabel="Postbox message pagination"
|
||||||
|
onPageChange={(next) => {
|
||||||
|
setPage(next);
|
||||||
|
setSelectedMessageId("");
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}}
|
||||||
|
onPageSizeChange={(next) => {
|
||||||
|
setPageSize(next);
|
||||||
|
setPage(1);
|
||||||
|
setSelectedMessageId("");
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="postbox-detail">
|
||||||
|
<div className="postbox-bar">
|
||||||
|
<div className="postbox-bar-title">
|
||||||
|
<MailOpen size={17} aria-hidden="true" />
|
||||||
|
<strong>{selectedMessage?.subject || "Message"}</strong>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => void acknowledgeSelected()}
|
||||||
|
disabled={!selectedMessage || Boolean(selectedMessage.acknowledged_at) || busy}
|
||||||
|
disabledReason={!canAcknowledge ? "You cannot acknowledge Postbox messages." : undefined}
|
||||||
|
>
|
||||||
|
<CheckCheck size={16} /> Acknowledge
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{selectedMessage ? (
|
||||||
|
<MessageDetail
|
||||||
|
message={selectedMessage}
|
||||||
|
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="postbox-empty">
|
||||||
|
<Inbox size={24} />
|
||||||
|
<strong>Select a message</strong>
|
||||||
|
<p>Source, function context, content, and evidence remain attached to the originating postbox.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={groupingDialogOpen}
|
||||||
|
title={groupingDraft.id ? "Edit unified view" : "New unified view"}
|
||||||
|
className="postbox-dialog"
|
||||||
|
onClose={() => setGroupingDialogOpen(false)}
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={
|
||||||
|
<div className="postbox-dialog-actions">
|
||||||
|
{groupingDraft.id ? (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => void removeGrouping()}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} /> Delete
|
||||||
|
</Button>
|
||||||
|
) : <span />}
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={() => setGroupingDialogOpen(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void saveGrouping()}
|
||||||
|
disabled={busy || !groupingDraft.name.trim()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="postbox-form-grid">
|
||||||
|
<FormField label="Name">
|
||||||
|
<input
|
||||||
|
value={groupingDraft.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGroupingDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
name: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="postbox-toggle-field">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Default unified view"
|
||||||
|
checked={groupingDraft.is_default}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setGroupingDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
is_default: checked
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<fieldset className="postbox-source-selector">
|
||||||
|
<legend>Source postboxes</legend>
|
||||||
|
{postboxes.map((postbox) => (
|
||||||
|
<label key={postbox.id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={groupingDraft.postbox_ids.includes(postbox.id)}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGroupingDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
postbox_ids: event.target.checked
|
||||||
|
? [...current.postbox_ids, postbox.id]
|
||||||
|
: current.postbox_ids.filter((id) => id !== postbox.id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{postbox.name}</strong>
|
||||||
|
<small>{postbox.organization_unit_name} · {postbox.function_name}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageDetail({
|
||||||
|
message,
|
||||||
|
postbox
|
||||||
|
}: {
|
||||||
|
message: PostboxMessage;
|
||||||
|
postbox?: PostboxDirectoryItem;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="postbox-message-detail">
|
||||||
|
<header>
|
||||||
|
<div className="postbox-detail-status">
|
||||||
|
<StatusBadge status={message.status} />
|
||||||
|
<StatusBadge status={message.classification} />
|
||||||
|
{message.acknowledged_at ? (
|
||||||
|
<StatusBadge status="success" label="Acknowledged" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<h1>{message.subject}</h1>
|
||||||
|
<div className="postbox-detail-byline">
|
||||||
|
<span>{message.sender_label || message.producer_module || "Platform"}</span>
|
||||||
|
<time>{formatLongDate(message.delivered_at)}</time>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section className="postbox-provenance">
|
||||||
|
<h2>Source and responsibility</h2>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Postbox</dt><dd>{postbox?.name || message.postbox_id}</dd></div>
|
||||||
|
<div><dt>Organization</dt><dd>{postbox?.organization_unit_name || "Not recorded"}</dd></div>
|
||||||
|
<div><dt>Function</dt><dd>{postbox?.function_name || "Not recorded"}</dd></div>
|
||||||
|
<div><dt>Address</dt><dd>{postbox?.address || "Not loaded"}</dd></div>
|
||||||
|
<div><dt>Producer</dt><dd>{producerLabel(message)}</dd></div>
|
||||||
|
<div><dt>Encryption profile</dt><dd>{message.encryption_profile} · epoch {message.key_epoch}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section className="postbox-body">
|
||||||
|
<p>{message.body_text || "No plaintext body is available for this message."}</p>
|
||||||
|
</section>
|
||||||
|
{message.participants.length ? (
|
||||||
|
<section className="postbox-participants">
|
||||||
|
<h2>Participants</h2>
|
||||||
|
{message.participants.map((participant, index) => (
|
||||||
|
<div key={`${participant.kind}-${participant.reference_id || index}`}>
|
||||||
|
<strong>{participant.kind}</strong>
|
||||||
|
<span>{participant.label || participant.address || participant.reference_id || participant.reference_type}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="postbox-attachments">
|
||||||
|
<h2>Evidence and attachments</h2>
|
||||||
|
{!message.attachments.length ? <p>No attachment references.</p> : null}
|
||||||
|
{message.attachments.map((attachment) => (
|
||||||
|
<div key={`${attachment.reference_type}:${attachment.reference_id}`}>
|
||||||
|
<Paperclip size={15} />
|
||||||
|
<span>
|
||||||
|
<strong>{attachment.name || attachment.reference_id}</strong>
|
||||||
|
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
{postbox?.access ? (
|
||||||
|
<section className="postbox-access-explanation">
|
||||||
|
<UserRoundCheck size={17} />
|
||||||
|
<div>
|
||||||
|
<strong>Current access</strong>
|
||||||
|
<p>{postbox.access.explanation}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
3
webui/src/index.ts
Normal file
3
webui/src/index.ts
Normal file
@@ -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";
|
||||||
151
webui/src/module.ts
Normal file
151
webui/src/module.ts
Normal file
@@ -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;
|
||||||
640
webui/src/styles/postbox.css
Normal file
640
webui/src/styles/postbox.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
webui/src/vite-env.d.ts
vendored
Normal file
1
webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
20
webui/tsconfig.json
Normal file
20
webui/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user