Add Postbox message authoring and concurrency
This commit is contained in:
@@ -17,6 +17,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
@@ -47,6 +48,11 @@ class PostboxTemplate(Base, TimestampMixin):
|
||||
index=True,
|
||||
)
|
||||
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
resource_revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
published_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
@@ -65,6 +71,14 @@ class PostboxTemplate(Base, TimestampMixin):
|
||||
order_by="PostboxTemplateRevision.revision",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"postbox_template",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class PostboxTemplateRevision(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_template_revisions"
|
||||
@@ -264,6 +278,11 @@ class Postbox(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
)
|
||||
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
resource_revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
@@ -286,6 +305,14 @@ class Postbox(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"postbox",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class PostboxBinding(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_bindings"
|
||||
@@ -376,6 +403,12 @@ class PostboxMessage(Base, TimestampMixin):
|
||||
"producer_resource_type",
|
||||
"producer_resource_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"authoring_key",
|
||||
name="uq_postbox_messages_authoring_key",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
@@ -415,6 +448,11 @@ class PostboxMessage(Base, TimestampMixin):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
authoring_key: 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,
|
||||
@@ -774,6 +812,11 @@ class PostboxGrouping(Base, TimestampMixin):
|
||||
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)
|
||||
resource_revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
@@ -786,6 +829,14 @@ class PostboxGrouping(Base, TimestampMixin):
|
||||
order_by="PostboxGroupingSource.position",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"postbox_grouping",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class PostboxGroupingSource(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_grouping_sources"
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"""v0.1.2 message authoring and optimistic concurrency
|
||||
|
||||
Revision ID: f5c8d0e3b7a2
|
||||
Revises: e4b7c9d2a6f1
|
||||
Create Date: 2026-07-31 16:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f5c8d0e3b7a2"
|
||||
down_revision = "e4b7c9d2a6f1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table_name in (
|
||||
"postbox_templates",
|
||||
"postboxes",
|
||||
"postbox_groupings",
|
||||
):
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"resource_revision",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
)
|
||||
)
|
||||
|
||||
with op.batch_alter_table("postbox_messages") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("authoring_key", sa.String(length=255), nullable=True)
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_postbox_messages_authoring_key",
|
||||
("authoring_key",),
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_postbox_messages_authoring_key",
|
||||
("tenant_id", "postbox_id", "authoring_key"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("postbox_messages") as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
"uq_postbox_messages_authoring_key",
|
||||
type_="unique",
|
||||
)
|
||||
batch_op.drop_index("ix_postbox_messages_authoring_key")
|
||||
batch_op.drop_column("authoring_key")
|
||||
|
||||
for table_name in (
|
||||
"postbox_groupings",
|
||||
"postboxes",
|
||||
"postbox_templates",
|
||||
):
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.drop_column("resource_revision")
|
||||
@@ -3,14 +3,21 @@ from __future__ import annotations
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.concurrency import (
|
||||
ConcurrencyError,
|
||||
MissingPreconditionError,
|
||||
RevisionConflictError,
|
||||
assert_revision_precondition,
|
||||
)
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxActorRef,
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxMessageAuthoringRequest,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
@@ -37,18 +44,22 @@ from govoplan_postbox.backend.schemas import (
|
||||
PostboxGroupingItem,
|
||||
PostboxGroupingListResponse,
|
||||
PostboxGroupingPayload,
|
||||
PostboxGroupingUpdateRequest,
|
||||
PostboxMaterializeRequest,
|
||||
PostboxMessageItem,
|
||||
PostboxMessageCreateRequest,
|
||||
PostboxMessageAuthoringPayload,
|
||||
PostboxMessageListResponse,
|
||||
PostboxMessageStateRequest,
|
||||
PostboxOrganizationTargetsResponse,
|
||||
PostboxMutationRequest,
|
||||
PostboxRouteDryRunRequest,
|
||||
PostboxRouteDryRunResponse,
|
||||
PostboxTemplateCreateRequest,
|
||||
PostboxTemplateItem,
|
||||
PostboxTemplateListResponse,
|
||||
PostboxTemplatePublishRequest,
|
||||
PostboxTemplateRevisionPayload,
|
||||
PostboxTemplateReviseRequest,
|
||||
)
|
||||
from govoplan_postbox.backend.service import PostboxError
|
||||
|
||||
@@ -136,6 +147,74 @@ def _http_error(exc: PostboxError) -> HTTPException:
|
||||
)
|
||||
|
||||
|
||||
def _require_mutation_precondition(
|
||||
if_match: str | None,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
base_revision: int,
|
||||
) -> None:
|
||||
try:
|
||||
assert_revision_precondition(
|
||||
if_match,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
submitted_base_revision=base_revision,
|
||||
)
|
||||
except MissingPreconditionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
||||
detail=exc.as_dict(),
|
||||
) from exc
|
||||
except ConcurrencyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "invalid_precondition", "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
|
||||
def _concurrency_http_error(exc: ConcurrencyError) -> HTTPException:
|
||||
if isinstance(exc, MissingPreconditionError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
||||
detail=exc.as_dict(),
|
||||
)
|
||||
if isinstance(exc, RevisionConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||
detail=exc.as_dict(),
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "concurrency_conflict", "message": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
def _set_etag(response: Response, etag: str | None) -> None:
|
||||
if etag:
|
||||
response.headers["ETag"] = etag
|
||||
|
||||
|
||||
def _authoring_request(
|
||||
payload: PostboxMessageAuthoringPayload,
|
||||
) -> PostboxMessageAuthoringRequest:
|
||||
return PostboxMessageAuthoringRequest(
|
||||
idempotency_key=payload.idempotency_key,
|
||||
subject=payload.subject,
|
||||
body_text=payload.body_text,
|
||||
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
|
||||
),
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
|
||||
|
||||
def _directory_item(value) -> PostboxDirectoryItem:
|
||||
return PostboxDirectoryItem.model_validate(asdict(value))
|
||||
|
||||
@@ -153,6 +232,8 @@ def _template_item(template) -> PostboxTemplateItem:
|
||||
description=template.description,
|
||||
status=template.status,
|
||||
current_revision=template.current_revision,
|
||||
resource_revision=template.resource_revision,
|
||||
etag=template.strong_etag,
|
||||
published_revision_id=template.published_revision_id,
|
||||
revisions=[
|
||||
{
|
||||
@@ -184,6 +265,8 @@ def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem:
|
||||
id=grouping.id,
|
||||
name=grouping.name,
|
||||
is_default=grouping.is_default,
|
||||
resource_revision=grouping.resource_revision,
|
||||
etag=grouping.strong_etag,
|
||||
postbox_ids=[
|
||||
source.postbox_id
|
||||
for source in grouping.sources
|
||||
@@ -306,6 +389,67 @@ def api_list_postbox_messages(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/messages",
|
||||
response_model=PostboxMessageItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_postbox_message(
|
||||
payload: PostboxMessageCreateRequest,
|
||||
assignment_context_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxMessageItem:
|
||||
_require(principal, SEND_SCOPE)
|
||||
try:
|
||||
message = get_service().create_message(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=payload.postbox_id,
|
||||
actor=_actor(
|
||||
principal,
|
||||
assignment_context_id=assignment_context_id,
|
||||
),
|
||||
request=_authoring_request(payload),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _message_item(message)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/messages/{message_id}/replies",
|
||||
response_model=PostboxMessageItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_reply_to_postbox_message(
|
||||
message_id: str,
|
||||
payload: PostboxMessageAuthoringPayload,
|
||||
assignment_context_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxMessageItem:
|
||||
_require(principal, REPLY_SCOPE)
|
||||
try:
|
||||
message = get_service().reply_to_message(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
message_id=message_id,
|
||||
actor=_actor(
|
||||
principal,
|
||||
assignment_context_id=assignment_context_id,
|
||||
),
|
||||
request=_authoring_request(payload),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _message_item(message)
|
||||
|
||||
|
||||
@router.get("/messages/{message_id}", response_model=PostboxMessageItem)
|
||||
def api_get_postbox_message(
|
||||
message_id: str,
|
||||
@@ -468,6 +612,7 @@ def api_list_postbox_groupings(
|
||||
)
|
||||
def api_create_postbox_grouping(
|
||||
payload: PostboxGroupingPayload,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxGroupingItem:
|
||||
@@ -484,17 +629,27 @@ def api_create_postbox_grouping(
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
||||
item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem)
|
||||
def api_update_postbox_grouping(
|
||||
grouping_id: str,
|
||||
payload: PostboxGroupingPayload,
|
||||
payload: PostboxGroupingUpdateRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxGroupingItem:
|
||||
_require(principal, READ_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox_grouping",
|
||||
resource_id=grouping_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
actor = _actor(principal)
|
||||
try:
|
||||
grouping = get_service().save_grouping(
|
||||
@@ -502,30 +657,48 @@ def api_update_postbox_grouping(
|
||||
tenant_id=principal.tenant_id,
|
||||
actor=actor,
|
||||
grouping_id=grouping_id,
|
||||
**payload.model_dump(),
|
||||
expected_revision=payload.base_revision,
|
||||
**payload.model_dump(exclude={"base_revision"}),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
||||
item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_postbox_grouping(
|
||||
grouping_id: str,
|
||||
payload: PostboxMutationRequest,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> None:
|
||||
_require(principal, READ_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox_grouping",
|
||||
resource_id=grouping_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
get_service().delete_grouping(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
actor=_actor(principal),
|
||||
grouping_id=grouping_id,
|
||||
expected_revision=payload.base_revision,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -573,6 +746,7 @@ def api_admin_postboxes(
|
||||
)
|
||||
def api_create_exact_postbox(
|
||||
payload: PostboxExactCreateRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxDirectoryItem:
|
||||
@@ -587,13 +761,15 @@ def api_create_exact_postbox(
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _directory_item(
|
||||
item = _directory_item(
|
||||
get_service().resolve_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
)
|
||||
)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -602,27 +778,42 @@ def api_create_exact_postbox(
|
||||
)
|
||||
def api_archive_postbox(
|
||||
postbox_id: str,
|
||||
payload: PostboxMutationRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxDirectoryItem:
|
||||
_require(principal, BINDING_ADMIN_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
postbox = get_service().archive_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
actor_id=principal.account_id,
|
||||
expected_revision=payload.base_revision,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
return _directory_item(
|
||||
item = _directory_item(
|
||||
get_service().resolve_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
)
|
||||
)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/admin/templates", response_model=PostboxTemplateListResponse)
|
||||
@@ -649,6 +840,7 @@ def api_postbox_templates(
|
||||
)
|
||||
def api_create_postbox_template(
|
||||
payload: PostboxTemplateCreateRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplateItem:
|
||||
@@ -663,7 +855,9 @@ def api_create_postbox_template(
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _template_item(template)
|
||||
item = _template_item(template)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -672,23 +866,37 @@ def api_create_postbox_template(
|
||||
)
|
||||
def api_revise_postbox_template(
|
||||
template_id: str,
|
||||
payload: PostboxTemplateRevisionPayload,
|
||||
payload: PostboxTemplateReviseRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplateItem:
|
||||
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox_template",
|
||||
resource_id=template_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
template = get_service().revise_template(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
template_id=template_id,
|
||||
actor_id=principal.account_id,
|
||||
**payload.model_dump(),
|
||||
expected_revision=payload.base_revision,
|
||||
**payload.model_dump(exclude={"base_revision"}),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
return _template_item(template)
|
||||
item = _template_item(template)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -698,10 +906,18 @@ def api_revise_postbox_template(
|
||||
def api_publish_postbox_template(
|
||||
template_id: str,
|
||||
payload: PostboxTemplatePublishRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplateItem:
|
||||
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox_template",
|
||||
resource_id=template_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
template = get_service().publish_template(
|
||||
session,
|
||||
@@ -709,11 +925,17 @@ def api_publish_postbox_template(
|
||||
template_id=template_id,
|
||||
revision_number=payload.revision,
|
||||
actor_id=principal.account_id,
|
||||
expected_revision=payload.base_revision,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
return _template_item(template)
|
||||
item = _template_item(template)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -722,21 +944,36 @@ def api_publish_postbox_template(
|
||||
)
|
||||
def api_retire_postbox_template(
|
||||
template_id: str,
|
||||
payload: PostboxMutationRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplateItem:
|
||||
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox_template",
|
||||
resource_id=template_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
template = get_service().retire_template(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
template_id=template_id,
|
||||
actor_id=principal.account_id,
|
||||
expected_revision=payload.base_revision,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
return _template_item(template)
|
||||
item = _template_item(template)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -49,6 +49,8 @@ class PostboxDirectoryItem(BaseModel):
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
access: PostboxAccessDecisionResponse | None = None
|
||||
resource_revision: int = Field(default=1, ge=1)
|
||||
etag: str | None = None
|
||||
|
||||
|
||||
class PostboxDirectoryResponse(BaseModel):
|
||||
@@ -91,6 +93,8 @@ class PostboxMessageItem(BaseModel):
|
||||
producer_module: str | None = None
|
||||
producer_resource_type: str | None = None
|
||||
producer_resource_id: str | None = None
|
||||
in_reply_to_message_id: str | None = None
|
||||
replaces_message_id: str | None = None
|
||||
encryption_profile: str
|
||||
key_epoch: int
|
||||
ciphertext_ref: str | None = None
|
||||
@@ -111,6 +115,20 @@ class PostboxMessageStateRequest(BaseModel):
|
||||
state: Literal["read", "acknowledged"]
|
||||
|
||||
|
||||
class PostboxMessageAuthoringPayload(BaseModel):
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
subject: str = Field(min_length=1, max_length=1000)
|
||||
body_text: str | None = None
|
||||
classification: PostboxClassification = "internal"
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload):
|
||||
postbox_id: str = Field(min_length=1, max_length=36)
|
||||
|
||||
|
||||
class PostboxTargetPayload(BaseModel):
|
||||
postbox_id: str | None = Field(default=None, max_length=36)
|
||||
address_key: str | None = Field(default=None, max_length=500)
|
||||
@@ -336,6 +354,10 @@ class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
|
||||
id: str
|
||||
revision: int
|
||||
@@ -354,6 +376,8 @@ class PostboxTemplateItem(BaseModel):
|
||||
description: str | None = None
|
||||
status: str
|
||||
current_revision: int
|
||||
resource_revision: int = Field(ge=1)
|
||||
etag: str
|
||||
published_revision_id: str | None = None
|
||||
revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
@@ -366,6 +390,11 @@ class PostboxTemplateListResponse(BaseModel):
|
||||
|
||||
class PostboxTemplatePublishRequest(BaseModel):
|
||||
revision: int | None = Field(default=None, ge=1)
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxMutationRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxMaterializeRequest(BaseModel):
|
||||
@@ -425,8 +454,14 @@ class PostboxGroupingPayload(BaseModel):
|
||||
postbox_ids: list[str] = Field(default_factory=list, max_length=250)
|
||||
|
||||
|
||||
class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxGroupingItem(PostboxGroupingPayload):
|
||||
id: str
|
||||
resource_revision: int = Field(ge=1)
|
||||
etag: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -19,6 +20,7 @@ from govoplan_core.core.events import (
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.concurrency import MissingPreconditionError, claim_revision
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
IdentityDirectory,
|
||||
@@ -57,6 +59,7 @@ from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryTemplateRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxMessageAvailability,
|
||||
PostboxMessageAuthoringRequest,
|
||||
PostboxMessageRef,
|
||||
PostboxMessageListState,
|
||||
PostboxOrganizationFunctionTargetRef,
|
||||
@@ -124,6 +127,49 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
def _authoring_digest(
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
*,
|
||||
in_reply_to: PostboxMessage | None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"subject": request.subject.strip() or "(No subject)",
|
||||
"body_text": request.body_text,
|
||||
"classification": request.classification.strip().casefold(),
|
||||
"in_reply_to_message_id": in_reply_to.id if in_reply_to else None,
|
||||
"participants": [
|
||||
{
|
||||
"kind": item.kind,
|
||||
"reference_type": item.reference_type,
|
||||
"reference_id": item.reference_id,
|
||||
"label": item.label,
|
||||
"address": item.address,
|
||||
}
|
||||
for item in request.participants
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"reference_type": item.reference_type,
|
||||
"reference_id": item.reference_id,
|
||||
"name": item.name,
|
||||
"media_type": item.media_type,
|
||||
"size_bytes": item.size_bytes,
|
||||
"digest": item.digest,
|
||||
"metadata": dict(item.metadata),
|
||||
}
|
||||
for item in request.attachments
|
||||
],
|
||||
"metadata": dict(request.metadata),
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
@@ -772,6 +818,268 @@ class PostboxService:
|
||||
db.refresh(message)
|
||||
return self._message_ref(message, account_id=actor.account_id)
|
||||
|
||||
def create_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
) -> PostboxMessageRef:
|
||||
db = _session(session)
|
||||
postbox = self._get_postbox(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
)
|
||||
return self._author_message(
|
||||
db,
|
||||
postbox=postbox,
|
||||
actor=actor,
|
||||
request=request,
|
||||
action="send",
|
||||
in_reply_to=None,
|
||||
)
|
||||
|
||||
def reply_to_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
) -> PostboxMessageRef:
|
||||
db = _session(session)
|
||||
parent = self._get_message(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
availability = _message_availability(parent)
|
||||
if availability != "available":
|
||||
raise PostboxError(
|
||||
f"message_{availability}",
|
||||
f"This Postbox message is {availability} and cannot be replied to.",
|
||||
)
|
||||
postbox = self._get_postbox(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=parent.postbox_id,
|
||||
)
|
||||
classification = self._validate_classification(request.classification)
|
||||
if not postbox_classification_allows(
|
||||
classification,
|
||||
parent.classification,
|
||||
):
|
||||
raise PostboxError(
|
||||
"reply_classification_too_low",
|
||||
"A reply cannot be classified below its parent message.",
|
||||
)
|
||||
return self._author_message(
|
||||
db,
|
||||
postbox=postbox,
|
||||
actor=actor,
|
||||
request=request,
|
||||
action="reply",
|
||||
in_reply_to=parent,
|
||||
)
|
||||
|
||||
def _author_message(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
postbox: Postbox,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
action: Literal["send", "reply"],
|
||||
in_reply_to: PostboxMessage | None,
|
||||
) -> PostboxMessageRef:
|
||||
classification = self._validate_classification(request.classification)
|
||||
if not postbox_classification_allows(
|
||||
postbox.classification,
|
||||
classification,
|
||||
):
|
||||
raise PostboxError(
|
||||
"classification_not_allowed",
|
||||
"The message classification exceeds the Postbox classification.",
|
||||
)
|
||||
decision = self._access_decision(
|
||||
postbox,
|
||||
actor=actor,
|
||||
action=action,
|
||||
assignments=self._assignments_for_actor(
|
||||
actor,
|
||||
tenant_id=postbox.tenant_id,
|
||||
),
|
||||
holder_cache={},
|
||||
classification=classification,
|
||||
)
|
||||
if not decision.allowed:
|
||||
self._record_access_event(
|
||||
session,
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
message_id=in_reply_to.id if in_reply_to else None,
|
||||
actor=actor,
|
||||
action=f"message.{action}",
|
||||
outcome="denied",
|
||||
reason_code=decision.reason_code,
|
||||
assignment_id=decision.selected_assignment_id,
|
||||
)
|
||||
raise PostboxError("access_denied", decision.explanation)
|
||||
|
||||
authoring_key = request.idempotency_key.strip()
|
||||
if not authoring_key:
|
||||
raise PostboxError(
|
||||
"idempotency_key_required",
|
||||
"Message authoring requires an idempotency key.",
|
||||
)
|
||||
digest = _authoring_digest(request, in_reply_to=in_reply_to)
|
||||
existing = (
|
||||
session.query(PostboxMessage)
|
||||
.options(
|
||||
selectinload(PostboxMessage.participants),
|
||||
selectinload(PostboxMessage.attachments),
|
||||
selectinload(PostboxMessage.receipts),
|
||||
)
|
||||
.filter(
|
||||
PostboxMessage.tenant_id == postbox.tenant_id,
|
||||
PostboxMessage.postbox_id == postbox.id,
|
||||
PostboxMessage.authoring_key == authoring_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
authoring = _mapping(existing.metadata_).get("authoring")
|
||||
existing_digest = (
|
||||
str(authoring.get("digest"))
|
||||
if isinstance(authoring, Mapping) and authoring.get("digest")
|
||||
else None
|
||||
)
|
||||
if existing_digest != digest:
|
||||
raise PostboxError(
|
||||
"idempotency_conflict",
|
||||
"The message idempotency key was already used for different content.",
|
||||
)
|
||||
return self._message_ref(existing, account_id=actor.account_id)
|
||||
|
||||
identity = (
|
||||
self._identities.get_identity(actor.identity_id)
|
||||
if actor.identity_id
|
||||
else self._identities.identity_for_account(actor.account_id)
|
||||
)
|
||||
sender_label = (
|
||||
identity.display_name
|
||||
if identity is not None and identity.display_name
|
||||
else actor.account_id
|
||||
)
|
||||
metadata = _mapping(request.metadata)
|
||||
metadata["authoring"] = {
|
||||
"digest": digest,
|
||||
"account_id": actor.account_id,
|
||||
"identity_id": actor.identity_id,
|
||||
"assignment_id": decision.selected_assignment_id,
|
||||
"action": action,
|
||||
}
|
||||
message = PostboxMessage(
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
subject=request.subject.strip() or "(No subject)",
|
||||
body_text=request.body_text,
|
||||
status="sent",
|
||||
classification=classification,
|
||||
sender_label=sender_label,
|
||||
producer_module="postbox",
|
||||
producer_resource_type="account_authored_message",
|
||||
producer_resource_id=actor.account_id,
|
||||
authoring_key=authoring_key,
|
||||
in_reply_to_message_id=(in_reply_to.id if in_reply_to else None),
|
||||
encryption_profile=postbox.encryption_profile,
|
||||
key_epoch=postbox.key_epoch,
|
||||
delivered_at=utc_now(),
|
||||
metadata_=metadata,
|
||||
)
|
||||
message.participants.append(
|
||||
PostboxParticipant(
|
||||
tenant_id=postbox.tenant_id,
|
||||
kind="author",
|
||||
reference_type="account",
|
||||
reference_id=actor.account_id,
|
||||
label=sender_label,
|
||||
position=0,
|
||||
metadata_={"assignment_id": decision.selected_assignment_id},
|
||||
)
|
||||
)
|
||||
for position, participant in enumerate(request.participants, start=1):
|
||||
message.participants.append(
|
||||
PostboxParticipant(
|
||||
tenant_id=postbox.tenant_id,
|
||||
kind=participant.kind,
|
||||
reference_type=participant.reference_type,
|
||||
reference_id=participant.reference_id,
|
||||
label=participant.label,
|
||||
address=participant.address,
|
||||
position=position,
|
||||
metadata_={},
|
||||
)
|
||||
)
|
||||
for position, attachment in enumerate(request.attachments):
|
||||
message.attachments.append(
|
||||
PostboxAttachmentReference(
|
||||
tenant_id=postbox.tenant_id,
|
||||
reference_type=attachment.reference_type,
|
||||
reference_id=attachment.reference_id,
|
||||
name=attachment.name,
|
||||
media_type=attachment.media_type,
|
||||
size_bytes=attachment.size_bytes,
|
||||
digest=attachment.digest,
|
||||
position=position,
|
||||
metadata_=_mapping(attachment.metadata),
|
||||
)
|
||||
)
|
||||
session.add(message)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise PostboxError(
|
||||
"idempotency_conflict",
|
||||
"The message idempotency key was accepted concurrently.",
|
||||
) from exc
|
||||
self._record_access_event(
|
||||
session,
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
message_id=message.id,
|
||||
actor=actor,
|
||||
action=f"message.{action}",
|
||||
outcome="allowed",
|
||||
reason_code=decision.reason_code,
|
||||
assignment_id=decision.selected_assignment_id,
|
||||
details={"in_reply_to_message_id": message.in_reply_to_message_id},
|
||||
)
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
(
|
||||
"postbox.message.replied.v1"
|
||||
if in_reply_to is not None
|
||||
else "postbox.message.authored.v1"
|
||||
),
|
||||
tenant_id=postbox.tenant_id,
|
||||
resource_type="postbox_message",
|
||||
resource_id=message.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="account",
|
||||
actor_id=actor.account_id,
|
||||
payload={
|
||||
"in_reply_to_message_id": message.in_reply_to_message_id,
|
||||
"classification": classification,
|
||||
"assignment_id": decision.selected_assignment_id,
|
||||
},
|
||||
)
|
||||
return self._message_ref(message, account_id=actor.account_id)
|
||||
|
||||
# Capability: delivery
|
||||
def deliver(
|
||||
self,
|
||||
@@ -2258,12 +2566,21 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
) -> Postbox:
|
||||
postbox = self._get_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=Postbox,
|
||||
resource=postbox,
|
||||
resource_type="postbox",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
postbox.status = "archived"
|
||||
postbox.archived_at = utc_now()
|
||||
postbox.address_record.status = "archived"
|
||||
@@ -2405,6 +2722,7 @@ class PostboxService:
|
||||
classification: str,
|
||||
allow_vacant_delivery: bool,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
routing_policy: Mapping[str, object] | None = None,
|
||||
) -> PostboxTemplate:
|
||||
classification = self._validate_classification(classification)
|
||||
@@ -2418,6 +2736,14 @@ class PostboxService:
|
||||
"template_retired",
|
||||
"A retired Postbox template cannot be revised.",
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxTemplate,
|
||||
resource=template,
|
||||
resource_type="postbox_template",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
self._validate_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_kind=scope_kind,
|
||||
@@ -2470,12 +2796,21 @@ class PostboxService:
|
||||
template_id: str,
|
||||
revision_number: int | None,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
) -> PostboxTemplate:
|
||||
template = self._get_template(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxTemplate,
|
||||
resource=template,
|
||||
resource_type="postbox_template",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
revision = next(
|
||||
(
|
||||
item
|
||||
@@ -2519,12 +2854,21 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
template_id: str,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
) -> PostboxTemplate:
|
||||
template = self._get_template(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxTemplate,
|
||||
resource=template,
|
||||
resource_type="postbox_template",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
template.status = "retired"
|
||||
template.retired_at = utc_now()
|
||||
template.updated_by = actor_id
|
||||
@@ -2773,6 +3117,7 @@ class PostboxService:
|
||||
name: str,
|
||||
is_default: bool,
|
||||
postbox_ids: Sequence[str],
|
||||
expected_revision: int | None = None,
|
||||
) -> PostboxGrouping:
|
||||
grouping = (
|
||||
session.query(PostboxGrouping)
|
||||
@@ -2788,6 +3133,16 @@ class PostboxService:
|
||||
)
|
||||
if grouping_id and grouping is None:
|
||||
raise PostboxError("grouping_not_found", "Postbox grouping not found.")
|
||||
if grouping is not None:
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxGrouping,
|
||||
resource=grouping,
|
||||
resource_type="postbox_grouping",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
account_id=actor.account_id,
|
||||
)
|
||||
existing_ids = tuple(
|
||||
source.postbox_id for source in grouping.sources
|
||||
) if grouping is not None else ()
|
||||
@@ -2822,6 +3177,7 @@ class PostboxService:
|
||||
settings={},
|
||||
)
|
||||
session.add(grouping)
|
||||
session.flush()
|
||||
else:
|
||||
grouping.name = name.strip()
|
||||
grouping.is_default = is_default
|
||||
@@ -2833,8 +3189,16 @@ class PostboxService:
|
||||
PostboxGrouping.tenant_id == tenant_id,
|
||||
PostboxGrouping.account_id == actor.account_id,
|
||||
PostboxGrouping.id != grouping.id,
|
||||
PostboxGrouping.is_default.is_(True),
|
||||
)
|
||||
.update(
|
||||
{
|
||||
PostboxGrouping.is_default: False,
|
||||
PostboxGrouping.resource_revision: (
|
||||
PostboxGrouping.resource_revision + 1
|
||||
),
|
||||
}
|
||||
)
|
||||
.update({PostboxGrouping.is_default: False})
|
||||
)
|
||||
for position, postbox_id in enumerate(saved_ids):
|
||||
grouping.sources.append(
|
||||
@@ -2854,6 +3218,7 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
actor: PostboxActorRef,
|
||||
grouping_id: str,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
grouping = (
|
||||
session.query(PostboxGrouping)
|
||||
@@ -2866,10 +3231,53 @@ class PostboxService:
|
||||
)
|
||||
if grouping is None:
|
||||
raise PostboxError("grouping_not_found", "Postbox grouping not found.")
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxGrouping,
|
||||
resource=grouping,
|
||||
resource_type="postbox_grouping",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
account_id=actor.account_id,
|
||||
)
|
||||
session.delete(grouping)
|
||||
session.flush()
|
||||
|
||||
# Internal helpers
|
||||
def _claim_resource_revision(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
model: type[Any],
|
||||
resource: Any,
|
||||
resource_type: str,
|
||||
tenant_id: str,
|
||||
expected_revision: int | None,
|
||||
account_id: str | None = None,
|
||||
) -> int:
|
||||
if expected_revision is None:
|
||||
raise MissingPreconditionError(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource.id,
|
||||
)
|
||||
filters = [
|
||||
model.id == resource.id,
|
||||
model.tenant_id == tenant_id,
|
||||
]
|
||||
if account_id is not None:
|
||||
filters.append(model.account_id == account_id)
|
||||
next_revision = claim_revision(
|
||||
session,
|
||||
model=model,
|
||||
filters=filters,
|
||||
revision_attribute="resource_revision",
|
||||
expected_revision=expected_revision,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource.id,
|
||||
)
|
||||
resource.resource_revision = next_revision
|
||||
return next_revision
|
||||
|
||||
def _get_postbox(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -3282,6 +3690,8 @@ class PostboxService:
|
||||
holder_count=holder_count,
|
||||
vacant=holder_count == 0,
|
||||
access=decision,
|
||||
resource_revision=postbox.resource_revision,
|
||||
etag=postbox.strong_etag,
|
||||
)
|
||||
|
||||
def _message_ref(
|
||||
@@ -3318,6 +3728,8 @@ class PostboxService:
|
||||
producer_module=message.producer_module,
|
||||
producer_resource_type=message.producer_resource_type,
|
||||
producer_resource_id=message.producer_resource_id,
|
||||
in_reply_to_message_id=message.in_reply_to_message_id,
|
||||
replaces_message_id=message.replaces_message_id,
|
||||
encryption_profile=message.encryption_profile,
|
||||
key_epoch=message.key_epoch,
|
||||
ciphertext_ref=message.ciphertext_ref,
|
||||
|
||||
@@ -18,17 +18,24 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
"govoplan_postbox.backend.migrations.versions."
|
||||
"e4b7c9d2a6f1_v011_hierarchy_routes"
|
||||
)
|
||||
occ_migration = importlib.import_module(
|
||||
"govoplan_postbox.backend.migrations.versions."
|
||||
"f5c8d0e3b7a2_v012_authoring_and_occ"
|
||||
)
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
original = migration.op
|
||||
route_original = route_migration.op
|
||||
occ_original = occ_migration.op
|
||||
migration.op = operations
|
||||
route_migration.op = operations
|
||||
occ_migration.op = operations
|
||||
try:
|
||||
migration.upgrade()
|
||||
route_migration.upgrade()
|
||||
occ_migration.upgrade()
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertIn("postboxes", tables)
|
||||
self.assertIn("postbox_messages", tables)
|
||||
@@ -50,6 +57,21 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
"withdrawn_at",
|
||||
}.issubset(message_columns)
|
||||
)
|
||||
self.assertIn("authoring_key", message_columns)
|
||||
for table_name in (
|
||||
"postbox_templates",
|
||||
"postboxes",
|
||||
"postbox_groupings",
|
||||
):
|
||||
self.assertIn(
|
||||
"resource_revision",
|
||||
{
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns(
|
||||
table_name
|
||||
)
|
||||
},
|
||||
)
|
||||
route_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns(
|
||||
@@ -61,6 +83,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
route_columns
|
||||
)
|
||||
)
|
||||
occ_migration.downgrade()
|
||||
route_migration.downgrade()
|
||||
migration.downgrade()
|
||||
self.assertFalse(
|
||||
@@ -73,6 +96,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
finally:
|
||||
migration.op = original
|
||||
route_migration.op = route_original
|
||||
occ_migration.op = occ_original
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ class PostboxRouterTests(unittest.TestCase):
|
||||
{
|
||||
"postbox:postbox:read",
|
||||
"postbox:message:write",
|
||||
"postbox:message:reply",
|
||||
"postbox:message:acknowledge",
|
||||
"postbox:delivery:write",
|
||||
"postbox:binding:admin",
|
||||
@@ -314,6 +315,134 @@ class PostboxRouterTests(unittest.TestCase):
|
||||
response.json()["diagnostics"],
|
||||
)
|
||||
|
||||
def test_message_authoring_and_reply_are_idempotent_and_linked(self) -> None:
|
||||
authored_payload = {
|
||||
"postbox_id": self.postbox_id,
|
||||
"idempotency_key": "compose-1",
|
||||
"subject": "Status request",
|
||||
"body_text": "Please provide a status update.",
|
||||
"participants": [
|
||||
{
|
||||
"kind": "to",
|
||||
"reference_type": "address",
|
||||
"address": "team@example.invalid",
|
||||
}
|
||||
],
|
||||
}
|
||||
authored = self.client.post(
|
||||
"/api/v1/postbox/messages",
|
||||
json=authored_payload,
|
||||
)
|
||||
duplicate = self.client.post(
|
||||
"/api/v1/postbox/messages",
|
||||
json=authored_payload,
|
||||
)
|
||||
conflict = self.client.post(
|
||||
"/api/v1/postbox/messages",
|
||||
json={**authored_payload, "subject": "Different request"},
|
||||
)
|
||||
|
||||
self.assertEqual(201, authored.status_code, authored.text)
|
||||
self.assertEqual(201, duplicate.status_code, duplicate.text)
|
||||
self.assertEqual(authored.json()["id"], duplicate.json()["id"])
|
||||
self.assertEqual(409, conflict.status_code, conflict.text)
|
||||
self.assertEqual("author", authored.json()["participants"][0]["kind"])
|
||||
|
||||
reply_payload = {
|
||||
"idempotency_key": "reply-1",
|
||||
"subject": "Re: Status request",
|
||||
"body_text": "The work is complete.",
|
||||
}
|
||||
reply = self.client.post(
|
||||
f"/api/v1/postbox/messages/{authored.json()['id']}/replies",
|
||||
json=reply_payload,
|
||||
)
|
||||
duplicate_reply = self.client.post(
|
||||
f"/api/v1/postbox/messages/{authored.json()['id']}/replies",
|
||||
json=reply_payload,
|
||||
)
|
||||
|
||||
self.assertEqual(201, reply.status_code, reply.text)
|
||||
self.assertEqual(reply.json()["id"], duplicate_reply.json()["id"])
|
||||
self.assertEqual(
|
||||
authored.json()["id"],
|
||||
reply.json()["in_reply_to_message_id"],
|
||||
)
|
||||
|
||||
def test_mutable_admin_resources_require_strong_preconditions(self) -> None:
|
||||
grouping = self.client.post(
|
||||
"/api/v1/postbox/groupings",
|
||||
json={
|
||||
"name": "Work",
|
||||
"is_default": True,
|
||||
"postbox_ids": [self.postbox_id],
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, grouping.status_code, grouping.text)
|
||||
grouping_data = grouping.json()
|
||||
update_payload = {
|
||||
"name": "Current work",
|
||||
"is_default": True,
|
||||
"postbox_ids": [self.postbox_id],
|
||||
"base_revision": grouping_data["resource_revision"],
|
||||
}
|
||||
updated = self.client.put(
|
||||
f"/api/v1/postbox/groupings/{grouping_data['id']}",
|
||||
json=update_payload,
|
||||
headers={"If-Match": grouping_data["etag"]},
|
||||
)
|
||||
stale = self.client.put(
|
||||
f"/api/v1/postbox/groupings/{grouping_data['id']}",
|
||||
json=update_payload,
|
||||
headers={"If-Match": grouping_data["etag"]},
|
||||
)
|
||||
self.assertEqual(200, updated.status_code, updated.text)
|
||||
self.assertEqual(2, updated.json()["resource_revision"])
|
||||
self.assertEqual(412, stale.status_code, stale.text)
|
||||
|
||||
template = self.client.post(
|
||||
"/api/v1/postbox/admin/templates",
|
||||
json={
|
||||
"slug": "case-intake",
|
||||
"name": "Case intake",
|
||||
"scope_kind": "tenant",
|
||||
"name_pattern": "{unit_name} / {function_name}",
|
||||
"address_pattern": "{template_slug}.{unit_slug}.{function_slug}",
|
||||
"classification": "internal",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, template.status_code, template.text)
|
||||
template_data = template.json()
|
||||
published = self.client.post(
|
||||
f"/api/v1/postbox/admin/templates/{template_data['id']}/publish",
|
||||
json={"base_revision": template_data["resource_revision"]},
|
||||
headers={"If-Match": template_data["etag"]},
|
||||
)
|
||||
stale_retire = self.client.post(
|
||||
f"/api/v1/postbox/admin/templates/{template_data['id']}/retire",
|
||||
json={"base_revision": template_data["resource_revision"]},
|
||||
headers={"If-Match": template_data["etag"]},
|
||||
)
|
||||
self.assertEqual(200, published.status_code, published.text)
|
||||
self.assertEqual(412, stale_retire.status_code, stale_retire.text)
|
||||
|
||||
directory = self.client.get("/api/v1/postbox/admin/postboxes").json()
|
||||
postbox = directory["postboxes"][0]
|
||||
missing = self.client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/postbox/admin/postboxes/{self.postbox_id}",
|
||||
json={"base_revision": postbox["resource_revision"]},
|
||||
)
|
||||
archived = self.client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/postbox/admin/postboxes/{self.postbox_id}",
|
||||
json={"base_revision": postbox["resource_revision"]},
|
||||
headers={"If-Match": postbox["etag"]},
|
||||
)
|
||||
self.assertEqual(428, missing.status_code, missing.text)
|
||||
self.assertEqual(200, archived.status_code, archived.text)
|
||||
self.assertEqual(2, archived.json()["resource_revision"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -616,6 +616,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
template_id=target_template.id,
|
||||
revision_number=None,
|
||||
actor_id="admin-1",
|
||||
expected_revision=target_template.resource_revision,
|
||||
)
|
||||
source_template = service.create_template(
|
||||
session,
|
||||
@@ -644,6 +645,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
template_id=source_template.id,
|
||||
revision_number=None,
|
||||
actor_id="admin-1",
|
||||
expected_revision=source_template.resource_revision,
|
||||
)
|
||||
source = service.materialize_template(
|
||||
session,
|
||||
@@ -846,6 +848,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
template_id=template.id,
|
||||
revision_number=1,
|
||||
actor_id="admin-1",
|
||||
expected_revision=template.resource_revision,
|
||||
)
|
||||
first = self.service.materialize_template(
|
||||
session,
|
||||
@@ -877,6 +880,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
classification="restricted",
|
||||
allow_vacant_delivery=True,
|
||||
actor_id="admin-1",
|
||||
expected_revision=template.resource_revision,
|
||||
)
|
||||
|
||||
self.assertEqual(first.id, second.id)
|
||||
@@ -916,6 +920,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
template_id=template.id,
|
||||
revision_number=None,
|
||||
actor_id="admin-1",
|
||||
expected_revision=template.resource_revision,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -1474,6 +1479,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
tenant_id="tenant-1",
|
||||
actor=self.actor,
|
||||
grouping_id=grouping_id,
|
||||
expected_revision=grouping.resource_revision,
|
||||
name="Assigned work",
|
||||
is_default=True,
|
||||
postbox_ids=(parent.id,),
|
||||
|
||||
+78
-20
@@ -40,6 +40,8 @@ export type PostboxDirectoryItem = {
|
||||
holder_count: number;
|
||||
vacant: boolean;
|
||||
access?: PostboxAccessDecision | null;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
};
|
||||
|
||||
export type PostboxParticipant = {
|
||||
@@ -78,6 +80,8 @@ export type PostboxMessage = {
|
||||
producer_module?: string | null;
|
||||
producer_resource_type?: string | null;
|
||||
producer_resource_id?: string | null;
|
||||
in_reply_to_message_id?: string | null;
|
||||
replaces_message_id?: string | null;
|
||||
encryption_profile: string;
|
||||
key_epoch: number;
|
||||
ciphertext_ref?: string | null;
|
||||
@@ -92,6 +96,8 @@ export type PostboxGrouping = {
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
postbox_ids: string[];
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -188,6 +194,8 @@ export type PostboxTemplate = {
|
||||
description?: string | null;
|
||||
status: string;
|
||||
current_revision: number;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
published_revision_id?: string | null;
|
||||
revisions: PostboxTemplateRevision[];
|
||||
created_at: string;
|
||||
@@ -221,6 +229,21 @@ export type PostboxExactCreatePayload = {
|
||||
classification: string;
|
||||
};
|
||||
|
||||
export type PostboxMessageAuthoringPayload = {
|
||||
idempotency_key: string;
|
||||
subject: string;
|
||||
body_text?: string | null;
|
||||
classification: string;
|
||||
participants: PostboxParticipant[];
|
||||
attachments: PostboxAttachment[];
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type PostboxGroupingPayload = Pick<
|
||||
PostboxGrouping,
|
||||
"name" | "is_default" | "postbox_ids"
|
||||
>;
|
||||
|
||||
export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
|
||||
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
|
||||
settings,
|
||||
@@ -271,6 +294,29 @@ export function markPostboxMessage(
|
||||
);
|
||||
}
|
||||
|
||||
export function createPostboxMessage(
|
||||
settings: ApiSettings,
|
||||
postboxId: string,
|
||||
payload: PostboxMessageAuthoringPayload
|
||||
): Promise<PostboxMessage> {
|
||||
return apiPostJson(settings, "/api/v1/postbox/messages", {
|
||||
...payload,
|
||||
postbox_id: postboxId
|
||||
});
|
||||
}
|
||||
|
||||
export function replyToPostboxMessage(
|
||||
settings: ApiSettings,
|
||||
messageId: string,
|
||||
payload: PostboxMessageAuthoringPayload
|
||||
): Promise<PostboxMessage> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/replies`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> {
|
||||
const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
|
||||
settings,
|
||||
@@ -281,34 +327,39 @@ export async function listPostboxGroupings(settings: ApiSettings): Promise<Postb
|
||||
|
||||
export function createPostboxGrouping(
|
||||
settings: ApiSettings,
|
||||
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
|
||||
payload: PostboxGroupingPayload
|
||||
): 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">
|
||||
grouping: PostboxGrouping,
|
||||
payload: PostboxGroupingPayload
|
||||
): Promise<PostboxGrouping> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
|
||||
`/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
headers: { "If-Match": grouping.etag },
|
||||
body: JSON.stringify({ ...payload, base_revision: grouping.resource_revision })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePostboxGrouping(
|
||||
settings: ApiSettings,
|
||||
groupingId: string
|
||||
grouping: PostboxGrouping
|
||||
): Promise<void> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
|
||||
{ method: "DELETE" }
|
||||
`/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "If-Match": grouping.etag },
|
||||
body: JSON.stringify({ base_revision: grouping.resource_revision })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -338,12 +389,16 @@ export function createExactPostbox(
|
||||
|
||||
export function archivePostbox(
|
||||
settings: ApiSettings,
|
||||
postboxId: string
|
||||
postbox: PostboxDirectoryItem
|
||||
): Promise<PostboxDirectoryItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`,
|
||||
{ method: "DELETE" }
|
||||
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "If-Match": postbox.etag },
|
||||
body: JSON.stringify({ base_revision: postbox.resource_revision })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,36 +419,39 @@ export function createPostboxTemplate(
|
||||
|
||||
export function revisePostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
templateId: string,
|
||||
template: PostboxTemplate,
|
||||
payload: PostboxTemplateRevisionPayload
|
||||
): Promise<PostboxTemplate> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`,
|
||||
payload
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/revisions`,
|
||||
{ ...payload, base_revision: template.resource_revision },
|
||||
{ headers: { "If-Match": template.etag } }
|
||||
);
|
||||
}
|
||||
|
||||
export function publishPostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
templateId: string,
|
||||
template: PostboxTemplate,
|
||||
revision?: number
|
||||
): Promise<PostboxTemplate> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`,
|
||||
{ revision: revision ?? null }
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/publish`,
|
||||
{ revision: revision ?? null, base_revision: template.resource_revision },
|
||||
{ headers: { "If-Match": template.etag } }
|
||||
);
|
||||
}
|
||||
|
||||
export function retirePostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
templateId: string
|
||||
template: PostboxTemplate
|
||||
): Promise<PostboxTemplate> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/retire`,
|
||||
{}
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/retire`,
|
||||
{ base_revision: template.resource_revision },
|
||||
{ headers: { "If-Match": template.etag } }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -236,9 +236,13 @@ export default function PostboxAdminPanel({
|
||||
setSuccess("");
|
||||
try {
|
||||
if (templateDraft.templateId) {
|
||||
const currentTemplate = templates.find(
|
||||
(item) => item.id === templateDraft.templateId
|
||||
);
|
||||
if (!currentTemplate) throw new Error("The template is no longer available.");
|
||||
await revisePostboxTemplate(
|
||||
settings,
|
||||
templateDraft.templateId,
|
||||
currentTemplate,
|
||||
revisionPayload(templateDraft)
|
||||
);
|
||||
setSuccess("A new immutable template revision was created.");
|
||||
@@ -268,7 +272,7 @@ export default function PostboxAdminPanel({
|
||||
try {
|
||||
await publishPostboxTemplate(
|
||||
settings,
|
||||
selectedTemplate.id,
|
||||
selectedTemplate,
|
||||
selectedTemplate.current_revision
|
||||
);
|
||||
setSuccess(`Published revision ${selectedTemplate.current_revision}.`);
|
||||
@@ -286,7 +290,7 @@ export default function PostboxAdminPanel({
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
await retirePostboxTemplate(settings, selectedTemplate.id);
|
||||
await retirePostboxTemplate(settings, selectedTemplate);
|
||||
setSuccess("Postbox template retired. Existing addresses remain durable.");
|
||||
await load();
|
||||
} catch (actionError) {
|
||||
@@ -365,7 +369,7 @@ export default function PostboxAdminPanel({
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
await archivePostbox(settings, archiveTarget.id);
|
||||
await archivePostbox(settings, archiveTarget);
|
||||
setSuccess("Postbox archived. Messages and delivery evidence were retained.");
|
||||
setArchiveTarget(null);
|
||||
await load();
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
Search,
|
||||
Send,
|
||||
Trash2,
|
||||
UserRoundCheck,
|
||||
X
|
||||
@@ -34,12 +36,14 @@ import {
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createPostboxGrouping,
|
||||
createPostboxMessage,
|
||||
deletePostboxGrouping,
|
||||
getPostboxMessage,
|
||||
listPostboxGroupings,
|
||||
listPostboxMessages,
|
||||
listPostboxes,
|
||||
markPostboxMessage,
|
||||
replyToPostboxMessage,
|
||||
updatePostboxGrouping,
|
||||
type PostboxDirectoryItem,
|
||||
type PostboxGrouping,
|
||||
@@ -56,6 +60,14 @@ type GroupingDraft = {
|
||||
|
||||
type MessageStateFilter = "all" | "unread" | "read" | "acknowledged";
|
||||
|
||||
type MessageDraft = {
|
||||
postbox_id: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
classification: string;
|
||||
recipients: string;
|
||||
};
|
||||
|
||||
const emptyGrouping = (): GroupingDraft => ({
|
||||
id: "",
|
||||
name: "",
|
||||
@@ -63,6 +75,14 @@ const emptyGrouping = (): GroupingDraft => ({
|
||||
postbox_ids: []
|
||||
});
|
||||
|
||||
const emptyMessageDraft = (): MessageDraft => ({
|
||||
postbox_id: "",
|
||||
subject: "",
|
||||
body_text: "",
|
||||
classification: "internal",
|
||||
recipients: ""
|
||||
});
|
||||
|
||||
export default function PostboxPage({
|
||||
settings,
|
||||
auth
|
||||
@@ -94,8 +114,13 @@ export default function PostboxPage({
|
||||
const [error, setError] = useState("");
|
||||
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
|
||||
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
|
||||
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
|
||||
const [replyParent, setReplyParent] = useState<PostboxMessage | null>(null);
|
||||
const [messageDraft, setMessageDraft] = useState<MessageDraft>(emptyMessageDraft);
|
||||
|
||||
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
|
||||
const canSend = hasScope(auth, "postbox:message:write");
|
||||
const canReply = hasScope(auth, "postbox:message:reply");
|
||||
const selectedPostbox = useMemo(
|
||||
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
|
||||
[postboxes, selectedPostboxId]
|
||||
@@ -335,8 +360,9 @@ export default function PostboxPage({
|
||||
postbox_ids: groupingDraft.postbox_ids
|
||||
};
|
||||
try {
|
||||
const saved = groupingDraft.id
|
||||
? await updatePostboxGrouping(settings, groupingDraft.id, payload)
|
||||
const existing = groupings.find((item) => item.id === groupingDraft.id);
|
||||
const saved = existing
|
||||
? await updatePostboxGrouping(settings, existing, payload)
|
||||
: await createPostboxGrouping(settings, payload);
|
||||
await loadDirectory();
|
||||
setSelectedScope(saved.id);
|
||||
@@ -354,7 +380,9 @@ export default function PostboxPage({
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deletePostboxGrouping(settings, groupingDraft.id);
|
||||
const existing = groupings.find((item) => item.id === groupingDraft.id);
|
||||
if (!existing) throw new Error("The grouping is no longer available.");
|
||||
await deletePostboxGrouping(settings, existing);
|
||||
setSelectedScope("all");
|
||||
setSelectedPostboxId("");
|
||||
setGroupingDialogOpen(false);
|
||||
@@ -366,6 +394,86 @@ export default function PostboxPage({
|
||||
}
|
||||
}
|
||||
|
||||
function openCompose() {
|
||||
const postbox = selectedPostbox ?? postboxes[0] ?? null;
|
||||
if (!postbox) return;
|
||||
setReplyParent(null);
|
||||
setMessageDraft({
|
||||
...emptyMessageDraft(),
|
||||
postbox_id: postbox.id,
|
||||
classification: postbox.classification
|
||||
});
|
||||
setMessageDialogOpen(true);
|
||||
}
|
||||
|
||||
function openReply() {
|
||||
if (!selectedMessage) return;
|
||||
setReplyParent(selectedMessage);
|
||||
setMessageDraft({
|
||||
...emptyMessageDraft(),
|
||||
postbox_id: selectedMessage.postbox_id,
|
||||
subject: selectedMessage.subject.toLowerCase().startsWith("re:")
|
||||
? selectedMessage.subject
|
||||
: `Re: ${selectedMessage.subject}`,
|
||||
classification: selectedMessage.classification
|
||||
});
|
||||
setMessageDialogOpen(true);
|
||||
}
|
||||
|
||||
async function submitMessage() {
|
||||
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const participants = messageDraft.recipients
|
||||
.split(/[;,\n]/)
|
||||
.map((address) => address.trim())
|
||||
.filter(Boolean)
|
||||
.map((address) => ({
|
||||
kind: "to",
|
||||
reference_type: "address",
|
||||
address
|
||||
}));
|
||||
const payload = {
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
subject: messageDraft.subject.trim(),
|
||||
body_text: messageDraft.body_text || null,
|
||||
classification: messageDraft.classification,
|
||||
participants,
|
||||
attachments: [],
|
||||
metadata: {}
|
||||
};
|
||||
try {
|
||||
const message = replyParent
|
||||
? await replyToPostboxMessage(settings, replyParent.id, payload)
|
||||
: await createPostboxMessage(
|
||||
settings,
|
||||
messageDraft.postbox_id,
|
||||
payload
|
||||
);
|
||||
setMessageDialogOpen(false);
|
||||
setReplyParent(null);
|
||||
setSelectedPostboxId(message.postbox_id);
|
||||
setSelectedScope("all");
|
||||
setSelectedMessageId(message.id);
|
||||
setSelectedMessage(message);
|
||||
setPage(1);
|
||||
const refreshed = await listPostboxMessages(
|
||||
settings,
|
||||
[message.postbox_id],
|
||||
pageSize,
|
||||
0,
|
||||
messageQuery,
|
||||
messageState
|
||||
);
|
||||
setMessages(refreshed.messages);
|
||||
setTotal(refreshed.total);
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workspace-data-page module-entry-page postbox-page">
|
||||
<div className="postbox-shell">
|
||||
@@ -472,12 +580,20 @@ export default function PostboxPage({
|
||||
)}
|
||||
<span className="postbox-total">{total}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Refresh messages"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={() => void loadMessages()}
|
||||
disabled={loadingMessages || busy}
|
||||
/>
|
||||
<div className="postbox-icon-actions">
|
||||
<IconButton
|
||||
label="New message"
|
||||
icon={<Send size={16} />}
|
||||
onClick={openCompose}
|
||||
disabled={!canSend || !postboxes.length || busy}
|
||||
/>
|
||||
<IconButton
|
||||
label="Refresh messages"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={() => void loadMessages()}
|
||||
disabled={loadingMessages || busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="postbox-message-filters">
|
||||
<div className="postbox-search-row">
|
||||
@@ -615,25 +731,38 @@ export default function PostboxPage({
|
||||
<MailOpen size={17} aria-hidden="true" />
|
||||
<strong>{selectedMessage?.subject || "Message"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void acknowledgeSelected()}
|
||||
disabled={
|
||||
!selectedMessage ||
|
||||
selectedMessage.availability !== "available" ||
|
||||
Boolean(selectedMessage.acknowledged_at) ||
|
||||
busy
|
||||
}
|
||||
disabledReason={
|
||||
!canAcknowledge
|
||||
? "You cannot acknowledge Postbox messages."
|
||||
: selectedMessage &&
|
||||
selectedMessage.availability !== "available"
|
||||
? "Withdrawn or expired messages cannot be acknowledged."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CheckCheck size={16} /> Acknowledge
|
||||
</Button>
|
||||
<div className="button-row compact-actions">
|
||||
<Button
|
||||
onClick={openReply}
|
||||
disabled={
|
||||
!canReply ||
|
||||
!selectedMessage ||
|
||||
selectedMessage.availability !== "available" ||
|
||||
busy
|
||||
}
|
||||
>
|
||||
<Reply size={16} /> Reply
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void acknowledgeSelected()}
|
||||
disabled={
|
||||
!selectedMessage ||
|
||||
selectedMessage.availability !== "available" ||
|
||||
Boolean(selectedMessage.acknowledged_at) ||
|
||||
busy
|
||||
}
|
||||
disabledReason={
|
||||
!canAcknowledge
|
||||
? "You cannot acknowledge Postbox messages."
|
||||
: selectedMessage &&
|
||||
selectedMessage.availability !== "available"
|
||||
? "Withdrawn or expired messages cannot be acknowledged."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CheckCheck size={16} /> Acknowledge
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{selectedMessage ? (
|
||||
<MessageDetail
|
||||
@@ -737,6 +866,119 @@ export default function PostboxPage({
|
||||
))}
|
||||
</fieldset>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={messageDialogOpen}
|
||||
title={replyParent ? "Reply" : "New message"}
|
||||
className="postbox-dialog postbox-message-dialog"
|
||||
onClose={() => setMessageDialogOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={
|
||||
<div className="postbox-dialog-actions end">
|
||||
<Button onClick={() => setMessageDialogOpen(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void submitMessage()}
|
||||
disabled={
|
||||
busy ||
|
||||
!messageDraft.postbox_id ||
|
||||
!messageDraft.subject.trim()
|
||||
}
|
||||
>
|
||||
<Send size={16} /> Send
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="postbox-compose-grid">
|
||||
<FormField label="Postbox">
|
||||
<select
|
||||
value={messageDraft.postbox_id}
|
||||
disabled={Boolean(replyParent)}
|
||||
onChange={(event) => {
|
||||
const postbox = postboxes.find(
|
||||
(item) => item.id === event.target.value
|
||||
);
|
||||
setMessageDraft((current) => ({
|
||||
...current,
|
||||
postbox_id: event.target.value,
|
||||
classification: postbox?.classification ?? "internal"
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{postboxes.map((postbox) => (
|
||||
<option key={postbox.id} value={postbox.id}>
|
||||
{postbox.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Classification">
|
||||
<select
|
||||
value={messageDraft.classification}
|
||||
onChange={(event) =>
|
||||
setMessageDraft((current) => ({
|
||||
...current,
|
||||
classification: event.target.value
|
||||
}))
|
||||
}
|
||||
>
|
||||
{classificationOptions(
|
||||
postboxes.find((item) => item.id === messageDraft.postbox_id)
|
||||
?.classification ?? "internal",
|
||||
replyParent?.classification
|
||||
).map((classification) => (
|
||||
<option key={classification} value={classification}>
|
||||
{classificationLabel(classification)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<div className="postbox-compose-wide">
|
||||
<FormField label="Recipients">
|
||||
<input
|
||||
value={messageDraft.recipients}
|
||||
placeholder="Separate addresses with commas"
|
||||
onChange={(event) =>
|
||||
setMessageDraft((current) => ({
|
||||
...current,
|
||||
recipients: event.target.value
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="postbox-compose-wide">
|
||||
<FormField label="Subject">
|
||||
<input
|
||||
value={messageDraft.subject}
|
||||
onChange={(event) =>
|
||||
setMessageDraft((current) => ({
|
||||
...current,
|
||||
subject: event.target.value
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="postbox-compose-wide">
|
||||
<FormField label="Message">
|
||||
<textarea
|
||||
rows={10}
|
||||
value={messageDraft.body_text}
|
||||
onChange={(event) =>
|
||||
setMessageDraft((current) => ({
|
||||
...current,
|
||||
body_text: event.target.value
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -842,6 +1084,38 @@ function producerLabel(message: PostboxMessage): string {
|
||||
return resource.length ? resource.join(" / ") : "Platform-native message";
|
||||
}
|
||||
|
||||
const CLASSIFICATIONS = [
|
||||
"public",
|
||||
"internal",
|
||||
"confidential",
|
||||
"restricted"
|
||||
] as const;
|
||||
|
||||
function classificationOptions(
|
||||
postboxClassification: string,
|
||||
minimumClassification?: string
|
||||
): string[] {
|
||||
const maximum = Math.max(
|
||||
0,
|
||||
CLASSIFICATIONS.indexOf(
|
||||
postboxClassification as (typeof CLASSIFICATIONS)[number]
|
||||
)
|
||||
);
|
||||
const minimum = minimumClassification
|
||||
? Math.max(
|
||||
0,
|
||||
CLASSIFICATIONS.indexOf(
|
||||
minimumClassification as (typeof CLASSIFICATIONS)[number]
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
return CLASSIFICATIONS.slice(minimum, maximum + 1);
|
||||
}
|
||||
|
||||
function classificationLabel(value: string): string {
|
||||
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
@@ -518,6 +518,31 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.postbox-message-dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.postbox-compose-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.postbox-compose-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.postbox-compose-grid textarea {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.postbox-dialog-actions.end {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.postbox-admin-page > .segmented-control {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
@@ -667,4 +692,8 @@
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.postbox-compose-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user