From b38597f2bef4b856f27d123e4d4bb97826febdea Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 11:53:35 +0200 Subject: [PATCH] Integrate Distribution Lists with Campaign recipients --- docs/CAMPAIGN_HANDBOOK.md | 23 ++ docs/RECIPIENT_ADDRESS_BOUNDARY.md | 8 + .../backend/documentation.py | 41 ++ src/govoplan_campaign/backend/manifest.py | 17 + .../backend/routes/campaigns.py | 147 +++++++ .../backend/schema/campaign.schema.json | 8 +- src/govoplan_campaign/backend/schemas.py | 126 ++++++ tests/test_partial_validation.py | 119 ++++++ webui/src/api/campaigns.ts | 155 +++++++ .../features/campaigns/RecipientDataPage.tsx | 80 ++++ .../DistributionListImportDialog.tsx | 389 ++++++++++++++++++ .../features/campaigns/utils/bulkImport.ts | 2 +- .../campaigns/utils/distributionListImport.ts | 330 +++++++++++++++ webui/tests/import-utils.test.ts | 107 +++++ webui/tsconfig.import-tests.json | 3 +- 15 files changed, 1552 insertions(+), 3 deletions(-) create mode 100644 webui/src/features/campaigns/recipients/DistributionListImportDialog.tsx create mode 100644 webui/src/features/campaigns/utils/distributionListImport.ts diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index 2275912..26f9801 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -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. diff --git a/docs/RECIPIENT_ADDRESS_BOUNDARY.md b/docs/RECIPIENT_ADDRESS_BOUNDARY.md index dd4ee68..2c412a8 100644 --- a/docs/RECIPIENT_ADDRESS_BOUNDARY.md +++ b/docs/RECIPIENT_ADDRESS_BOUNDARY.md @@ -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: diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index 1e421b9..03849aa 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -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", diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index f6d99d8..85632fe 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -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", diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py index eac8b3a..a26044b 100644 --- a/src/govoplan_campaign/backend/routes/campaigns.py +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -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, diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.json b/src/govoplan_campaign/backend/schema/campaign.schema.json index 0b15296..d9bd3a5 100644 --- a/src/govoplan_campaign/backend/schema/campaign.schema.json +++ b/src/govoplan_campaign/backend/schema/campaign.schema.json @@ -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": { diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index 774000b..1954d71 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -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 diff --git a/tests/test_partial_validation.py b/tests/test_partial_validation.py index c5c0ea7..c17dd7c 100644 --- a/tests/test_partial_validation.py +++ b/tests/test_partial_validation.py @@ -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: diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index c4e063d..c73fb08 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -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; + metadata: Record; +}; + +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; +}; + +export type CampaignDistributionExplanation = { + code: string; + message: string; + severity: "info" | "warning" | "error" | string; + provider?: string | null; + source?: CampaignDistributionSourceReference | null; + provenance: Record; +}; + +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; +}; + +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; + provenance: Record; +}; + +export type CampaignDistributionProviderEvidence = { + provider: string; + source: CampaignDistributionSourceReference; + actual_revision?: string | null; + actual_fingerprint?: string | null; + stale: boolean; + generated_at?: string | null; + details: Record; +}; + +export type CampaignDistributionListExpansion = { + source: CampaignDistributionListSource; + request: Record; + 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; + idempotency_key?: string | null; +}; + export type CampaignPostboxDirectoryEntry = { id: string; address: string; @@ -746,6 +867,40 @@ campaignId: string) return apiFetch(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`); } +export async function listCampaignRecipientDistributionLists( +settings: ApiSettings, +campaignId: string, +query = "") +: Promise { + const params = new URLSearchParams(); + params.set("limit", "250"); + if (query.trim()) params.set("query", query.trim()); + const suffix = params.size ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists${suffix}`); +} + +export async function previewCampaignRecipientDistributionList( +settings: ApiSettings, +campaignId: string, +payload: CampaignDistributionListExpansionInput) +: Promise { + return apiFetch(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 { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists/snapshot`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function getCampaignPostboxCatalog( settings: ApiSettings, campaignId: string) diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index 93557bc..99b783f 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -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([]); + const [distributionListImportOpen, setDistributionListImportOpen] = useState(false); + const [distributionListImportInitialId, setDistributionListImportInitialId] = useState(""); + const [distributionListsAvailable, setDistributionListsAvailable] = useState(false); + const [distributionListsLoading, setDistributionListsLoading] = useState(false); + const [distributionLists, setDistributionLists] = useState([]); const [recipientProfilesPage, setRecipientProfilesPage] = useState(1); const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10); const [recipientProfilesQuery, setRecipientProfilesQuery] = useState({ 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 = { @@ -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 + } + {(distributionListsAvailable || distributionListsLoading) && + } @@ -459,6 +508,27 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A } + {staleDistributionListImports.length > 0 && + +
+ Frozen Distribution List imports have changed or become unavailable. Campaign recipients remain unchanged until an explicit refresh. +
+ {staleDistributionListImports.map((item) => + + {item.sourceLabel}: {item.reason} + {item.currentRevision !== null && + + } + + )} +
+
+
+ } {!source.type &&
setAddressSourceImportOpen(false)} onImport={applyAddressSourceImport} /> + } + {distributionListImportOpen && + setDistributionListImportOpen(false)} + onImport={applyDistributionListImport} /> + } {recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] && = [ + { 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("append"); + const [requestedChannels, setRequestedChannels] = useState(channelOptions.map((item) => item.id)); + const [parameters, setParameters] = useState>({}); + const [preview, setPreview] = useState(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(() => [ + ...(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 ( + + + + + } + > +
+ setSourceQuery(event.target.value)} + /> + + + +
+ +
+
+ {filteredSources.map((source) => ( + + ))} + {sources.length > 0 && filteredSources.length === 0 && ( +
No Distribution Lists match the search.
+ )} +
+ +
+
+ Requested channels + {channelOptions.map((channel) => ( + toggleChannel(channel.id, checked)} + /> + ))} +
+ {selectedSource?.parameters.map((parameter) => ( + updateParameter(parameter, value)} + /> + ))} + +
+
+ + {error && {error}} + {loading && Resolving Distribution List...} + {!loading && sources.length === 0 && ( + + No Distribution Lists are available to this Campaign. + + )} + {preview && ( + <> +
+
List
{preview.source.name}
+
Revision
{preview.source.revision}
+
Included
{preview.recipients.length}
+
Excluded
{preview.excluded.length}
+
Providers
{preview.provider_evidence.length}
+
State
{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}
+
+ {preview.stale && ( + + At least one provider result is stale. Review its diagnostics before freezing this expansion. + + )} + {preview.truncated && ( + + The expansion reached a safety limit and cannot be frozen from this dialog. + + )} + {preview.diagnostics.map((diagnostic) => ( + + {diagnostic.message} + + ))} + + {previewRows.length > 100 && ( +

{previewRows.length - 100} more decisions are included in the frozen evidence.

+ )} + + )} +
+ ); +} + +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 ; + } + if (parameter.allowed_values.length > 0) { + return ( + + + + ); + } + const inputType = parameter.value_type === "date" + ? "date" + : parameter.value_type === "datetime" + ? "datetime-local" + : ["integer", "number"].includes(parameter.value_type) + ? "number" + : "text"; + return ( + + onChange(event.target.value)} + /> + + ); +} + +function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) { + const columns: DataGridColumn[] = [ + { 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 ( + `${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)}`; +} diff --git a/webui/src/features/campaigns/utils/bulkImport.ts b/webui/src/features/campaigns/utils/bulkImport.ts index 3432980..1c513ab 100644 --- a/webui/src/features/campaigns/utils/bulkImport.ts +++ b/webui/src/features/campaigns/utils/bulkImport.ts @@ -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"; diff --git a/webui/src/features/campaigns/utils/distributionListImport.ts b/webui/src/features/campaigns/utils/distributionListImport.ts new file mode 100644 index 0000000..56b0edc --- /dev/null +++ b/webui/src/features/campaigns/utils/distributionListImport.ts @@ -0,0 +1,330 @@ +import type { RecipientImportMode, RecipientImportProvenance } from "./bulkImport"; + +type JsonRecord = Record; + +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; +}; + +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; + provenance: Record; +}; + +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; + metadata?: Record; + }; + request: Record; + 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(); + 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 +): 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 +): 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): Record { + 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 { + 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"; +} diff --git a/webui/tests/import-utils.test.ts b/webui/tests/import-utils.test.ts index ca9082d..f79fc0d 100644 --- a/webui/tests/import-utils.test.ts +++ b/webui/tests/import-utils.test.ts @@ -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[]; +const firstDistributionSource = asRecord(distributionInline[0].distribution_source); +const secondDistributionSource = asRecord(distributionInline[1].distribution_source); +const distributionImports = distributionEntries.imports as Record[]; + +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 { diff --git a/webui/tsconfig.import-tests.json b/webui/tsconfig.import-tests.json index d437524..57547c0 100644 --- a/webui/tsconfig.import-tests.json +++ b/webui/tsconfig.import-tests.json @@ -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" ] }