Integrate Distribution Lists with Campaign recipients
This commit is contained in:
@@ -412,6 +412,7 @@ Current principal contracts include:
|
||||
| `files.campaign_attachments` 0.1.x | Files -> Campaign | Select/materialize governed file versions and preserve campaign usage/evidence |
|
||||
| `addresses.lookup` 0.1.x | Addresses -> Campaign | Optional address suggestions |
|
||||
| `addresses.recipient_source` 0.1.x | Addresses -> Campaign | Optional versioned recipient-source snapshots |
|
||||
| `dist_lists.source` / `dist_lists.expand` 0.1.x | Distribution Lists -> Campaign | Discover, preview, and freeze reusable audiences without importing module internals |
|
||||
| `campaigns.access` 0.1.x | Campaign -> platform | Explain campaign access/existence without exporting ORM objects |
|
||||
| `campaigns.mail_policy_context` 0.1.x | Campaign -> Mail | Resolve campaign tenant/owner context for Mail policy |
|
||||
| `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks |
|
||||
@@ -421,6 +422,28 @@ Breaking payload or ownership changes require an interface-version bump and a
|
||||
release-composition alignment gate. Optional absence must be tested physically,
|
||||
not only hidden in navigation.
|
||||
|
||||
### Reusable Distribution Lists
|
||||
|
||||
When Distribution Lists is available, Recipient data offers a separate import
|
||||
dialog. The author selects a visible list revision, supplies declared
|
||||
parameters, requests candidate channels, and previews included, excluded,
|
||||
stale, ambiguous, suppressed, policy-blocked, and provider-unavailable results.
|
||||
The final action freezes an idempotent Distribution Lists snapshot and copies
|
||||
the resulting rows into the editable Campaign version.
|
||||
|
||||
Each copied row retains the list and revision IDs, definition and expansion
|
||||
hashes, snapshot ID, source entry IDs, provider references, channel candidates,
|
||||
the one selected route where it is unambiguous, fallback candidates, and the
|
||||
decision explanation. Campaign-only fields, attachment rules, review state,
|
||||
and outcomes remain local to Campaign and never mutate the reusable list.
|
||||
|
||||
A later list revision only raises a drift warning. Refresh is deliberate and
|
||||
uses append or replace; saving that changed Campaign version clears prior
|
||||
validation, build, review, and execution state through the normal content
|
||||
invalidation path. Postal-only or otherwise unsupported routes remain present
|
||||
in the frozen evidence but inactive until a compatible Campaign output path is
|
||||
configured.
|
||||
|
||||
### External API expectations
|
||||
|
||||
- Tenant and campaign access are evaluated for every operation.
|
||||
|
||||
@@ -64,6 +64,14 @@ warning and lets the user reopen the import dialog with that source preselected.
|
||||
The user still chooses append or replace; campaign should not silently rewrite
|
||||
recipient rows.
|
||||
|
||||
Distribution Lists is a separate, provider-neutral audience boundary. Campaign
|
||||
uses `dist_lists.source` and `dist_lists.expand` to preview and freeze mixed
|
||||
email, postal, internal-mail, and portal candidates. It stores exact list,
|
||||
revision, source-entry, provider, policy, route, exclusion, and expansion-hash
|
||||
evidence with the Campaign version. Addresses remains the contact/contact-point
|
||||
owner; Distribution Lists remains the reusable audience owner; Campaign owns
|
||||
only its copied recipients, enrichment, route choices, review, and outcomes.
|
||||
|
||||
## Non-Goals For Campaign
|
||||
|
||||
Campaign should not become the global address book. It should not own:
|
||||
|
||||
@@ -43,6 +43,8 @@ _FILES_INTEGRATION = "files.campaign_attachments"
|
||||
_MAIL_INTEGRATION = "mail.campaign_delivery"
|
||||
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
|
||||
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
||||
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
|
||||
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
|
||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||
|
||||
|
||||
@@ -219,6 +221,45 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
|
||||
related_modules=("addresses",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-distribution-list",
|
||||
title="Freeze a Distribution List into a campaign",
|
||||
summary="Resolve a reusable audience, inspect its channel and policy decisions, and copy an immutable snapshot into the current campaign version.",
|
||||
body="A Distribution List stays live and versioned in its owning module. Campaign freezes one exact expansion; later list or provider changes only produce a drift warning and never rewrite the saved Campaign recipients.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_modules=("campaigns", "dist_lists"),
|
||||
required_capabilities=(
|
||||
_DISTRIBUTION_LIST_SOURCE_INTEGRATION,
|
||||
_DISTRIBUTION_LIST_EXPAND_INTEGRATION,
|
||||
),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/recipients",
|
||||
screen="Recipient data",
|
||||
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||
prerequisites=(
|
||||
"A visible Distribution List can be expanded for campaign delivery.",
|
||||
"The current Campaign version is editable.",
|
||||
),
|
||||
steps=(
|
||||
"Open Recipient data and select Import Distribution List.",
|
||||
"Choose the list, requested channels, and any declared parameters, then preview the expansion.",
|
||||
"Review included and excluded recipients, stale provider evidence, diagnostics, and unresolved route choices.",
|
||||
"Choose append or replace, freeze and import the expansion, inspect the copied rows, and save the Campaign version.",
|
||||
"Use the drift warning for a deliberate refresh when the reusable list changes later.",
|
||||
),
|
||||
outcome="A Campaign-local recipient snapshot with immutable audience, provider, policy, and channel-decision evidence.",
|
||||
verification="The saved recipient rows retain the list revision and snapshot reference, and later list changes do not alter them automatically.",
|
||||
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
|
||||
related_modules=("dist_lists",),
|
||||
limitations=("Unsupported non-email output routes remain inactive until a compatible Campaign output integration is configured.",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.use-managed-attachments",
|
||||
title="Use managed files as campaign attachments",
|
||||
|
||||
@@ -36,6 +36,10 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
CAPABILITY_DISTRIBUTION_LIST_SOURCE,
|
||||
)
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
@@ -363,6 +367,7 @@ manifest = ModuleManifest(
|
||||
"mail",
|
||||
"notifications",
|
||||
"addresses",
|
||||
"dist_lists",
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
@@ -415,6 +420,18 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_DISTRIBUTION_LIST_SOURCE,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_POSTBOX_DELIVERY,
|
||||
version_min="0.1.1",
|
||||
|
||||
@@ -21,6 +21,10 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignRecipientAddressSourceSnapshotResponse,
|
||||
CampaignRecipientSnapshotExcludedItem,
|
||||
CampaignRecipientSnapshotItem,
|
||||
CampaignDistributionListExpansionRequest,
|
||||
CampaignDistributionListExpansionResponse,
|
||||
CampaignDistributionListSource,
|
||||
CampaignDistributionListSourcesResponse,
|
||||
RecipientImportMappingProfileListResponse,
|
||||
RecipientImportMappingProfilePayload,
|
||||
RecipientImportMappingProfileResponse,
|
||||
@@ -63,6 +67,11 @@ from govoplan_campaign.backend.integrations import (
|
||||
postbox_integration,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
CAPABILITY_DISTRIBUTION_LIST_SOURCE,
|
||||
DistributionExpansionRequest,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.campaigns import (
|
||||
CampaignReportError,
|
||||
generate_campaign_report,
|
||||
@@ -809,6 +818,144 @@ def snapshot_campaign_recipient_address_source(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/recipient-distribution-lists",
|
||||
response_model=CampaignDistributionListSourcesResponse,
|
||||
)
|
||||
def list_campaign_recipient_distribution_lists(
|
||||
campaign_id: str,
|
||||
query: str = Query(default="", min_length=0, max_length=200),
|
||||
limit: int = Query(default=100, ge=1, le=250),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
source_capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_SOURCE)
|
||||
expand_capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_EXPAND)
|
||||
if source_capability is None or not hasattr(source_capability, "list_sources"):
|
||||
return CampaignDistributionListSourcesResponse(available=False)
|
||||
sources = getattr(source_capability, "list_sources")(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
return CampaignDistributionListSourcesResponse(
|
||||
available=True,
|
||||
expand_available=bool(
|
||||
expand_capability is not None and hasattr(expand_capability, "expand")
|
||||
),
|
||||
sources=[
|
||||
CampaignDistributionListSource.model_validate(
|
||||
_capability_payload(source)
|
||||
)
|
||||
for source in sources
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/recipient-distribution-lists/preview",
|
||||
response_model=CampaignDistributionListExpansionResponse,
|
||||
)
|
||||
def preview_campaign_recipient_distribution_list(
|
||||
campaign_id: str,
|
||||
payload: CampaignDistributionListExpansionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
return _expand_campaign_distribution_list(
|
||||
session,
|
||||
principal,
|
||||
payload=payload,
|
||||
freeze=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/recipient-distribution-lists/snapshot",
|
||||
response_model=CampaignDistributionListExpansionResponse,
|
||||
)
|
||||
def snapshot_campaign_recipient_distribution_list(
|
||||
campaign_id: str,
|
||||
payload: CampaignDistributionListExpansionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
if not payload.idempotency_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Freezing a distribution list requires an idempotency key",
|
||||
)
|
||||
result = _expand_campaign_distribution_list(
|
||||
session,
|
||||
principal,
|
||||
payload=payload,
|
||||
freeze=True,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.distribution_list_snapshot_frozen",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
"list_id": result.source.id,
|
||||
"list_revision": result.source.revision,
|
||||
"snapshot_id": result.snapshot_id,
|
||||
"expansion_hash": result.expansion_hash,
|
||||
"recipient_count": len(result.recipients),
|
||||
"excluded_count": len(result.excluded),
|
||||
"stale": result.stale,
|
||||
"truncated": result.truncated,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _expand_campaign_distribution_list(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
payload: CampaignDistributionListExpansionRequest,
|
||||
freeze: bool,
|
||||
) -> CampaignDistributionListExpansionResponse:
|
||||
capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_EXPAND)
|
||||
if capability is None or not hasattr(capability, "expand"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Distribution-list expansion capability is not available",
|
||||
)
|
||||
request = DistributionExpansionRequest(
|
||||
list_id=payload.list_id,
|
||||
revision=payload.revision,
|
||||
effective_at=payload.effective_at,
|
||||
purpose=payload.purpose,
|
||||
requested_channels=tuple(payload.requested_channels),
|
||||
parameters=payload.parameters,
|
||||
preview=not freeze,
|
||||
freeze=freeze,
|
||||
idempotency_key=payload.idempotency_key if freeze else None,
|
||||
)
|
||||
try:
|
||||
result = getattr(capability, "expand")(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return CampaignDistributionListExpansionResponse.model_validate(
|
||||
_capability_payload(result)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}", response_model=CampaignResponse)
|
||||
def get_campaign(
|
||||
campaign_id: str,
|
||||
|
||||
@@ -1109,6 +1109,11 @@
|
||||
"additionalProperties": true,
|
||||
"default": {}
|
||||
},
|
||||
"distribution_source": {
|
||||
"type": "object",
|
||||
"description": "Immutable Distribution List recipient, route-decision, and source evidence captured for this Campaign version.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"last_sent": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -1195,7 +1200,8 @@
|
||||
"csv",
|
||||
"xlsx",
|
||||
"text",
|
||||
"addresses"
|
||||
"addresses",
|
||||
"distribution_list"
|
||||
]
|
||||
},
|
||||
"source_id": {
|
||||
|
||||
@@ -438,6 +438,132 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignDistributionListParameter(BaseModel):
|
||||
key: str
|
||||
value_type: str
|
||||
label: str | None = None
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
allowed_values: list[Any] = Field(default_factory=list)
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
pattern: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CampaignDistributionListSource(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
revision_id: str
|
||||
revision: int
|
||||
definition_hash: str
|
||||
definition_kind: str = "static"
|
||||
description: str | None = None
|
||||
status: str = "active"
|
||||
entry_count: int = 0
|
||||
read_only: bool = False
|
||||
stale: bool = False
|
||||
parameters: list[CampaignDistributionListParameter] = Field(default_factory=list)
|
||||
updated_at: datetime | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignDistributionListSourcesResponse(BaseModel):
|
||||
available: bool = False
|
||||
expand_available: bool = False
|
||||
sources: list[CampaignDistributionListSource] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignDistributionListExpansionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
list_id: str = Field(min_length=1, max_length=36)
|
||||
revision: int | None = Field(default=None, ge=1)
|
||||
effective_at: datetime | None = None
|
||||
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
|
||||
requested_channels: list[Literal["email", "postal", "internal_mail", "portal"]] = Field(
|
||||
default_factory=list,
|
||||
max_length=4,
|
||||
)
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CampaignDistributionSourceReference(BaseModel):
|
||||
provider: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
revision: str | None = None
|
||||
fingerprint: str | None = None
|
||||
label: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignDistributionExplanation(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
severity: str
|
||||
provider: str | None = None
|
||||
source: CampaignDistributionSourceReference | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignDistributionChannelCandidate(BaseModel):
|
||||
channel: str
|
||||
target: str
|
||||
target_key: str
|
||||
status: str
|
||||
contact_point_id: str | None = None
|
||||
locale: str | None = None
|
||||
preferred: bool = False
|
||||
reason_code: str | None = None
|
||||
explanation: str | None = None
|
||||
source: CampaignDistributionSourceReference | None = None
|
||||
decision_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignDistributionRecipient(BaseModel):
|
||||
recipient_key: str
|
||||
display_name: str
|
||||
status: str
|
||||
channels: list[CampaignDistributionChannelCandidate] = Field(default_factory=list)
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
contact_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
source_entry_ids: list[str] = Field(default_factory=list)
|
||||
explanations: list[CampaignDistributionExplanation] = Field(default_factory=list)
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignDistributionProviderEvidence(BaseModel):
|
||||
provider: str
|
||||
source: CampaignDistributionSourceReference
|
||||
actual_revision: str | None = None
|
||||
actual_fingerprint: str | None = None
|
||||
stale: bool = False
|
||||
generated_at: datetime | None = None
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignDistributionListExpansionResponse(BaseModel):
|
||||
source: CampaignDistributionListSource
|
||||
request: dict[str, Any] = Field(default_factory=dict)
|
||||
recipients: list[CampaignDistributionRecipient] = Field(default_factory=list)
|
||||
excluded: list[CampaignDistributionRecipient] = Field(default_factory=list)
|
||||
diagnostics: list[CampaignDistributionExplanation] = Field(default_factory=list)
|
||||
provider_evidence: list[CampaignDistributionProviderEvidence] = Field(default_factory=list)
|
||||
expansion_hash: str
|
||||
generated_at: datetime | None = None
|
||||
snapshot_id: str | None = None
|
||||
stale: bool = False
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class CampaignJobsResponse(BaseModel):
|
||||
jobs: list[dict[str, Any]]
|
||||
page: int = 1
|
||||
|
||||
@@ -130,6 +130,125 @@ class CampaignPartialValidationTests(unittest.TestCase):
|
||||
self.assertEqual(response.excluded[0].reason_code, "addresses.channel.opted_out")
|
||||
self.assertTrue(response.provenance["governance_applied"])
|
||||
|
||||
def test_distribution_list_preview_preserves_provider_and_route_evidence(self) -> None:
|
||||
class ExpansionCapability:
|
||||
def expand(self, _session, _principal, *, request):
|
||||
self.request = request
|
||||
return _distribution_expansion_payload()
|
||||
|
||||
capability = ExpansionCapability()
|
||||
payload = router.CampaignDistributionListExpansionRequest(
|
||||
list_id="11111111-1111-1111-1111-111111111111",
|
||||
revision=3,
|
||||
requested_channels=["email", "postal"],
|
||||
parameters={"district": "north"},
|
||||
)
|
||||
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(
|
||||
router, "_registry_capability", return_value=capability
|
||||
):
|
||||
response = router.preview_campaign_recipient_distribution_list(
|
||||
"campaign-1", payload, session=object(), principal=object()
|
||||
)
|
||||
|
||||
self.assertTrue(capability.request.preview)
|
||||
self.assertFalse(capability.request.freeze)
|
||||
self.assertEqual(capability.request.parameters, {"district": "north"})
|
||||
self.assertEqual(response.source.revision, 3)
|
||||
self.assertEqual(response.recipients[0].source_entry_ids, ["entry-1"])
|
||||
self.assertEqual(response.recipients[0].channels[0].decision_provenance["policy"], "allow")
|
||||
self.assertEqual(response.provider_evidence[0].actual_revision, "provider-r7")
|
||||
|
||||
def test_distribution_list_snapshot_requires_and_forwards_idempotency(self) -> None:
|
||||
class ExpansionCapability:
|
||||
def expand(self, _session, _principal, *, request):
|
||||
self.request = request
|
||||
payload = _distribution_expansion_payload()
|
||||
payload["snapshot_id"] = "snapshot-1"
|
||||
return payload
|
||||
|
||||
capability = ExpansionCapability()
|
||||
payload = router.CampaignDistributionListExpansionRequest(
|
||||
list_id="11111111-1111-1111-1111-111111111111",
|
||||
requested_channels=["email"],
|
||||
idempotency_key="campaign:one:list:one",
|
||||
)
|
||||
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(
|
||||
router, "_registry_capability", return_value=capability
|
||||
), patch.object(router, "audit_from_principal") as audit:
|
||||
response = router.snapshot_campaign_recipient_distribution_list(
|
||||
"campaign-1", payload, session=object(), principal=object()
|
||||
)
|
||||
|
||||
self.assertTrue(capability.request.freeze)
|
||||
self.assertFalse(capability.request.preview)
|
||||
self.assertEqual(capability.request.idempotency_key, "campaign:one:list:one")
|
||||
self.assertEqual(response.snapshot_id, "snapshot-1")
|
||||
self.assertTrue(audit.call_args.kwargs["commit"])
|
||||
|
||||
|
||||
def _distribution_expansion_payload() -> dict[str, object]:
|
||||
source = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"tenant_id": "tenant-1",
|
||||
"name": "Residents",
|
||||
"revision_id": "revision-id-3",
|
||||
"revision": 3,
|
||||
"definition_hash": "definition-hash",
|
||||
"definition_kind": "parameterized",
|
||||
"entry_count": 1,
|
||||
"parameters": [],
|
||||
"provenance": {},
|
||||
"metadata": {},
|
||||
}
|
||||
reference = {
|
||||
"provider": "addresses",
|
||||
"resource_type": "address_list",
|
||||
"resource_id": "list-1",
|
||||
"revision": "provider-r7",
|
||||
"metadata": {},
|
||||
}
|
||||
recipient = {
|
||||
"recipient_key": "contact:1",
|
||||
"display_name": "Ada Lovelace",
|
||||
"status": "usable",
|
||||
"channels": [
|
||||
{
|
||||
"channel": "email",
|
||||
"target": "ada@example.local",
|
||||
"target_key": "email:ada@example.local",
|
||||
"status": "usable",
|
||||
"preferred": True,
|
||||
"source": reference,
|
||||
"decision_provenance": {"policy": "allow"},
|
||||
}
|
||||
],
|
||||
"contact_id": "contact-1",
|
||||
"source_entry_ids": ["entry-1"],
|
||||
"explanations": [],
|
||||
"attributes": {"district": "north"},
|
||||
"provenance": {"provider": "addresses"},
|
||||
}
|
||||
return {
|
||||
"source": source,
|
||||
"request": {"parameters": {"district": "north"}},
|
||||
"recipients": [recipient],
|
||||
"excluded": [],
|
||||
"diagnostics": [],
|
||||
"provider_evidence": [
|
||||
{
|
||||
"provider": "addresses",
|
||||
"source": reference,
|
||||
"actual_revision": "provider-r7",
|
||||
"stale": False,
|
||||
"details": {},
|
||||
}
|
||||
],
|
||||
"expansion_hash": "expansion-hash",
|
||||
"generated_at": "2026-08-02T10:00:00+00:00",
|
||||
"stale": False,
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
|
||||
class CampaignSemanticValidationTests(unittest.TestCase):
|
||||
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
|
||||
|
||||
@@ -195,6 +195,127 @@ export type CampaignRecipientAddressSourcesResponse = {
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
};
|
||||
|
||||
export type CampaignDistributionListParameter = {
|
||||
key: string;
|
||||
value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "string_list";
|
||||
label?: string | null;
|
||||
required: boolean;
|
||||
default?: unknown;
|
||||
allowed_values: unknown[];
|
||||
minimum?: number | null;
|
||||
maximum?: number | null;
|
||||
pattern?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListSource = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
revision_id: string;
|
||||
revision: number;
|
||||
definition_hash: string;
|
||||
definition_kind: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
entry_count: number;
|
||||
read_only: boolean;
|
||||
stale: boolean;
|
||||
parameters: CampaignDistributionListParameter[];
|
||||
updated_at?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListSourcesResponse = {
|
||||
available: boolean;
|
||||
expand_available: boolean;
|
||||
sources: CampaignDistributionListSource[];
|
||||
};
|
||||
|
||||
export type CampaignDistributionSourceReference = {
|
||||
provider: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
revision?: string | null;
|
||||
fingerprint?: string | null;
|
||||
label?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionExplanation = {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: "info" | "warning" | "error" | string;
|
||||
provider?: string | null;
|
||||
source?: CampaignDistributionSourceReference | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionChannelCandidate = {
|
||||
channel: "email" | "postal" | "internal_mail" | "portal" | string;
|
||||
target: string;
|
||||
target_key: string;
|
||||
status: string;
|
||||
contact_point_id?: string | null;
|
||||
locale?: string | null;
|
||||
preferred: boolean;
|
||||
reason_code?: string | null;
|
||||
explanation?: string | null;
|
||||
source?: CampaignDistributionSourceReference | null;
|
||||
decision_provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionRecipient = {
|
||||
recipient_key: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
channels: CampaignDistributionChannelCandidate[];
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
contact_id?: string | null;
|
||||
organization_unit_id?: string | null;
|
||||
function_id?: string | null;
|
||||
source_entry_ids: string[];
|
||||
explanations: CampaignDistributionExplanation[];
|
||||
attributes: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionProviderEvidence = {
|
||||
provider: string;
|
||||
source: CampaignDistributionSourceReference;
|
||||
actual_revision?: string | null;
|
||||
actual_fingerprint?: string | null;
|
||||
stale: boolean;
|
||||
generated_at?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListExpansion = {
|
||||
source: CampaignDistributionListSource;
|
||||
request: Record<string, unknown>;
|
||||
recipients: CampaignDistributionRecipient[];
|
||||
excluded: CampaignDistributionRecipient[];
|
||||
diagnostics: CampaignDistributionExplanation[];
|
||||
provider_evidence: CampaignDistributionProviderEvidence[];
|
||||
expansion_hash: string;
|
||||
generated_at?: string | null;
|
||||
snapshot_id?: string | null;
|
||||
stale: boolean;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListExpansionInput = {
|
||||
list_id: string;
|
||||
revision?: number | null;
|
||||
effective_at?: string | null;
|
||||
purpose?: string;
|
||||
requested_channels: Array<"email" | "postal" | "internal_mail" | "portal">;
|
||||
parameters: Record<string, unknown>;
|
||||
idempotency_key?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignPostboxDirectoryEntry = {
|
||||
id: string;
|
||||
address: string;
|
||||
@@ -746,6 +867,40 @@ campaignId: string)
|
||||
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
|
||||
}
|
||||
|
||||
export async function listCampaignRecipientDistributionLists(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
query = "")
|
||||
: Promise<CampaignDistributionListSourcesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "250");
|
||||
if (query.trim()) params.set("query", query.trim());
|
||||
const suffix = params.size ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignDistributionListSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists${suffix}`);
|
||||
}
|
||||
|
||||
export async function previewCampaignRecipientDistributionList(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignDistributionListExpansionInput)
|
||||
: Promise<CampaignDistributionListExpansion> {
|
||||
return apiFetch<CampaignDistributionListExpansion>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists/preview`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function snapshotCampaignRecipientDistributionList(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignDistributionListExpansionInput)
|
||||
: Promise<CampaignDistributionListExpansion> {
|
||||
return apiFetch<CampaignDistributionListExpansion>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists/snapshot`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaignPostboxCatalog(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
getCampaignPostboxCatalog,
|
||||
listCampaignRecipientAddressSources,
|
||||
listCampaignRecipientDistributionLists,
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
type CampaignDistributionListExpansion,
|
||||
type CampaignDistributionListSource,
|
||||
type CampaignPostboxCatalog,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot } from
|
||||
@@ -38,6 +41,11 @@ import {
|
||||
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
|
||||
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
|
||||
import {
|
||||
distributionListDrift,
|
||||
materializeDistributionListExpansion
|
||||
} from "./utils/distributionListImport";
|
||||
import {
|
||||
AddressHeaderControl,
|
||||
HeaderAddressEditorDialog,
|
||||
@@ -71,6 +79,11 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
|
||||
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
|
||||
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
|
||||
const [distributionListImportOpen, setDistributionListImportOpen] = useState(false);
|
||||
const [distributionListImportInitialId, setDistributionListImportInitialId] = useState("");
|
||||
const [distributionListsAvailable, setDistributionListsAvailable] = useState(false);
|
||||
const [distributionListsLoading, setDistributionListsLoading] = useState(false);
|
||||
const [distributionLists, setDistributionLists] = useState<CampaignDistributionListSource[]>([]);
|
||||
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
||||
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
||||
const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
|
||||
@@ -130,6 +143,10 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
}).
|
||||
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
|
||||
}, [addressSourceRevisionById, entries.imports]);
|
||||
const staleDistributionListImports = useMemo(
|
||||
() => distributionListDrift(entries.imports, distributionLists),
|
||||
[distributionLists, entries.imports]
|
||||
);
|
||||
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
|
||||
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
@@ -159,6 +176,26 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDistributionListsLoading(true);
|
||||
void listCampaignRecipientDistributionLists(settings, campaignId)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setDistributionListsAvailable(response.available && response.expand_available);
|
||||
setDistributionLists(response.available ? response.sources ?? [] : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setDistributionListsAvailable(false);
|
||||
setDistributionLists([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDistributionListsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postboxModuleInstalled) {
|
||||
setPostboxCatalog({
|
||||
@@ -322,6 +359,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
setAddressSourceImportOpen(false);
|
||||
}
|
||||
|
||||
function applyDistributionListImport(snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) {
|
||||
if (locked || !draft) return;
|
||||
setDraft(materializeDistributionListExpansion(draft, snapshot, mode));
|
||||
markDirty();
|
||||
setDistributionListImportOpen(false);
|
||||
}
|
||||
|
||||
function saveHeaderAddresses(values: HeaderAddressValues) {
|
||||
const nextRecipients = { ...recipientsSection };
|
||||
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
|
||||
@@ -435,6 +479,11 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
|
||||
Import address book/list
|
||||
</Button>
|
||||
}
|
||||
{(distributionListsAvailable || distributionListsLoading) &&
|
||||
<Button disabled={locked || distributionListsLoading || !distributionListsAvailable} onClick={() => {setDistributionListImportInitialId("");setDistributionListImportOpen(true);}}>
|
||||
Import Distribution List
|
||||
</Button>
|
||||
}
|
||||
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
</div>
|
||||
@@ -459,6 +508,27 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
</div>
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{staleDistributionListImports.length > 0 &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
<div className="stale-address-import-warning">
|
||||
<span>Frozen Distribution List imports have changed or become unavailable. Campaign recipients remain unchanged until an explicit refresh.</span>
|
||||
<div className="button-row compact-actions">
|
||||
{staleDistributionListImports.map((item) =>
|
||||
<span key={`${item.sourceId}:${item.importedRevision ?? "unknown"}`} className="button-row compact-actions">
|
||||
<span>{item.sourceLabel}: {item.reason}</span>
|
||||
{item.currentRevision !== null &&
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {setDistributionListImportInitialId(item.sourceId);setDistributionListImportOpen(true);}}>
|
||||
Refresh {item.sourceLabel}
|
||||
</Button>
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{!source.type &&
|
||||
<div className="admin-table-surface recipient-profiles-table-surface">
|
||||
<DataGrid
|
||||
@@ -527,6 +597,16 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
onCancel={() => setAddressSourceImportOpen(false)}
|
||||
onImport={applyAddressSourceImport} />
|
||||
|
||||
}
|
||||
{distributionListImportOpen &&
|
||||
<DistributionListImportDialog
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
sources={distributionLists}
|
||||
initialSourceId={distributionListImportInitialId}
|
||||
onCancel={() => setDistributionListImportOpen(false)}
|
||||
onImport={applyDistributionListImport} />
|
||||
|
||||
}
|
||||
{recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] &&
|
||||
<RecipientAddressEditorDialog
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
ToggleSwitch,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
previewCampaignRecipientDistributionList,
|
||||
snapshotCampaignRecipientDistributionList,
|
||||
type CampaignDistributionListExpansion,
|
||||
type CampaignDistributionListExpansionInput,
|
||||
type CampaignDistributionListParameter,
|
||||
type CampaignDistributionListSource,
|
||||
type CampaignDistributionRecipient
|
||||
} from "../../../api/campaigns";
|
||||
import type { RecipientImportMode } from "../utils/bulkImport";
|
||||
import { usableChannelSummary } from "../utils/distributionListImport";
|
||||
|
||||
type RequestedChannel = "email" | "postal" | "internal_mail" | "portal";
|
||||
type PreviewRow = CampaignDistributionRecipient & { included: boolean };
|
||||
|
||||
const channelOptions: Array<{id: RequestedChannel;label: string;}> = [
|
||||
{ id: "email", label: "Email" },
|
||||
{ id: "postal", label: "Postal" },
|
||||
{ id: "internal_mail", label: "Internal mail" },
|
||||
{ id: "portal", label: "Portal" }
|
||||
];
|
||||
|
||||
export default function DistributionListImportDialog({
|
||||
settings,
|
||||
campaignId,
|
||||
sources,
|
||||
initialSourceId = "",
|
||||
onCancel,
|
||||
onImport
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignDistributionListSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) => void;
|
||||
}) {
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.id || "");
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [requestedChannels, setRequestedChannels] = useState<RequestedChannel[]>(channelOptions.map((item) => item.id));
|
||||
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
||||
const [preview, setPreview] = useState<CampaignDistributionListExpansion | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = sourceQuery.trim().toLowerCase();
|
||||
if (!query) return sources;
|
||||
return sources.filter((source) => [source.name, source.description, source.definition_kind]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query)));
|
||||
}, [sourceQuery, sources]);
|
||||
const previewRows = useMemo<PreviewRow[]>(() => [
|
||||
...(preview?.recipients ?? []).map((recipient) => ({ ...recipient, included: true })),
|
||||
...(preview?.excluded ?? []).map((recipient) => ({ ...recipient, included: false }))
|
||||
], [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.id === selectedSourceId)) return;
|
||||
setSelectedSourceId(filteredSources[0]?.id ?? "");
|
||||
}, [filteredSources, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSource) {
|
||||
setParameters({});
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setParameters(Object.fromEntries(
|
||||
selectedSource.parameters
|
||||
.filter((parameter) => parameter.default !== null && parameter.default !== undefined)
|
||||
.map((parameter) => [parameter.key, parameter.default])
|
||||
));
|
||||
setPreview(null);
|
||||
setError("");
|
||||
}, [selectedSource?.id, selectedSource?.revision_id]);
|
||||
|
||||
function toggleChannel(channel: RequestedChannel, checked: boolean) {
|
||||
setRequestedChannels((current) => checked
|
||||
? [...new Set([...current, channel])]
|
||||
: current.filter((item) => item !== channel));
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
function updateParameter(parameter: CampaignDistributionListParameter, value: unknown) {
|
||||
setParameters((current) => ({ ...current, [parameter.key]: normalizeParameterValue(parameter, value) }));
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
function requestPayload(idempotencyKey?: string): CampaignDistributionListExpansionInput {
|
||||
return {
|
||||
list_id: selectedSourceId,
|
||||
revision: selectedSource?.revision ?? null,
|
||||
purpose: "campaign_delivery",
|
||||
requested_channels: requestedChannels,
|
||||
parameters,
|
||||
idempotency_key: idempotencyKey ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
if (!selectedSourceId || requestedChannels.length === 0) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setPreview(await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload()));
|
||||
} catch (reason) {
|
||||
setPreview(null);
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function freezeAndImport() {
|
||||
if (!preview || !selectedSourceId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const idempotencyKey = `campaign:${campaignId}:distribution:${selectedSourceId}:${randomId()}`;
|
||||
const snapshot = await snapshotCampaignRecipientDistributionList(
|
||||
settings,
|
||||
campaignId,
|
||||
requestPayload(idempotencyKey)
|
||||
);
|
||||
onImport(snapshot, mode);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Import a Distribution List"
|
||||
className="recipient-import-modal"
|
||||
bodyClassName="recipient-import-body"
|
||||
closeDisabled={loading}
|
||||
closeOnBackdrop={!loading}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated}
|
||||
onClick={() => void freezeAndImport()}
|
||||
>
|
||||
Freeze and import
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="address-source-import-controls">
|
||||
<input
|
||||
type="search"
|
||||
value={sourceQuery}
|
||||
disabled={loading || sources.length === 0}
|
||||
placeholder="Search Distribution Lists"
|
||||
aria-label="Search Distribution Lists"
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
/>
|
||||
<FormField label="Import mode">
|
||||
<SegmentedControl
|
||||
ariaLabel="Distribution List import mode"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
size="content"
|
||||
width="inline"
|
||||
disabled={loading}
|
||||
options={[
|
||||
{ id: "append", label: "Append" },
|
||||
{ id: "replace", label: "Replace" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<div className="address-source-picker" role="radiogroup" aria-label="Distribution List">
|
||||
{filteredSources.map((source) => (
|
||||
<button
|
||||
type="button"
|
||||
key={source.id}
|
||||
className={`address-source-option ${source.id === selectedSourceId ? "is-selected" : ""}`}
|
||||
disabled={loading}
|
||||
role="radio"
|
||||
aria-checked={source.id === selectedSourceId}
|
||||
onClick={() => setSelectedSourceId(source.id)}
|
||||
>
|
||||
<span className="address-source-option-main">
|
||||
<strong>{source.name}</strong>
|
||||
<span>{source.definition_kind} · revision {source.revision}</span>
|
||||
</span>
|
||||
<span className="address-source-option-meta">
|
||||
<span>{source.entry_count} entries</span>
|
||||
{source.stale && <span>Stale source</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{sources.length > 0 && filteredSources.length === 0 && (
|
||||
<div className="empty-state compact-empty">No Distribution Lists match the search.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="distribution-list-import-settings">
|
||||
<fieldset className="form-section compact-form-section">
|
||||
<legend>Requested channels</legend>
|
||||
{channelOptions.map((channel) => (
|
||||
<ToggleSwitch
|
||||
key={channel.id}
|
||||
label={channel.label}
|
||||
checked={requestedChannels.includes(channel.id)}
|
||||
disabled={loading}
|
||||
onChange={(checked) => toggleChannel(channel.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
{selectedSource?.parameters.map((parameter) => (
|
||||
<DistributionParameterField
|
||||
key={parameter.key}
|
||||
parameter={parameter}
|
||||
value={parameters[parameter.key]}
|
||||
disabled={loading}
|
||||
onChange={(value) => updateParameter(parameter, value)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || !selectedSourceId || requestedChannels.length === 0}
|
||||
onClick={() => void loadPreview()}
|
||||
>
|
||||
{preview ? "Refresh preview" : "Preview expansion"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Resolving Distribution List...</DismissibleAlert>}
|
||||
{!loading && sources.length === 0 && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
No Distribution Lists are available to this Campaign.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div><dt>List</dt><dd>{preview.source.name}</dd></div>
|
||||
<div><dt>Revision</dt><dd>{preview.source.revision}</dd></div>
|
||||
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
|
||||
<div><dt>Excluded</dt><dd>{preview.excluded.length}</dd></div>
|
||||
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
|
||||
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
|
||||
</dl>
|
||||
{preview.stale && (
|
||||
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||
At least one provider result is stale. Review its diagnostics before freezing this expansion.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview.truncated && (
|
||||
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||
The expansion reached a safety limit and cannot be frozen from this dialog.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview.diagnostics.map((diagnostic) => (
|
||||
<DismissibleAlert
|
||||
key={`${diagnostic.code}:${diagnostic.message}`}
|
||||
tone={diagnostic.severity === "error" ? "danger" : diagnostic.severity === "warning" ? "warning" : "info"}
|
||||
compact
|
||||
dismissible={false}
|
||||
>
|
||||
{diagnostic.message}
|
||||
</DismissibleAlert>
|
||||
))}
|
||||
<DistributionPreviewGrid rows={previewRows.slice(0, 100)} />
|
||||
{previewRows.length > 100 && (
|
||||
<p className="muted small-note">{previewRows.length - 100} more decisions are included in the frozen evidence.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionParameterField({
|
||||
parameter,
|
||||
value,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
parameter: CampaignDistributionListParameter;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const label = parameter.label || parameter.key;
|
||||
if (parameter.value_type === "boolean") {
|
||||
return <ToggleSwitch label={label} checked={Boolean(value)} disabled={disabled} onChange={onChange} />;
|
||||
}
|
||||
if (parameter.allowed_values.length > 0) {
|
||||
return (
|
||||
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
||||
<select value={scalarInputValue(value)} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
||||
{!parameter.required && <option value="">Any</option>}
|
||||
{parameter.allowed_values.map((option) => (
|
||||
<option key={String(option)} value={String(option)}>{String(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
const inputType = parameter.value_type === "date"
|
||||
? "date"
|
||||
: parameter.value_type === "datetime"
|
||||
? "datetime-local"
|
||||
: ["integer", "number"].includes(parameter.value_type)
|
||||
? "number"
|
||||
: "text";
|
||||
return (
|
||||
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={scalarInputValue(value)}
|
||||
disabled={disabled}
|
||||
min={parameter.minimum ?? undefined}
|
||||
max={parameter.maximum ?? undefined}
|
||||
pattern={parameter.pattern ?? undefined}
|
||||
placeholder={parameter.value_type === "string_list" ? "Value 1, Value 2" : undefined}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) {
|
||||
const columns: DataGridColumn<PreviewRow>[] = [
|
||||
{ id: "name", header: "Recipient", width: "minmax(180px, 1fr)", value: (row) => row.display_name || row.recipient_key },
|
||||
{ id: "result", header: "Decision", width: 120, value: (row) => row.included ? "Included" : row.status },
|
||||
{ id: "channels", header: "Usable channels", width: "minmax(160px, 0.8fr)", value: (row) => usableChannelSummary(row.channels) },
|
||||
{ id: "source", header: "Source entries", width: "minmax(160px, 0.8fr)", value: (row) => row.source_entry_ids.join(", ") },
|
||||
{ id: "reason", header: "Explanation", width: "minmax(220px, 1.2fr)", value: (row) => row.explanations.map((item) => item.message).join(" · ") }
|
||||
];
|
||||
return (
|
||||
<DataGrid
|
||||
id="campaign-distribution-list-preview"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => `${row.included ? "included" : "excluded"}:${row.recipient_key}`}
|
||||
emptyText="No recipients resolved from this Distribution List."
|
||||
className="recipient-table-wrap"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeParameterValue(parameter: CampaignDistributionListParameter, value: unknown): unknown {
|
||||
if (value === "" || value === null || value === undefined) return null;
|
||||
if (parameter.value_type === "integer") return Number.parseInt(String(value), 10);
|
||||
if (parameter.value_type === "number") return Number(String(value));
|
||||
if (parameter.value_type === "string_list") return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
||||
return value;
|
||||
}
|
||||
|
||||
function scalarInputValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export type CsvParseOptions = {
|
||||
quoted: boolean;
|
||||
};
|
||||
|
||||
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses";
|
||||
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses" | "distribution_list";
|
||||
|
||||
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { RecipientImportMode, RecipientImportProvenance } from "./bulkImport";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type DistributionChannelCandidateSnapshot = {
|
||||
channel: string;
|
||||
target: string;
|
||||
target_key: string;
|
||||
status: string;
|
||||
preferred: boolean;
|
||||
contact_point_id?: string | null;
|
||||
locale?: string | null;
|
||||
reason_code?: string | null;
|
||||
explanation?: string | null;
|
||||
source?: unknown;
|
||||
decision_provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DistributionRecipientSnapshot = {
|
||||
recipient_key: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
channels: DistributionChannelCandidateSnapshot[];
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
contact_id?: string | null;
|
||||
organization_unit_id?: string | null;
|
||||
function_id?: string | null;
|
||||
source_entry_ids: string[];
|
||||
explanations: unknown[];
|
||||
attributes: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DistributionListExpansionSnapshot = {
|
||||
source: {
|
||||
id: string;
|
||||
name: string;
|
||||
revision_id: string;
|
||||
revision: number;
|
||||
definition_hash: string;
|
||||
tenant_id?: string;
|
||||
definition_kind?: string;
|
||||
status?: string;
|
||||
entry_count?: number;
|
||||
read_only?: boolean;
|
||||
stale?: boolean;
|
||||
parameters?: unknown[];
|
||||
provenance?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
request: Record<string, unknown>;
|
||||
recipients: DistributionRecipientSnapshot[];
|
||||
excluded: DistributionRecipientSnapshot[];
|
||||
diagnostics: unknown[];
|
||||
provider_evidence: unknown[];
|
||||
expansion_hash: string;
|
||||
generated_at?: string | null;
|
||||
snapshot_id?: string | null;
|
||||
stale: boolean;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export function materializeDistributionListExpansion(
|
||||
draft: JsonRecord,
|
||||
expansion: DistributionListExpansionSnapshot,
|
||||
mode: RecipientImportMode
|
||||
): JsonRecord {
|
||||
const currentEntries = asRecord(draft.entries);
|
||||
const existingEntries = asArray(currentEntries.inline).map(asRecord);
|
||||
const usedIds = new Set(existingEntries.map((entry) => text(entry.id)).filter(Boolean));
|
||||
const fieldNames = new Set<string>();
|
||||
const importedEntries = expansion.recipients.map((recipient) => {
|
||||
const entry = recipientEntry(expansion, recipient, usedIds);
|
||||
Object.keys(asRecord(entry.fields)).forEach((name) => fieldNames.add(name));
|
||||
return entry;
|
||||
});
|
||||
const provenance = distributionListImportProvenance(expansion, mode, fieldNames);
|
||||
const previousImports = asArray(currentEntries.imports).map(asRecord);
|
||||
|
||||
return {
|
||||
...draft,
|
||||
fields: mergeFieldDefinitions(draft.fields, [...fieldNames]),
|
||||
entries: {
|
||||
...currentEntries,
|
||||
inline: mode === "append" ? [...existingEntries, ...importedEntries] : importedEntries,
|
||||
imports: [...previousImports, provenance]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function distributionListDrift(
|
||||
imports: unknown,
|
||||
sources: Array<{id: string;revision: number;revision_id: string;definition_hash: string;}>
|
||||
): Array<{
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
importedRevision: number | null;
|
||||
currentRevision: number | null;
|
||||
reason: string;
|
||||
}> {
|
||||
const currentById = new Map(sources.map((source) => [source.id, source]));
|
||||
const driftedImports: Array<{
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
importedRevision: number | null;
|
||||
currentRevision: number | null;
|
||||
reason: string;
|
||||
}> = [];
|
||||
for (const item of asArray(imports).map(asRecord).filter((record) => record.source_type === "distribution_list")) {
|
||||
const sourceId = text(item.source_id);
|
||||
const current = currentById.get(sourceId);
|
||||
if (!sourceId) continue;
|
||||
const sourceProvenance = asRecord(item.source_provenance);
|
||||
const importedRevision = numberOrNull(sourceProvenance.list_revision);
|
||||
const importedRevisionId = text(sourceProvenance.list_revision_id);
|
||||
const importedHash = text(sourceProvenance.definition_hash);
|
||||
if (!current) {
|
||||
driftedImports.push({
|
||||
sourceId,
|
||||
sourceLabel: text(item.source_label) || sourceId,
|
||||
importedRevision,
|
||||
currentRevision: null,
|
||||
reason: "The source is no longer visible or available; the frozen Campaign snapshot is unchanged."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const drifted = (
|
||||
importedRevision !== current.revision ||
|
||||
(importedRevisionId && importedRevisionId !== current.revision_id) ||
|
||||
(importedHash && importedHash !== current.definition_hash)
|
||||
);
|
||||
if (!drifted) continue;
|
||||
driftedImports.push({
|
||||
sourceId,
|
||||
sourceLabel: text(item.source_label) || sourceId,
|
||||
importedRevision,
|
||||
currentRevision: current.revision,
|
||||
reason: `Frozen revision ${importedRevision ?? "unknown"}; current revision ${current.revision}.`
|
||||
});
|
||||
}
|
||||
return driftedImports;
|
||||
}
|
||||
|
||||
function recipientEntry(
|
||||
expansion: DistributionListExpansionSnapshot,
|
||||
recipient: DistributionRecipientSnapshot,
|
||||
usedIds: Set<string>
|
||||
): JsonRecord {
|
||||
const usableChannels = recipient.channels.filter((candidate) => candidate.status === "usable");
|
||||
const preferredChannels = usableChannels.filter((candidate) => candidate.preferred);
|
||||
const selectedRoute = preferredChannels.length === 1
|
||||
? preferredChannels[0]
|
||||
: usableChannels.length === 1
|
||||
? usableChannels[0]
|
||||
: null;
|
||||
const email = selectedRoute?.channel === "email" ? selectedRoute.target.trim() : "";
|
||||
const fields = stringFields(recipient.attributes);
|
||||
const routeReason = selectedRoute
|
||||
? (selectedRoute.preferred ? "preferred_channel" : "single_usable_channel")
|
||||
: usableChannels.length > 1
|
||||
? "explicit_route_required"
|
||||
: "no_usable_channel";
|
||||
|
||||
return {
|
||||
id: uniqueRecipientId(recipient.recipient_key, usedIds),
|
||||
active: recipient.status === "usable" && Boolean(email),
|
||||
name: recipient.display_name,
|
||||
email,
|
||||
from: [],
|
||||
to: email ? [{ name: recipient.display_name, email }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
reply_to: [],
|
||||
merge_to: false,
|
||||
merge_cc: true,
|
||||
merge_bcc: true,
|
||||
merge_reply_to: true,
|
||||
fields,
|
||||
attachments: [],
|
||||
combine_attachments: true,
|
||||
distribution_source: {
|
||||
list_id: expansion.source.id,
|
||||
list_revision_id: expansion.source.revision_id,
|
||||
list_revision: expansion.source.revision,
|
||||
definition_hash: expansion.source.definition_hash,
|
||||
snapshot_id: expansion.snapshot_id ?? null,
|
||||
expansion_hash: expansion.expansion_hash,
|
||||
recipient_key: recipient.recipient_key,
|
||||
recipient_status: recipient.status,
|
||||
source_entry_ids: recipient.source_entry_ids,
|
||||
identity_id: recipient.identity_id ?? null,
|
||||
account_id: recipient.account_id ?? null,
|
||||
contact_id: recipient.contact_id ?? null,
|
||||
organization_unit_id: recipient.organization_unit_id ?? null,
|
||||
function_id: recipient.function_id ?? null,
|
||||
channels: recipient.channels,
|
||||
selected_route: selectedRoute,
|
||||
fallback_routes: selectedRoute
|
||||
? usableChannels.filter((candidate) => candidate.target_key !== selectedRoute.target_key)
|
||||
: [],
|
||||
route_reason: routeReason,
|
||||
explanations: recipient.explanations,
|
||||
attributes: recipient.attributes,
|
||||
provenance: recipient.provenance
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function distributionListImportProvenance(
|
||||
expansion: DistributionListExpansionSnapshot,
|
||||
mode: RecipientImportMode,
|
||||
fieldNames: Set<string>
|
||||
): RecipientImportProvenance {
|
||||
return {
|
||||
id: `recipient-import-distribution-${safeId(expansion.source.id)}-${Date.now().toString(36)}`,
|
||||
imported_at: new Date().toISOString(),
|
||||
mode,
|
||||
source_type: "distribution_list",
|
||||
source_id: expansion.source.id,
|
||||
source_label: expansion.source.name,
|
||||
source_revision: String(expansion.source.revision),
|
||||
source_provenance: {
|
||||
list_revision: expansion.source.revision,
|
||||
list_revision_id: expansion.source.revision_id,
|
||||
definition_hash: expansion.source.definition_hash,
|
||||
snapshot_id: expansion.snapshot_id,
|
||||
expansion_hash: expansion.expansion_hash,
|
||||
generated_at: expansion.generated_at,
|
||||
request: expansion.request,
|
||||
stale: expansion.stale,
|
||||
truncated: expansion.truncated,
|
||||
recipient_decisions: expansion.recipients.map((recipient) => ({
|
||||
recipient_key: recipient.recipient_key,
|
||||
source_entry_ids: recipient.source_entry_ids,
|
||||
channels: recipient.channels,
|
||||
explanations: recipient.explanations,
|
||||
provenance: recipient.provenance
|
||||
})),
|
||||
exclusions: expansion.excluded,
|
||||
diagnostics: expansion.diagnostics,
|
||||
provider_evidence: expansion.provider_evidence
|
||||
},
|
||||
filename: null,
|
||||
sheet_name: null,
|
||||
encoding: null,
|
||||
delimiter: null,
|
||||
header_rows: 0,
|
||||
quoted: null,
|
||||
value_separators: null,
|
||||
rows_total: expansion.recipients.length + expansion.excluded.length,
|
||||
valid_rows: expansion.recipients.length,
|
||||
invalid_rows: expansion.excluded.length,
|
||||
imported_rows: expansion.recipients.length,
|
||||
field_names_created: [...fieldNames].sort(),
|
||||
attachment_patterns: 0,
|
||||
mapping: []
|
||||
};
|
||||
}
|
||||
|
||||
function stringFields(value: Record<string, unknown>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, item]) => [key, scalarText(item)] as const)
|
||||
.filter(([, item]) => Boolean(item))
|
||||
);
|
||||
}
|
||||
|
||||
function scalarText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFieldDefinitions(value: unknown, names: string[]): JsonRecord[] {
|
||||
const existing = asArray(value).map(asRecord);
|
||||
const existingNames = new Set(existing.map((field) => text(field.name) || text(field.id)).filter(Boolean));
|
||||
const additions = names
|
||||
.filter((name) => !existingNames.has(name))
|
||||
.map((name) => ({
|
||||
name,
|
||||
label: name.replace(/[_-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}));
|
||||
return [...existing, ...additions];
|
||||
}
|
||||
|
||||
function uniqueRecipientId(value: string, usedIds: Set<string>): string {
|
||||
const base = `distribution-${safeId(value).slice(0, 70)}`;
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function safeId(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "recipient";
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function usableChannelSummary(channels: DistributionChannelCandidateSnapshot[]): string {
|
||||
const usable = channels.filter((candidate) => candidate.status === "usable");
|
||||
return usable.length ? usable.map((candidate) => candidate.channel).join(", ") : "No usable channel";
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
xlsxSheetsFromArrayBuffer,
|
||||
type RecipientColumnMapping
|
||||
} from "../src/features/campaigns/utils/bulkImport";
|
||||
import {
|
||||
distributionListDrift,
|
||||
materializeDistributionListExpansion,
|
||||
type DistributionListExpansionSnapshot
|
||||
} from "../src/features/campaigns/utils/distributionListImport";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -130,6 +135,108 @@ assert(attachments.length === 2, "valid row patterns become attachment rules");
|
||||
assert(attachments[0].base_path_id === "bp-1" && attachments[0].base_dir === "invoices", "attachment rules use the selected source");
|
||||
assert(attachments[0].file_filter === "${customer_id}.pdf", "field placeholders remain in imported patterns");
|
||||
|
||||
const distributionExpansion: DistributionListExpansionSnapshot = {
|
||||
source: {
|
||||
id: "list-1",
|
||||
tenant_id: "tenant-1",
|
||||
name: "Residents",
|
||||
revision_id: "revision-3",
|
||||
revision: 3,
|
||||
definition_hash: "definition-hash-3",
|
||||
definition_kind: "static",
|
||||
status: "active",
|
||||
entry_count: 2,
|
||||
read_only: false,
|
||||
stale: false,
|
||||
parameters: [],
|
||||
provenance: {},
|
||||
metadata: {}
|
||||
},
|
||||
request: { requested_channels: ["email", "postal"] },
|
||||
recipients: [
|
||||
{
|
||||
recipient_key: "contact:ada",
|
||||
display_name: "Ada Lovelace",
|
||||
status: "usable",
|
||||
channels: [
|
||||
{
|
||||
channel: "email",
|
||||
target: "ada@example.org",
|
||||
target_key: "email:ada@example.org",
|
||||
status: "usable",
|
||||
preferred: true,
|
||||
decision_provenance: { policy: "allow" }
|
||||
},
|
||||
{
|
||||
channel: "postal",
|
||||
target: "Example Street 1",
|
||||
target_key: "postal:ada",
|
||||
status: "usable",
|
||||
preferred: false,
|
||||
decision_provenance: { policy: "fallback" }
|
||||
}
|
||||
],
|
||||
source_entry_ids: ["entry-addresses"],
|
||||
explanations: [],
|
||||
attributes: { district: "north" },
|
||||
provenance: { provider: "addresses" }
|
||||
},
|
||||
{
|
||||
recipient_key: "contact:postal",
|
||||
display_name: "Postal only",
|
||||
status: "usable",
|
||||
channels: [
|
||||
{
|
||||
channel: "postal",
|
||||
target: "Example Street 2",
|
||||
target_key: "postal:only",
|
||||
status: "usable",
|
||||
preferred: true,
|
||||
decision_provenance: { policy: "allow" }
|
||||
}
|
||||
],
|
||||
source_entry_ids: ["entry-postal"],
|
||||
explanations: [],
|
||||
attributes: {},
|
||||
provenance: {}
|
||||
}
|
||||
],
|
||||
excluded: [
|
||||
{
|
||||
recipient_key: "contact:suppressed",
|
||||
display_name: "Suppressed",
|
||||
status: "suppressed",
|
||||
channels: [],
|
||||
source_entry_ids: ["entry-addresses"],
|
||||
explanations: [{ code: "opt_out", message: "Recipient opted out.", severity: "warning", provenance: {} }],
|
||||
attributes: {},
|
||||
provenance: {}
|
||||
}
|
||||
],
|
||||
diagnostics: [],
|
||||
provider_evidence: [],
|
||||
expansion_hash: "expansion-hash-3",
|
||||
generated_at: "2026-08-02T10:00:00Z",
|
||||
snapshot_id: "snapshot-3",
|
||||
stale: false,
|
||||
truncated: false
|
||||
};
|
||||
const distributionDraft = materializeDistributionListExpansion(draft, distributionExpansion, "replace");
|
||||
const distributionEntries = asRecord(distributionDraft.entries);
|
||||
const distributionInline = distributionEntries.inline as Record<string, unknown>[];
|
||||
const firstDistributionSource = asRecord(distributionInline[0].distribution_source);
|
||||
const secondDistributionSource = asRecord(distributionInline[1].distribution_source);
|
||||
const distributionImports = distributionEntries.imports as Record<string, unknown>[];
|
||||
|
||||
assert(distributionInline.length === 2, "all included Distribution List recipients are frozen into Campaign");
|
||||
assert(distributionInline[0].active === true && distributionInline[0].email === "ada@example.org", "one preferred route is selected without duplicating channels");
|
||||
assert(distributionInline[1].active === false, "postal-only recipients are retained but not sent through the mail-only path");
|
||||
assert(firstDistributionSource.snapshot_id === "snapshot-3" && firstDistributionSource.list_revision_id === "revision-3", "recipient rows retain snapshot and list revision evidence");
|
||||
assert((secondDistributionSource.source_entry_ids as string[])[0] === "entry-postal", "recipient rows retain source entry references");
|
||||
assert(distributionImports[0].source_type === "distribution_list", "Campaign stores Distribution List import provenance");
|
||||
assert((asRecord(distributionImports[0].source_provenance).exclusions as unknown[]).length === 1, "excluded recipients remain in immutable import evidence");
|
||||
assert(distributionListDrift(distributionEntries.imports, [{ id: "list-1", revision: 4, revision_id: "revision-4", definition_hash: "definition-hash-4" }]).length === 1, "list revision drift is detected without changing frozen recipients");
|
||||
|
||||
void runXlsxImportAssertions();
|
||||
|
||||
async function runXlsxImportAssertions(): Promise<void> {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"include": [
|
||||
"tests/import-utils.test.ts",
|
||||
"src/features/campaigns/utils/bulkImport.ts"
|
||||
"src/features/campaigns/utils/bulkImport.ts",
|
||||
"src/features/campaigns/utils/distributionListImport.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user