Integrate Distribution Lists with Campaign recipients

This commit is contained in:
2026-08-02 11:53:35 +02:00
parent 4eeba62bbc
commit b38597f2be
15 changed files with 1552 additions and 3 deletions
+23
View File
@@ -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.
+8
View File
@@ -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",
+17
View File
@@ -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": {
+126
View File
@@ -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
+119
View File
@@ -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:
+155
View File
@@ -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";
}
+107
View File
@@ -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> {
+2 -1
View File
@@ -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"
]
}