Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d848a9a503 | |||
| 19216e5043 | |||
| 5d141a0eaa | |||
| a3c809c391 | |||
| b39fbde7f8 |
13
AGENTS.md
13
AGENTS.md
@@ -2,9 +2,9 @@
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the `postbox` module: in-platform postboxes, role-organization-bound access, postbox messages, postbox directory APIs, internal and portal postbox surfaces, campaign postbox integration, postbox-owned migrations, and future `@govoplan/postbox-webui`.
|
||||
This repository owns the `postbox` module: in-platform postboxes, function-organization-bound access, postbox messages, postbox directory APIs, internal and portal postbox surfaces, campaign postbox integration, postbox-owned migrations, and future `@govoplan/postbox-webui`.
|
||||
|
||||
Postboxes are not login-bound mailboxes. They are platform-owned communication and access containers whose visibility is derived from organizational role assignments, explicit postbox bindings, and capability contracts.
|
||||
Postboxes are not login-bound mailboxes. They are platform-owned communication and access containers whose visibility is derived primarily from effective identity-to-organization-function assignments, explicit postbox bindings, and capability contracts.
|
||||
|
||||
## Local Commands
|
||||
|
||||
@@ -18,14 +18,15 @@ cd /mnt/DATA/git/govoplan-core
|
||||
For combined checks once implementation starts, run:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/check-focused.sh
|
||||
cd /mnt/DATA/git/govoplan
|
||||
tools/checks/check-focused.sh
|
||||
```
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Keep postbox behavior in this module, not core.
|
||||
- Derive role-organization access through core/access contracts; do not duplicate RBAC membership logic locally.
|
||||
- Resolve units and functions through Organizations and effective identity-to-function assignments through IDM. Use Core/Access for generic Postbox action authorization; do not turn a function assignment into an RBAC role merely to open its function postbox.
|
||||
- Do not duplicate identity, organization, assignment, hierarchy, or RBAC logic locally.
|
||||
- Do not import mail, files, campaign, or portal internals. Use manifests, capabilities, events, API routes, and typed DTOs.
|
||||
- Treat postbox access changes as auditable security events, especially when access changes because a role assignment changes.
|
||||
- Treat postbox access changes as auditable security events, especially when access changes because a function assignment, delegation, acting context, or generic Postbox permission changes.
|
||||
- Keep campaign delivery, file evidence, and portal usage optional behind capability boundaries.
|
||||
|
||||
69
README.md
69
README.md
@@ -1,6 +1,11 @@
|
||||
# govoplan-postbox
|
||||
|
||||
GovOPlaN Postbox provides platform-owned postboxes for internal work, portals, campaign flows, and role-bound organizational communication.
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
GovOPlaN Postbox provides platform-owned postboxes for internal work, portals,
|
||||
campaign flows, and function-bound organizational communication.
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -9,18 +14,47 @@ This repository owns:
|
||||
- backend module manifest `postbox`
|
||||
- postbox permissions and policy checks
|
||||
- postbox, binding, message, participant, attachment-reference, and audit-facing data models
|
||||
- role-organization-bound access resolution for postboxes
|
||||
- function-organization-bound access resolution for postboxes through
|
||||
normalized Identity, IDM, Organizations, and Core/Access contracts
|
||||
- API routes for postbox directory, messages, access checks, and administration
|
||||
- optional integration capabilities for campaign, files, portal, notification, and mail-facing workflows
|
||||
- future WebUI package `@govoplan/postbox-webui`
|
||||
- inbox and tenant administration WebUI package `@govoplan/postbox-webui`
|
||||
|
||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, module discovery, migrations, CSRF/API helpers, and shell layout. Access owns identities, users, groups, roles, memberships, and administrative RBAC surfaces.
|
||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, module
|
||||
discovery, migrations, CSRF/API helpers, and shell layout. Identity owns
|
||||
identities and account links. Organizations owns units, structures, function
|
||||
types, and concrete functions. IDM owns effective identity-to-function
|
||||
assignments, delegation, and acting-for facts. Access owns generic application
|
||||
roles, permissions, and administrative RBAC surfaces.
|
||||
|
||||
## Role-bound postboxes
|
||||
## Function-bound postboxes
|
||||
|
||||
A role-bound postbox is linked to an organizational unit and one or more roles. A person can access that postbox while their identity has an effective matching role in that organizational unit. Access is not tied to a login mailbox, personal email address, or static user assignment.
|
||||
A function-bound postbox has a stable institutional address linked to an
|
||||
organizational unit and one or more functions. It can exist with zero, one, or
|
||||
several current incumbents. A person can access it only while their identity
|
||||
has an effective assignment or time-bounded delegation in that organizational
|
||||
context and their account may perform the relevant Postbox action. Access is
|
||||
not tied to a login mailbox, personal email address, static user assignment, or
|
||||
function-to-RBAC-role mapping.
|
||||
|
||||
When the role assignment changes, postbox access changes with it. The postbox keeps durable message and evidence history, while authorization remains derived from current platform role state.
|
||||
When the assignment changes, postbox access and encrypted key grants change
|
||||
with it. The postbox keeps durable content and evidence history through
|
||||
vacancy, hand-over, and delegation; multiple incumbents receive independently
|
||||
auditable access. Revocation prevents future platform key access but cannot
|
||||
erase plaintext already fetched or exported.
|
||||
|
||||
Reusable Postbox templates may target a function type and organization scope.
|
||||
Unit-specific addresses are resolved lazily and remain stable through vacancy
|
||||
and reassignment. Exact postboxes remain available for exceptional
|
||||
responsibilities or case/service contexts.
|
||||
|
||||
Users holding several functions may group selected postboxes into unified
|
||||
inbox views. These are query projections only: messages, address, read state,
|
||||
retention, and evidence remain attached to their source postboxes.
|
||||
|
||||
Hierarchy propagation is off by default. Explicit copy, attention/escalation,
|
||||
and shared-visibility rules are distinct, bounded, classification-aware, and
|
||||
snapshotted when a message is delivered.
|
||||
|
||||
## Module integration
|
||||
|
||||
@@ -38,3 +72,24 @@ Frontend package:
|
||||
```
|
||||
|
||||
Platform RBAC, module capability contracts, and governance rules are documented in `govoplan-core/docs/`.
|
||||
|
||||
## Current implementation
|
||||
|
||||
The first usable slice includes immutable template revisions, stable lazy
|
||||
addresses, exact function-bound Postboxes, current IDM assignment access
|
||||
decisions, vacancy status, idempotent producer delivery, source-preserving
|
||||
message and attachment references, personal read/acknowledgement receipts,
|
||||
unified inbox projections, access evidence, an inbox route, and tenant
|
||||
administration.
|
||||
|
||||
The persistence model reserves ciphertext manifests, wrapped keys, key epochs,
|
||||
expiry, and withdrawal state. The active profile remains `plaintext_v1`; the
|
||||
module does not claim end-to-end encryption until the history, recovery, and
|
||||
handover policy choices in the security issues are resolved.
|
||||
|
||||
Run focused checks with:
|
||||
|
||||
```bash
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||
cd webui && npm run test:ui-structure
|
||||
```
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
|
||||
GovOPlaN Postbox provides in-platform postboxes that are addressable containers for messages, files, workflow evidence, and operational handoff. They can be used internally, exposed through portals, and connected to campaign workflows.
|
||||
|
||||
The key distinction from a mailbox is ownership. A mailbox is usually bound to a login, user credential, or external mail account. A GovOPlaN postbox is bound to platform context: organization, role, process, portal, campaign, or service responsibility.
|
||||
The key distinction from a mailbox is ownership. A mailbox is usually bound to
|
||||
a login, user credential, or external mail account. A GovOPlaN postbox is a
|
||||
durable communication and content-access container bound to institutional
|
||||
context: primarily a function in an organizational unit, and where explicitly
|
||||
needed a role, process, portal, campaign, or service responsibility. It may look
|
||||
like an inbox for a message task or like a vault for content shared with the
|
||||
current holders of that responsibility; neither form is owned by one account.
|
||||
|
||||
The strategic target is an encrypted administrative postbox. The first
|
||||
implementation may start with ordinary persisted messages, but the model must
|
||||
@@ -13,45 +19,87 @@ manifests, external-recipient tokens, or honest retraction semantics. The
|
||||
cross-module target architecture is recorded in
|
||||
`govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
|
||||
|
||||
## Function/Role-Organization-Bound Access
|
||||
## Function-Organization-Bound Access
|
||||
|
||||
The special access pattern is a postbox linked to an organizational unit and
|
||||
one or more roles or functions. A person can access that postbox while their
|
||||
account has an effective matching function assignment in that organizational
|
||||
unit, or while a matching function maps to one of the required roles.
|
||||
The primary access pattern is a postbox linked to an organizational unit and
|
||||
one or more institutional functions. A person can access that postbox while
|
||||
their identity/account has an effective matching function assignment in that
|
||||
organizational unit and their account may perform the requested Postbox action.
|
||||
Opening a function postbox does not require mapping the function to an RBAC
|
||||
role.
|
||||
|
||||
Example:
|
||||
|
||||
- Organizational unit: `District Office North`
|
||||
- Required role: `Case Clerk`
|
||||
- Required function: `Case Clerk`
|
||||
- Postbox: `District Office North / Case Clerk Intake`
|
||||
|
||||
Any identity/account currently holding the `Case Clerk` function or a mapped
|
||||
role for `District Office North` can see the postbox. When the function,
|
||||
delegation, or role mapping is removed or expires, access disappears without
|
||||
moving messages or reassigning a mailbox.
|
||||
Any identity/account currently holding the `Case Clerk` function for `District
|
||||
Office North` can see the postbox if it also satisfies the generic Postbox
|
||||
permission and applicable policy. When the function assignment or delegation
|
||||
is removed or expires, access disappears without moving messages or
|
||||
reassigning a mailbox.
|
||||
|
||||
The postbox exists independently of its holders. It remains addressable while
|
||||
the function is vacant and may accept durable deliveries according to policy;
|
||||
the UI must then show that no current human holder can open or act on the
|
||||
content. Assigning one or several incumbents grants access in their explicit
|
||||
function context. It does not transfer ownership of the container or rewrite
|
||||
its history.
|
||||
|
||||
This makes postboxes useful for responsibilities that outlive individuals:
|
||||
|
||||
- intake desks
|
||||
- role-based service queues
|
||||
- function-based service queues
|
||||
- campaign sender or response desks
|
||||
- portal message inboxes for organizational responsibilities
|
||||
- file or evidence drops linked to a role in an organization
|
||||
- file or evidence drops linked to a function in an organization
|
||||
|
||||
## Authorization Model
|
||||
### Incumbency, hand-over, and delegation
|
||||
|
||||
Postbox authorization should be derived from access-owned identity and role data through core/access contracts. The postbox module stores postbox bindings and postbox-specific permissions, but it should not duplicate membership, group, or role resolution.
|
||||
- Zero, one, or several people may hold a function at the same time.
|
||||
- A postbox can remain vacant without being deleted, redirected to a personal
|
||||
account, or losing content.
|
||||
- A new assignment can grant access to the function's permitted history through
|
||||
a current key epoch. Which historical epochs a new incumbent receives is an
|
||||
explicit postbox policy, not an accidental consequence of account creation.
|
||||
- A delegation is a time-bounded access grant in the represented function
|
||||
context. Expiry removes future platform key access and action authority; it
|
||||
cannot destroy plaintext or exports already obtained.
|
||||
- Hand-over and revocation rotate the function/postbox key epoch. Per-content
|
||||
data keys should normally be rewrapped; policy may require content
|
||||
re-encryption for a stronger rotation event.
|
||||
- Stored content is not silently substituted or overwritten. A correction,
|
||||
replacement, or new version is a new linked object with provenance while the
|
||||
previous signed/ciphertext manifest remains governed by retention policy.
|
||||
|
||||
## Directory And Authorization Model
|
||||
|
||||
The normalized ownership boundary is:
|
||||
|
||||
- Organizations owns units, structures, function types, and concrete
|
||||
functions.
|
||||
- Identity owns identities and account links.
|
||||
- IDM owns effective identity-to-function assignments, validity, delegation,
|
||||
acting-for context, and assignment lifecycle facts.
|
||||
- Core/Access authorizes generic Postbox actions.
|
||||
- Postbox owns templates, stable addresses, containers, messages, routing,
|
||||
visibility decisions, grouping preferences, and retention.
|
||||
|
||||
The Postbox module stores postbox bindings and postbox-specific decisions, but
|
||||
it must not duplicate identity, organization, assignment, hierarchy, or RBAC
|
||||
resolution.
|
||||
|
||||
The minimum authorization inputs are:
|
||||
|
||||
- postbox id
|
||||
- tenant id
|
||||
- organizational unit id
|
||||
- required function id, role id, or role key
|
||||
- required function id or function type id
|
||||
- actor identity id
|
||||
- current effective function assignments, delegations, and roles from the
|
||||
access module
|
||||
- current effective function assignments, delegations, and acting context from
|
||||
IDM
|
||||
- generic Postbox permissions from Core/Access
|
||||
- optional explicit administrative grants for postbox administration
|
||||
|
||||
The expected result is a narrow access decision:
|
||||
@@ -62,27 +110,100 @@ The expected result is a narrow access decision:
|
||||
- can attach or link files
|
||||
- can administer bindings
|
||||
|
||||
Access changes must be auditable because a person can gain or lose postbox visibility through role assignment changes rather than direct postbox membership edits.
|
||||
Access changes must be auditable because a person can gain or lose postbox
|
||||
visibility through function-assignment changes rather than direct postbox
|
||||
membership edits.
|
||||
|
||||
Runtime integration must use the access kernel capabilities:
|
||||
Runtime integration must use the kernel capabilities:
|
||||
|
||||
- `access.semanticDirectory` to inspect identity/account/function facts.
|
||||
- `access.explanation` to attach identity/account/function/role/right
|
||||
provenance to access decisions.
|
||||
- `identity.directory` to resolve identities and account links.
|
||||
- `idm.directory` to resolve effective function assignments.
|
||||
- `organizations.directory` to resolve function, function-type, unit, and
|
||||
hierarchy facts.
|
||||
- the Core/Access permission evaluator for generic Postbox actions.
|
||||
- access explanation/audit contracts to attach permission and acting-context
|
||||
provenance where available.
|
||||
|
||||
Postbox must not import access ORM models or duplicate function/role
|
||||
resolution. Acting-in-place access should require an explicit selected acting
|
||||
context once Access exposes that runtime selector.
|
||||
Postbox must not import Identity, IDM, Organizations, or Access ORM models.
|
||||
Acting-in-place access requires an explicit selected acting context. A function
|
||||
assignment is an organizational responsibility fact; it does not grant
|
||||
unrelated application permissions.
|
||||
|
||||
## Templates And Stable Addresses
|
||||
|
||||
A reusable Postbox template can target a function type and an organization
|
||||
scope, such as a unit type, structure, or subtree. Postbox resolves a stable
|
||||
unit-specific address from the tenant, template revision, concrete unit,
|
||||
concrete function, and optional case/service context.
|
||||
|
||||
Addresses should be resolved lazily and idempotently rather than eagerly
|
||||
creating empty containers for every unit. They remain durable through vacancy
|
||||
and reassignment. A delivery snapshots the template revision and normalized
|
||||
organization/function references so later hierarchy changes do not rewrite
|
||||
history.
|
||||
|
||||
Exact postboxes remain useful for exceptional responsibilities that do not
|
||||
belong to a reusable function type.
|
||||
|
||||
## Unified Inbox Projections
|
||||
|
||||
A user with several functions can group selected visible postboxes into named
|
||||
unified inbox views and keep other responsibilities separate. Grouping is a
|
||||
query projection only. It never merges source containers, messages, read or
|
||||
acknowledgement state, retention, encryption keys, or audit evidence.
|
||||
|
||||
Every item and action continues to show the source function, unit, postbox,
|
||||
assignment/delegation context, and classification. Policy may require some
|
||||
postboxes to remain separate.
|
||||
|
||||
## Hierarchy Routing
|
||||
|
||||
Hierarchy behavior is disabled by default. The system distinguishes:
|
||||
|
||||
- a linked copy delivered to a parent function postbox
|
||||
- attention or escalation metadata sent to a parent responsibility
|
||||
- shared visibility over the original message
|
||||
|
||||
These have different privacy, retention, acknowledgement, and audit effects
|
||||
and must not be treated as synonyms.
|
||||
|
||||
The first production slice should implement explicit linked-copy routing with
|
||||
a selected structure, target function mapping, maximum depth, stop condition,
|
||||
classification gate, loop protection, and delivery-time route snapshot.
|
||||
Organization changes do not retroactively expose old messages.
|
||||
|
||||
Vacancy is a visible delivery/attention state rather than an automatic grant
|
||||
to an unrelated personal account. Policy may trigger a bounded escalation
|
||||
after a delay.
|
||||
|
||||
## Campaign Distribution
|
||||
|
||||
Campaign can use Postbox as an explicit delivery channel through
|
||||
`postbox.delivery`. A campaign may select Mail, Postbox, both, or a configured
|
||||
fallback order for a target. It must never switch channels silently.
|
||||
|
||||
Validation and build preview stable function/unit/context destinations,
|
||||
vacancies, hierarchy-copy effects, classifications, and duplicates. Delivery
|
||||
uses idempotency keys and returns per-target evidence. Postbox owns acceptance,
|
||||
routing, message state, read/acknowledgement state, and postbox ids; Campaign
|
||||
owns campaign preparation, jobs, recipient reports, and channel-attempt
|
||||
evidence.
|
||||
|
||||
## Domain Objects
|
||||
|
||||
The initial domain model should stay small:
|
||||
|
||||
- `Postbox`: the addressable container.
|
||||
- `PostboxTemplate` and immutable revisions: reusable function/scope
|
||||
configuration.
|
||||
- `PostboxAddress`: the stable tenant/function/unit/context destination.
|
||||
- `Postbox`: the addressable container, materialized when needed.
|
||||
- `PostboxBinding`: the binding to organization, role, portal, campaign, service, or explicit context.
|
||||
- `PostboxMessage`: a platform-native message or message reference.
|
||||
- `PostboxParticipant`: normalized sender, recipient, author, or actor reference.
|
||||
- `PostboxAttachmentRef`: reference to a file, evidence item, generated campaign artifact, or external attachment.
|
||||
- `PostboxDelivery` and `PostboxRoute`: idempotent producer acceptance and
|
||||
linked copy/escalation provenance.
|
||||
- `PostboxGrouping`: a per-user source-preserving inbox projection.
|
||||
- `PostboxAccessEvent`: auditable record of access-affecting changes and sensitive actions.
|
||||
|
||||
Messages and files should be linked by stable ids and typed references. The postbox module should not import file, mail, or campaign internals.
|
||||
@@ -99,15 +220,23 @@ Postbox should expose narrow capabilities through core:
|
||||
|
||||
Optional consumers:
|
||||
|
||||
- Campaign can use postboxes for campaign sender context, reply intake, review queues, and role-bound access to campaign artifacts.
|
||||
- Files can expose file references to a postbox when the actor's role grants access.
|
||||
- Campaign can use postboxes for function-targeted delivery, sender context,
|
||||
reply intake, review queues, and access to campaign artifacts.
|
||||
- Files can expose file references to a postbox when the actor has effective
|
||||
source-postbox access.
|
||||
- Portal can show portal-facing postboxes without owning the postbox access model.
|
||||
- Mail can bridge external mailbox delivery into postboxes when configured, without making postboxes mailbox-bound.
|
||||
|
||||
## Operational Rules
|
||||
|
||||
- Current role state controls current access.
|
||||
- Historical message records remain durable even when no current person holds the role.
|
||||
- Current function assignment and delegation state controls current access.
|
||||
- Historical message records remain durable even when no current person holds
|
||||
the function; vacancy is a visible attention/access state, not a missing
|
||||
postbox.
|
||||
- Multiple incumbents receive independent device-bound key grants and remain
|
||||
distinguishable in access and action evidence.
|
||||
- Delegation start, expiry, withdrawal, key grant, and key-epoch rotation are
|
||||
separate auditable events.
|
||||
- Administration of bindings should require explicit postbox administration permission plus access/RBAC authority for the target organization.
|
||||
- Sensitive access decisions and binding changes should emit audit events.
|
||||
- Retention rules should be postbox-owned but able to reference campaign, file, and portal provenance.
|
||||
@@ -117,6 +246,58 @@ Optional consumers:
|
||||
key epochs, recipient device references, and external capability tokens even
|
||||
before full E2EE ships.
|
||||
|
||||
### Retention, Audit, And Privacy
|
||||
|
||||
Postbox retention is owned by the postbox module because postbox messages are
|
||||
platform-native communication records, not mailbox folders and not ordinary file
|
||||
shares. Retention policies may reference provenance from campaign, file, portal,
|
||||
mail, or workflow modules, but those modules should pass stable ids and typed
|
||||
evidence references through capabilities instead of giving postbox direct access
|
||||
to their internals.
|
||||
|
||||
The postbox module should emit audit events for:
|
||||
|
||||
- postbox creation, archival, and destructive retirement
|
||||
- binding creation, changes, expiry, and removal
|
||||
- sensitive access checks when an actor gains or loses visibility
|
||||
- message creation, read/download of sensitive content, attachment linking, and
|
||||
delivery handoff
|
||||
- retention holds, retention expiry, export, and destruction decisions
|
||||
|
||||
Privacy behavior must separate current access from historical evidence. Losing
|
||||
a function assignment or generic Postbox permission removes future visibility,
|
||||
but it does not rewrite the fact that a person previously accessed a message or
|
||||
that a message existed. Deletion and destructive retention actions must
|
||||
preserve legally required audit/evidence records while removing or redacting
|
||||
content according to the effective policy.
|
||||
|
||||
When E2EE is enabled later, retention and audit metadata must remain operable
|
||||
without decrypting message content. UI copy should be honest: expiry,
|
||||
withdrawal, or revocation can prevent future platform access, but it cannot
|
||||
guarantee removal of plaintext already fetched, exported, printed, or delivered
|
||||
outside the platform.
|
||||
|
||||
### Migration And Compatibility Ownership
|
||||
|
||||
Postbox-owned tables, DTOs, migrations, and capability names belong in
|
||||
`govoplan-postbox`. Core may temporarily contain compatibility imports or
|
||||
legacy migration references only when needed to keep existing installations
|
||||
upgradable while code is being extracted.
|
||||
|
||||
Compatibility code must be narrow and documented:
|
||||
|
||||
- new postbox behavior is implemented in `govoplan-postbox`
|
||||
- old import paths may re-export postbox DTOs or helpers during a transition,
|
||||
but must not become active owners of postbox logic
|
||||
- migrations that move tables to postbox ownership must preserve existing data
|
||||
and have explicit downgrade/retirement notes
|
||||
- optional integrations with campaign, files, portal, or mail remain capability
|
||||
contracts, not direct imports
|
||||
|
||||
Once supported release migrations have crossed the compatibility window, legacy
|
||||
core import aliases and old table ownership comments should be removed through
|
||||
a normal cleanup issue.
|
||||
|
||||
## First Implementation Shape
|
||||
|
||||
The first implementation should define the backend manifest, permissions, DTOs, and migrations before building rich UI. A minimal API can then support directory lookup, access checks, message creation, message listing, and binding administration.
|
||||
@@ -124,9 +305,9 @@ The first implementation should define the backend manifest, permissions, DTOs,
|
||||
The WebUI should start as an administration and inbox surface:
|
||||
|
||||
- postbox directory
|
||||
- role-bound access explanation
|
||||
- function-bound access explanation
|
||||
- message list and message detail
|
||||
- binding editor for organization and role links
|
||||
- template and binding editor for organization/function links
|
||||
- audit-visible administrative actions
|
||||
|
||||
Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable.
|
||||
@@ -143,3 +324,19 @@ Before the data model is considered stable, verify that it can represent:
|
||||
- external recipient token state
|
||||
- expiry and withdrawal state separate from deletion
|
||||
- retention state that can operate without decrypting content
|
||||
|
||||
## E2EE decisions still to settle before implementation
|
||||
|
||||
The product direction above is selected, but the first trusted profile still
|
||||
needs bounded decisions on:
|
||||
|
||||
- whether a new incumbent receives all retained history, history from a
|
||||
policy-defined date, or only content delivered during the assignment;
|
||||
- organizational recovery/escrow and the authority required when every holder
|
||||
loses all registered device keys;
|
||||
- whether ordinary rotation only rewraps per-content keys or also re-encrypts
|
||||
ciphertext, and which events require the stronger path;
|
||||
- assurance and quorum requirements for delegation, hand-over, emergency
|
||||
access, export, and destructive retention; and
|
||||
- how attention/escalation works during a vacancy without granting plaintext
|
||||
access to an unrelated personal account.
|
||||
|
||||
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