Integrate Distribution Lists with Campaign recipients
This commit is contained in:
@@ -195,6 +195,127 @@ export type CampaignRecipientAddressSourcesResponse = {
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
};
|
||||
|
||||
export type CampaignDistributionListParameter = {
|
||||
key: string;
|
||||
value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "string_list";
|
||||
label?: string | null;
|
||||
required: boolean;
|
||||
default?: unknown;
|
||||
allowed_values: unknown[];
|
||||
minimum?: number | null;
|
||||
maximum?: number | null;
|
||||
pattern?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListSource = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
revision_id: string;
|
||||
revision: number;
|
||||
definition_hash: string;
|
||||
definition_kind: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
entry_count: number;
|
||||
read_only: boolean;
|
||||
stale: boolean;
|
||||
parameters: CampaignDistributionListParameter[];
|
||||
updated_at?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListSourcesResponse = {
|
||||
available: boolean;
|
||||
expand_available: boolean;
|
||||
sources: CampaignDistributionListSource[];
|
||||
};
|
||||
|
||||
export type CampaignDistributionSourceReference = {
|
||||
provider: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
revision?: string | null;
|
||||
fingerprint?: string | null;
|
||||
label?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionExplanation = {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: "info" | "warning" | "error" | string;
|
||||
provider?: string | null;
|
||||
source?: CampaignDistributionSourceReference | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionChannelCandidate = {
|
||||
channel: "email" | "postal" | "internal_mail" | "portal" | string;
|
||||
target: string;
|
||||
target_key: string;
|
||||
status: string;
|
||||
contact_point_id?: string | null;
|
||||
locale?: string | null;
|
||||
preferred: boolean;
|
||||
reason_code?: string | null;
|
||||
explanation?: string | null;
|
||||
source?: CampaignDistributionSourceReference | null;
|
||||
decision_provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionRecipient = {
|
||||
recipient_key: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
channels: CampaignDistributionChannelCandidate[];
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
contact_id?: string | null;
|
||||
organization_unit_id?: string | null;
|
||||
function_id?: string | null;
|
||||
source_entry_ids: string[];
|
||||
explanations: CampaignDistributionExplanation[];
|
||||
attributes: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionProviderEvidence = {
|
||||
provider: string;
|
||||
source: CampaignDistributionSourceReference;
|
||||
actual_revision?: string | null;
|
||||
actual_fingerprint?: string | null;
|
||||
stale: boolean;
|
||||
generated_at?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListExpansion = {
|
||||
source: CampaignDistributionListSource;
|
||||
request: Record<string, unknown>;
|
||||
recipients: CampaignDistributionRecipient[];
|
||||
excluded: CampaignDistributionRecipient[];
|
||||
diagnostics: CampaignDistributionExplanation[];
|
||||
provider_evidence: CampaignDistributionProviderEvidence[];
|
||||
expansion_hash: string;
|
||||
generated_at?: string | null;
|
||||
snapshot_id?: string | null;
|
||||
stale: boolean;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type CampaignDistributionListExpansionInput = {
|
||||
list_id: string;
|
||||
revision?: number | null;
|
||||
effective_at?: string | null;
|
||||
purpose?: string;
|
||||
requested_channels: Array<"email" | "postal" | "internal_mail" | "portal">;
|
||||
parameters: Record<string, unknown>;
|
||||
idempotency_key?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignPostboxDirectoryEntry = {
|
||||
id: string;
|
||||
address: string;
|
||||
@@ -746,6 +867,40 @@ campaignId: string)
|
||||
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
|
||||
}
|
||||
|
||||
export async function listCampaignRecipientDistributionLists(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
query = "")
|
||||
: Promise<CampaignDistributionListSourcesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "250");
|
||||
if (query.trim()) params.set("query", query.trim());
|
||||
const suffix = params.size ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignDistributionListSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists${suffix}`);
|
||||
}
|
||||
|
||||
export async function previewCampaignRecipientDistributionList(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignDistributionListExpansionInput)
|
||||
: Promise<CampaignDistributionListExpansion> {
|
||||
return apiFetch<CampaignDistributionListExpansion>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists/preview`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function snapshotCampaignRecipientDistributionList(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignDistributionListExpansionInput)
|
||||
: Promise<CampaignDistributionListExpansion> {
|
||||
return apiFetch<CampaignDistributionListExpansion>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists/snapshot`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaignPostboxCatalog(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
getCampaignPostboxCatalog,
|
||||
listCampaignRecipientAddressSources,
|
||||
listCampaignRecipientDistributionLists,
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
type CampaignDistributionListExpansion,
|
||||
type CampaignDistributionListSource,
|
||||
type CampaignPostboxCatalog,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot } from
|
||||
@@ -38,6 +41,11 @@ import {
|
||||
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
|
||||
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
|
||||
import {
|
||||
distributionListDrift,
|
||||
materializeDistributionListExpansion
|
||||
} from "./utils/distributionListImport";
|
||||
import {
|
||||
AddressHeaderControl,
|
||||
HeaderAddressEditorDialog,
|
||||
@@ -71,6 +79,11 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
|
||||
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
|
||||
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
|
||||
const [distributionListImportOpen, setDistributionListImportOpen] = useState(false);
|
||||
const [distributionListImportInitialId, setDistributionListImportInitialId] = useState("");
|
||||
const [distributionListsAvailable, setDistributionListsAvailable] = useState(false);
|
||||
const [distributionListsLoading, setDistributionListsLoading] = useState(false);
|
||||
const [distributionLists, setDistributionLists] = useState<CampaignDistributionListSource[]>([]);
|
||||
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
||||
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
||||
const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
|
||||
@@ -130,6 +143,10 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
}).
|
||||
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
|
||||
}, [addressSourceRevisionById, entries.imports]);
|
||||
const staleDistributionListImports = useMemo(
|
||||
() => distributionListDrift(entries.imports, distributionLists),
|
||||
[distributionLists, entries.imports]
|
||||
);
|
||||
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
|
||||
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
@@ -159,6 +176,26 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDistributionListsLoading(true);
|
||||
void listCampaignRecipientDistributionLists(settings, campaignId)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setDistributionListsAvailable(response.available && response.expand_available);
|
||||
setDistributionLists(response.available ? response.sources ?? [] : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setDistributionListsAvailable(false);
|
||||
setDistributionLists([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDistributionListsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postboxModuleInstalled) {
|
||||
setPostboxCatalog({
|
||||
@@ -322,6 +359,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
setAddressSourceImportOpen(false);
|
||||
}
|
||||
|
||||
function applyDistributionListImport(snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) {
|
||||
if (locked || !draft) return;
|
||||
setDraft(materializeDistributionListExpansion(draft, snapshot, mode));
|
||||
markDirty();
|
||||
setDistributionListImportOpen(false);
|
||||
}
|
||||
|
||||
function saveHeaderAddresses(values: HeaderAddressValues) {
|
||||
const nextRecipients = { ...recipientsSection };
|
||||
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
|
||||
@@ -435,6 +479,11 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
|
||||
Import address book/list
|
||||
</Button>
|
||||
}
|
||||
{(distributionListsAvailable || distributionListsLoading) &&
|
||||
<Button disabled={locked || distributionListsLoading || !distributionListsAvailable} onClick={() => {setDistributionListImportInitialId("");setDistributionListImportOpen(true);}}>
|
||||
Import Distribution List
|
||||
</Button>
|
||||
}
|
||||
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
</div>
|
||||
@@ -459,6 +508,27 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
</div>
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{staleDistributionListImports.length > 0 &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
<div className="stale-address-import-warning">
|
||||
<span>Frozen Distribution List imports have changed or become unavailable. Campaign recipients remain unchanged until an explicit refresh.</span>
|
||||
<div className="button-row compact-actions">
|
||||
{staleDistributionListImports.map((item) =>
|
||||
<span key={`${item.sourceId}:${item.importedRevision ?? "unknown"}`} className="button-row compact-actions">
|
||||
<span>{item.sourceLabel}: {item.reason}</span>
|
||||
{item.currentRevision !== null &&
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {setDistributionListImportInitialId(item.sourceId);setDistributionListImportOpen(true);}}>
|
||||
Refresh {item.sourceLabel}
|
||||
</Button>
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{!source.type &&
|
||||
<div className="admin-table-surface recipient-profiles-table-surface">
|
||||
<DataGrid
|
||||
@@ -527,6 +597,16 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
onCancel={() => setAddressSourceImportOpen(false)}
|
||||
onImport={applyAddressSourceImport} />
|
||||
|
||||
}
|
||||
{distributionListImportOpen &&
|
||||
<DistributionListImportDialog
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
sources={distributionLists}
|
||||
initialSourceId={distributionListImportInitialId}
|
||||
onCancel={() => setDistributionListImportOpen(false)}
|
||||
onImport={applyDistributionListImport} />
|
||||
|
||||
}
|
||||
{recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] &&
|
||||
<RecipientAddressEditorDialog
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
ToggleSwitch,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
previewCampaignRecipientDistributionList,
|
||||
snapshotCampaignRecipientDistributionList,
|
||||
type CampaignDistributionListExpansion,
|
||||
type CampaignDistributionListExpansionInput,
|
||||
type CampaignDistributionListParameter,
|
||||
type CampaignDistributionListSource,
|
||||
type CampaignDistributionRecipient
|
||||
} from "../../../api/campaigns";
|
||||
import type { RecipientImportMode } from "../utils/bulkImport";
|
||||
import { usableChannelSummary } from "../utils/distributionListImport";
|
||||
|
||||
type RequestedChannel = "email" | "postal" | "internal_mail" | "portal";
|
||||
type PreviewRow = CampaignDistributionRecipient & { included: boolean };
|
||||
|
||||
const channelOptions: Array<{id: RequestedChannel;label: string;}> = [
|
||||
{ id: "email", label: "Email" },
|
||||
{ id: "postal", label: "Postal" },
|
||||
{ id: "internal_mail", label: "Internal mail" },
|
||||
{ id: "portal", label: "Portal" }
|
||||
];
|
||||
|
||||
export default function DistributionListImportDialog({
|
||||
settings,
|
||||
campaignId,
|
||||
sources,
|
||||
initialSourceId = "",
|
||||
onCancel,
|
||||
onImport
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignDistributionListSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) => void;
|
||||
}) {
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.id || "");
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [requestedChannels, setRequestedChannels] = useState<RequestedChannel[]>(channelOptions.map((item) => item.id));
|
||||
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
||||
const [preview, setPreview] = useState<CampaignDistributionListExpansion | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = sourceQuery.trim().toLowerCase();
|
||||
if (!query) return sources;
|
||||
return sources.filter((source) => [source.name, source.description, source.definition_kind]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query)));
|
||||
}, [sourceQuery, sources]);
|
||||
const previewRows = useMemo<PreviewRow[]>(() => [
|
||||
...(preview?.recipients ?? []).map((recipient) => ({ ...recipient, included: true })),
|
||||
...(preview?.excluded ?? []).map((recipient) => ({ ...recipient, included: false }))
|
||||
], [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.id === selectedSourceId)) return;
|
||||
setSelectedSourceId(filteredSources[0]?.id ?? "");
|
||||
}, [filteredSources, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSource) {
|
||||
setParameters({});
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setParameters(Object.fromEntries(
|
||||
selectedSource.parameters
|
||||
.filter((parameter) => parameter.default !== null && parameter.default !== undefined)
|
||||
.map((parameter) => [parameter.key, parameter.default])
|
||||
));
|
||||
setPreview(null);
|
||||
setError("");
|
||||
}, [selectedSource?.id, selectedSource?.revision_id]);
|
||||
|
||||
function toggleChannel(channel: RequestedChannel, checked: boolean) {
|
||||
setRequestedChannels((current) => checked
|
||||
? [...new Set([...current, channel])]
|
||||
: current.filter((item) => item !== channel));
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
function updateParameter(parameter: CampaignDistributionListParameter, value: unknown) {
|
||||
setParameters((current) => ({ ...current, [parameter.key]: normalizeParameterValue(parameter, value) }));
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
function requestPayload(idempotencyKey?: string): CampaignDistributionListExpansionInput {
|
||||
return {
|
||||
list_id: selectedSourceId,
|
||||
revision: selectedSource?.revision ?? null,
|
||||
purpose: "campaign_delivery",
|
||||
requested_channels: requestedChannels,
|
||||
parameters,
|
||||
idempotency_key: idempotencyKey ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
if (!selectedSourceId || requestedChannels.length === 0) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setPreview(await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload()));
|
||||
} catch (reason) {
|
||||
setPreview(null);
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function freezeAndImport() {
|
||||
if (!preview || !selectedSourceId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const idempotencyKey = `campaign:${campaignId}:distribution:${selectedSourceId}:${randomId()}`;
|
||||
const snapshot = await snapshotCampaignRecipientDistributionList(
|
||||
settings,
|
||||
campaignId,
|
||||
requestPayload(idempotencyKey)
|
||||
);
|
||||
onImport(snapshot, mode);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Import a Distribution List"
|
||||
className="recipient-import-modal"
|
||||
bodyClassName="recipient-import-body"
|
||||
closeDisabled={loading}
|
||||
closeOnBackdrop={!loading}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated}
|
||||
onClick={() => void freezeAndImport()}
|
||||
>
|
||||
Freeze and import
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="address-source-import-controls">
|
||||
<input
|
||||
type="search"
|
||||
value={sourceQuery}
|
||||
disabled={loading || sources.length === 0}
|
||||
placeholder="Search Distribution Lists"
|
||||
aria-label="Search Distribution Lists"
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
/>
|
||||
<FormField label="Import mode">
|
||||
<SegmentedControl
|
||||
ariaLabel="Distribution List import mode"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
size="content"
|
||||
width="inline"
|
||||
disabled={loading}
|
||||
options={[
|
||||
{ id: "append", label: "Append" },
|
||||
{ id: "replace", label: "Replace" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<div className="address-source-picker" role="radiogroup" aria-label="Distribution List">
|
||||
{filteredSources.map((source) => (
|
||||
<button
|
||||
type="button"
|
||||
key={source.id}
|
||||
className={`address-source-option ${source.id === selectedSourceId ? "is-selected" : ""}`}
|
||||
disabled={loading}
|
||||
role="radio"
|
||||
aria-checked={source.id === selectedSourceId}
|
||||
onClick={() => setSelectedSourceId(source.id)}
|
||||
>
|
||||
<span className="address-source-option-main">
|
||||
<strong>{source.name}</strong>
|
||||
<span>{source.definition_kind} · revision {source.revision}</span>
|
||||
</span>
|
||||
<span className="address-source-option-meta">
|
||||
<span>{source.entry_count} entries</span>
|
||||
{source.stale && <span>Stale source</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{sources.length > 0 && filteredSources.length === 0 && (
|
||||
<div className="empty-state compact-empty">No Distribution Lists match the search.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="distribution-list-import-settings">
|
||||
<fieldset className="form-section compact-form-section">
|
||||
<legend>Requested channels</legend>
|
||||
{channelOptions.map((channel) => (
|
||||
<ToggleSwitch
|
||||
key={channel.id}
|
||||
label={channel.label}
|
||||
checked={requestedChannels.includes(channel.id)}
|
||||
disabled={loading}
|
||||
onChange={(checked) => toggleChannel(channel.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
{selectedSource?.parameters.map((parameter) => (
|
||||
<DistributionParameterField
|
||||
key={parameter.key}
|
||||
parameter={parameter}
|
||||
value={parameters[parameter.key]}
|
||||
disabled={loading}
|
||||
onChange={(value) => updateParameter(parameter, value)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || !selectedSourceId || requestedChannels.length === 0}
|
||||
onClick={() => void loadPreview()}
|
||||
>
|
||||
{preview ? "Refresh preview" : "Preview expansion"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Resolving Distribution List...</DismissibleAlert>}
|
||||
{!loading && sources.length === 0 && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
No Distribution Lists are available to this Campaign.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div><dt>List</dt><dd>{preview.source.name}</dd></div>
|
||||
<div><dt>Revision</dt><dd>{preview.source.revision}</dd></div>
|
||||
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
|
||||
<div><dt>Excluded</dt><dd>{preview.excluded.length}</dd></div>
|
||||
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
|
||||
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
|
||||
</dl>
|
||||
{preview.stale && (
|
||||
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||
At least one provider result is stale. Review its diagnostics before freezing this expansion.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview.truncated && (
|
||||
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||
The expansion reached a safety limit and cannot be frozen from this dialog.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview.diagnostics.map((diagnostic) => (
|
||||
<DismissibleAlert
|
||||
key={`${diagnostic.code}:${diagnostic.message}`}
|
||||
tone={diagnostic.severity === "error" ? "danger" : diagnostic.severity === "warning" ? "warning" : "info"}
|
||||
compact
|
||||
dismissible={false}
|
||||
>
|
||||
{diagnostic.message}
|
||||
</DismissibleAlert>
|
||||
))}
|
||||
<DistributionPreviewGrid rows={previewRows.slice(0, 100)} />
|
||||
{previewRows.length > 100 && (
|
||||
<p className="muted small-note">{previewRows.length - 100} more decisions are included in the frozen evidence.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionParameterField({
|
||||
parameter,
|
||||
value,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
parameter: CampaignDistributionListParameter;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const label = parameter.label || parameter.key;
|
||||
if (parameter.value_type === "boolean") {
|
||||
return <ToggleSwitch label={label} checked={Boolean(value)} disabled={disabled} onChange={onChange} />;
|
||||
}
|
||||
if (parameter.allowed_values.length > 0) {
|
||||
return (
|
||||
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
||||
<select value={scalarInputValue(value)} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
||||
{!parameter.required && <option value="">Any</option>}
|
||||
{parameter.allowed_values.map((option) => (
|
||||
<option key={String(option)} value={String(option)}>{String(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
const inputType = parameter.value_type === "date"
|
||||
? "date"
|
||||
: parameter.value_type === "datetime"
|
||||
? "datetime-local"
|
||||
: ["integer", "number"].includes(parameter.value_type)
|
||||
? "number"
|
||||
: "text";
|
||||
return (
|
||||
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={scalarInputValue(value)}
|
||||
disabled={disabled}
|
||||
min={parameter.minimum ?? undefined}
|
||||
max={parameter.maximum ?? undefined}
|
||||
pattern={parameter.pattern ?? undefined}
|
||||
placeholder={parameter.value_type === "string_list" ? "Value 1, Value 2" : undefined}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) {
|
||||
const columns: DataGridColumn<PreviewRow>[] = [
|
||||
{ id: "name", header: "Recipient", width: "minmax(180px, 1fr)", value: (row) => row.display_name || row.recipient_key },
|
||||
{ id: "result", header: "Decision", width: 120, value: (row) => row.included ? "Included" : row.status },
|
||||
{ id: "channels", header: "Usable channels", width: "minmax(160px, 0.8fr)", value: (row) => usableChannelSummary(row.channels) },
|
||||
{ id: "source", header: "Source entries", width: "minmax(160px, 0.8fr)", value: (row) => row.source_entry_ids.join(", ") },
|
||||
{ id: "reason", header: "Explanation", width: "minmax(220px, 1.2fr)", value: (row) => row.explanations.map((item) => item.message).join(" · ") }
|
||||
];
|
||||
return (
|
||||
<DataGrid
|
||||
id="campaign-distribution-list-preview"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => `${row.included ? "included" : "excluded"}:${row.recipient_key}`}
|
||||
emptyText="No recipients resolved from this Distribution List."
|
||||
className="recipient-table-wrap"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeParameterValue(parameter: CampaignDistributionListParameter, value: unknown): unknown {
|
||||
if (value === "" || value === null || value === undefined) return null;
|
||||
if (parameter.value_type === "integer") return Number.parseInt(String(value), 10);
|
||||
if (parameter.value_type === "number") return Number(String(value));
|
||||
if (parameter.value_type === "string_list") return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
||||
return value;
|
||||
}
|
||||
|
||||
function scalarInputValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export type CsvParseOptions = {
|
||||
quoted: boolean;
|
||||
};
|
||||
|
||||
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses";
|
||||
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses" | "distribution_list";
|
||||
|
||||
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { RecipientImportMode, RecipientImportProvenance } from "./bulkImport";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type DistributionChannelCandidateSnapshot = {
|
||||
channel: string;
|
||||
target: string;
|
||||
target_key: string;
|
||||
status: string;
|
||||
preferred: boolean;
|
||||
contact_point_id?: string | null;
|
||||
locale?: string | null;
|
||||
reason_code?: string | null;
|
||||
explanation?: string | null;
|
||||
source?: unknown;
|
||||
decision_provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DistributionRecipientSnapshot = {
|
||||
recipient_key: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
channels: DistributionChannelCandidateSnapshot[];
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
contact_id?: string | null;
|
||||
organization_unit_id?: string | null;
|
||||
function_id?: string | null;
|
||||
source_entry_ids: string[];
|
||||
explanations: unknown[];
|
||||
attributes: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DistributionListExpansionSnapshot = {
|
||||
source: {
|
||||
id: string;
|
||||
name: string;
|
||||
revision_id: string;
|
||||
revision: number;
|
||||
definition_hash: string;
|
||||
tenant_id?: string;
|
||||
definition_kind?: string;
|
||||
status?: string;
|
||||
entry_count?: number;
|
||||
read_only?: boolean;
|
||||
stale?: boolean;
|
||||
parameters?: unknown[];
|
||||
provenance?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
request: Record<string, unknown>;
|
||||
recipients: DistributionRecipientSnapshot[];
|
||||
excluded: DistributionRecipientSnapshot[];
|
||||
diagnostics: unknown[];
|
||||
provider_evidence: unknown[];
|
||||
expansion_hash: string;
|
||||
generated_at?: string | null;
|
||||
snapshot_id?: string | null;
|
||||
stale: boolean;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export function materializeDistributionListExpansion(
|
||||
draft: JsonRecord,
|
||||
expansion: DistributionListExpansionSnapshot,
|
||||
mode: RecipientImportMode
|
||||
): JsonRecord {
|
||||
const currentEntries = asRecord(draft.entries);
|
||||
const existingEntries = asArray(currentEntries.inline).map(asRecord);
|
||||
const usedIds = new Set(existingEntries.map((entry) => text(entry.id)).filter(Boolean));
|
||||
const fieldNames = new Set<string>();
|
||||
const importedEntries = expansion.recipients.map((recipient) => {
|
||||
const entry = recipientEntry(expansion, recipient, usedIds);
|
||||
Object.keys(asRecord(entry.fields)).forEach((name) => fieldNames.add(name));
|
||||
return entry;
|
||||
});
|
||||
const provenance = distributionListImportProvenance(expansion, mode, fieldNames);
|
||||
const previousImports = asArray(currentEntries.imports).map(asRecord);
|
||||
|
||||
return {
|
||||
...draft,
|
||||
fields: mergeFieldDefinitions(draft.fields, [...fieldNames]),
|
||||
entries: {
|
||||
...currentEntries,
|
||||
inline: mode === "append" ? [...existingEntries, ...importedEntries] : importedEntries,
|
||||
imports: [...previousImports, provenance]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function distributionListDrift(
|
||||
imports: unknown,
|
||||
sources: Array<{id: string;revision: number;revision_id: string;definition_hash: string;}>
|
||||
): Array<{
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
importedRevision: number | null;
|
||||
currentRevision: number | null;
|
||||
reason: string;
|
||||
}> {
|
||||
const currentById = new Map(sources.map((source) => [source.id, source]));
|
||||
const driftedImports: Array<{
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
importedRevision: number | null;
|
||||
currentRevision: number | null;
|
||||
reason: string;
|
||||
}> = [];
|
||||
for (const item of asArray(imports).map(asRecord).filter((record) => record.source_type === "distribution_list")) {
|
||||
const sourceId = text(item.source_id);
|
||||
const current = currentById.get(sourceId);
|
||||
if (!sourceId) continue;
|
||||
const sourceProvenance = asRecord(item.source_provenance);
|
||||
const importedRevision = numberOrNull(sourceProvenance.list_revision);
|
||||
const importedRevisionId = text(sourceProvenance.list_revision_id);
|
||||
const importedHash = text(sourceProvenance.definition_hash);
|
||||
if (!current) {
|
||||
driftedImports.push({
|
||||
sourceId,
|
||||
sourceLabel: text(item.source_label) || sourceId,
|
||||
importedRevision,
|
||||
currentRevision: null,
|
||||
reason: "The source is no longer visible or available; the frozen Campaign snapshot is unchanged."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const drifted = (
|
||||
importedRevision !== current.revision ||
|
||||
(importedRevisionId && importedRevisionId !== current.revision_id) ||
|
||||
(importedHash && importedHash !== current.definition_hash)
|
||||
);
|
||||
if (!drifted) continue;
|
||||
driftedImports.push({
|
||||
sourceId,
|
||||
sourceLabel: text(item.source_label) || sourceId,
|
||||
importedRevision,
|
||||
currentRevision: current.revision,
|
||||
reason: `Frozen revision ${importedRevision ?? "unknown"}; current revision ${current.revision}.`
|
||||
});
|
||||
}
|
||||
return driftedImports;
|
||||
}
|
||||
|
||||
function recipientEntry(
|
||||
expansion: DistributionListExpansionSnapshot,
|
||||
recipient: DistributionRecipientSnapshot,
|
||||
usedIds: Set<string>
|
||||
): JsonRecord {
|
||||
const usableChannels = recipient.channels.filter((candidate) => candidate.status === "usable");
|
||||
const preferredChannels = usableChannels.filter((candidate) => candidate.preferred);
|
||||
const selectedRoute = preferredChannels.length === 1
|
||||
? preferredChannels[0]
|
||||
: usableChannels.length === 1
|
||||
? usableChannels[0]
|
||||
: null;
|
||||
const email = selectedRoute?.channel === "email" ? selectedRoute.target.trim() : "";
|
||||
const fields = stringFields(recipient.attributes);
|
||||
const routeReason = selectedRoute
|
||||
? (selectedRoute.preferred ? "preferred_channel" : "single_usable_channel")
|
||||
: usableChannels.length > 1
|
||||
? "explicit_route_required"
|
||||
: "no_usable_channel";
|
||||
|
||||
return {
|
||||
id: uniqueRecipientId(recipient.recipient_key, usedIds),
|
||||
active: recipient.status === "usable" && Boolean(email),
|
||||
name: recipient.display_name,
|
||||
email,
|
||||
from: [],
|
||||
to: email ? [{ name: recipient.display_name, email }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
reply_to: [],
|
||||
merge_to: false,
|
||||
merge_cc: true,
|
||||
merge_bcc: true,
|
||||
merge_reply_to: true,
|
||||
fields,
|
||||
attachments: [],
|
||||
combine_attachments: true,
|
||||
distribution_source: {
|
||||
list_id: expansion.source.id,
|
||||
list_revision_id: expansion.source.revision_id,
|
||||
list_revision: expansion.source.revision,
|
||||
definition_hash: expansion.source.definition_hash,
|
||||
snapshot_id: expansion.snapshot_id ?? null,
|
||||
expansion_hash: expansion.expansion_hash,
|
||||
recipient_key: recipient.recipient_key,
|
||||
recipient_status: recipient.status,
|
||||
source_entry_ids: recipient.source_entry_ids,
|
||||
identity_id: recipient.identity_id ?? null,
|
||||
account_id: recipient.account_id ?? null,
|
||||
contact_id: recipient.contact_id ?? null,
|
||||
organization_unit_id: recipient.organization_unit_id ?? null,
|
||||
function_id: recipient.function_id ?? null,
|
||||
channels: recipient.channels,
|
||||
selected_route: selectedRoute,
|
||||
fallback_routes: selectedRoute
|
||||
? usableChannels.filter((candidate) => candidate.target_key !== selectedRoute.target_key)
|
||||
: [],
|
||||
route_reason: routeReason,
|
||||
explanations: recipient.explanations,
|
||||
attributes: recipient.attributes,
|
||||
provenance: recipient.provenance
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function distributionListImportProvenance(
|
||||
expansion: DistributionListExpansionSnapshot,
|
||||
mode: RecipientImportMode,
|
||||
fieldNames: Set<string>
|
||||
): RecipientImportProvenance {
|
||||
return {
|
||||
id: `recipient-import-distribution-${safeId(expansion.source.id)}-${Date.now().toString(36)}`,
|
||||
imported_at: new Date().toISOString(),
|
||||
mode,
|
||||
source_type: "distribution_list",
|
||||
source_id: expansion.source.id,
|
||||
source_label: expansion.source.name,
|
||||
source_revision: String(expansion.source.revision),
|
||||
source_provenance: {
|
||||
list_revision: expansion.source.revision,
|
||||
list_revision_id: expansion.source.revision_id,
|
||||
definition_hash: expansion.source.definition_hash,
|
||||
snapshot_id: expansion.snapshot_id,
|
||||
expansion_hash: expansion.expansion_hash,
|
||||
generated_at: expansion.generated_at,
|
||||
request: expansion.request,
|
||||
stale: expansion.stale,
|
||||
truncated: expansion.truncated,
|
||||
recipient_decisions: expansion.recipients.map((recipient) => ({
|
||||
recipient_key: recipient.recipient_key,
|
||||
source_entry_ids: recipient.source_entry_ids,
|
||||
channels: recipient.channels,
|
||||
explanations: recipient.explanations,
|
||||
provenance: recipient.provenance
|
||||
})),
|
||||
exclusions: expansion.excluded,
|
||||
diagnostics: expansion.diagnostics,
|
||||
provider_evidence: expansion.provider_evidence
|
||||
},
|
||||
filename: null,
|
||||
sheet_name: null,
|
||||
encoding: null,
|
||||
delimiter: null,
|
||||
header_rows: 0,
|
||||
quoted: null,
|
||||
value_separators: null,
|
||||
rows_total: expansion.recipients.length + expansion.excluded.length,
|
||||
valid_rows: expansion.recipients.length,
|
||||
invalid_rows: expansion.excluded.length,
|
||||
imported_rows: expansion.recipients.length,
|
||||
field_names_created: [...fieldNames].sort(),
|
||||
attachment_patterns: 0,
|
||||
mapping: []
|
||||
};
|
||||
}
|
||||
|
||||
function stringFields(value: Record<string, unknown>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, item]) => [key, scalarText(item)] as const)
|
||||
.filter(([, item]) => Boolean(item))
|
||||
);
|
||||
}
|
||||
|
||||
function scalarText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFieldDefinitions(value: unknown, names: string[]): JsonRecord[] {
|
||||
const existing = asArray(value).map(asRecord);
|
||||
const existingNames = new Set(existing.map((field) => text(field.name) || text(field.id)).filter(Boolean));
|
||||
const additions = names
|
||||
.filter((name) => !existingNames.has(name))
|
||||
.map((name) => ({
|
||||
name,
|
||||
label: name.replace(/[_-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}));
|
||||
return [...existing, ...additions];
|
||||
}
|
||||
|
||||
function uniqueRecipientId(value: string, usedIds: Set<string>): string {
|
||||
const base = `distribution-${safeId(value).slice(0, 70)}`;
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function safeId(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "recipient";
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function usableChannelSummary(channels: DistributionChannelCandidateSnapshot[]): string {
|
||||
const usable = channels.filter((candidate) => candidate.status === "usable");
|
||||
return usable.length ? usable.map((candidate) => candidate.channel).join(", ") : "No usable channel";
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
xlsxSheetsFromArrayBuffer,
|
||||
type RecipientColumnMapping
|
||||
} from "../src/features/campaigns/utils/bulkImport";
|
||||
import {
|
||||
distributionListDrift,
|
||||
materializeDistributionListExpansion,
|
||||
type DistributionListExpansionSnapshot
|
||||
} from "../src/features/campaigns/utils/distributionListImport";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -130,6 +135,108 @@ assert(attachments.length === 2, "valid row patterns become attachment rules");
|
||||
assert(attachments[0].base_path_id === "bp-1" && attachments[0].base_dir === "invoices", "attachment rules use the selected source");
|
||||
assert(attachments[0].file_filter === "${customer_id}.pdf", "field placeholders remain in imported patterns");
|
||||
|
||||
const distributionExpansion: DistributionListExpansionSnapshot = {
|
||||
source: {
|
||||
id: "list-1",
|
||||
tenant_id: "tenant-1",
|
||||
name: "Residents",
|
||||
revision_id: "revision-3",
|
||||
revision: 3,
|
||||
definition_hash: "definition-hash-3",
|
||||
definition_kind: "static",
|
||||
status: "active",
|
||||
entry_count: 2,
|
||||
read_only: false,
|
||||
stale: false,
|
||||
parameters: [],
|
||||
provenance: {},
|
||||
metadata: {}
|
||||
},
|
||||
request: { requested_channels: ["email", "postal"] },
|
||||
recipients: [
|
||||
{
|
||||
recipient_key: "contact:ada",
|
||||
display_name: "Ada Lovelace",
|
||||
status: "usable",
|
||||
channels: [
|
||||
{
|
||||
channel: "email",
|
||||
target: "ada@example.org",
|
||||
target_key: "email:ada@example.org",
|
||||
status: "usable",
|
||||
preferred: true,
|
||||
decision_provenance: { policy: "allow" }
|
||||
},
|
||||
{
|
||||
channel: "postal",
|
||||
target: "Example Street 1",
|
||||
target_key: "postal:ada",
|
||||
status: "usable",
|
||||
preferred: false,
|
||||
decision_provenance: { policy: "fallback" }
|
||||
}
|
||||
],
|
||||
source_entry_ids: ["entry-addresses"],
|
||||
explanations: [],
|
||||
attributes: { district: "north" },
|
||||
provenance: { provider: "addresses" }
|
||||
},
|
||||
{
|
||||
recipient_key: "contact:postal",
|
||||
display_name: "Postal only",
|
||||
status: "usable",
|
||||
channels: [
|
||||
{
|
||||
channel: "postal",
|
||||
target: "Example Street 2",
|
||||
target_key: "postal:only",
|
||||
status: "usable",
|
||||
preferred: true,
|
||||
decision_provenance: { policy: "allow" }
|
||||
}
|
||||
],
|
||||
source_entry_ids: ["entry-postal"],
|
||||
explanations: [],
|
||||
attributes: {},
|
||||
provenance: {}
|
||||
}
|
||||
],
|
||||
excluded: [
|
||||
{
|
||||
recipient_key: "contact:suppressed",
|
||||
display_name: "Suppressed",
|
||||
status: "suppressed",
|
||||
channels: [],
|
||||
source_entry_ids: ["entry-addresses"],
|
||||
explanations: [{ code: "opt_out", message: "Recipient opted out.", severity: "warning", provenance: {} }],
|
||||
attributes: {},
|
||||
provenance: {}
|
||||
}
|
||||
],
|
||||
diagnostics: [],
|
||||
provider_evidence: [],
|
||||
expansion_hash: "expansion-hash-3",
|
||||
generated_at: "2026-08-02T10:00:00Z",
|
||||
snapshot_id: "snapshot-3",
|
||||
stale: false,
|
||||
truncated: false
|
||||
};
|
||||
const distributionDraft = materializeDistributionListExpansion(draft, distributionExpansion, "replace");
|
||||
const distributionEntries = asRecord(distributionDraft.entries);
|
||||
const distributionInline = distributionEntries.inline as Record<string, unknown>[];
|
||||
const firstDistributionSource = asRecord(distributionInline[0].distribution_source);
|
||||
const secondDistributionSource = asRecord(distributionInline[1].distribution_source);
|
||||
const distributionImports = distributionEntries.imports as Record<string, unknown>[];
|
||||
|
||||
assert(distributionInline.length === 2, "all included Distribution List recipients are frozen into Campaign");
|
||||
assert(distributionInline[0].active === true && distributionInline[0].email === "ada@example.org", "one preferred route is selected without duplicating channels");
|
||||
assert(distributionInline[1].active === false, "postal-only recipients are retained but not sent through the mail-only path");
|
||||
assert(firstDistributionSource.snapshot_id === "snapshot-3" && firstDistributionSource.list_revision_id === "revision-3", "recipient rows retain snapshot and list revision evidence");
|
||||
assert((secondDistributionSource.source_entry_ids as string[])[0] === "entry-postal", "recipient rows retain source entry references");
|
||||
assert(distributionImports[0].source_type === "distribution_list", "Campaign stores Distribution List import provenance");
|
||||
assert((asRecord(distributionImports[0].source_provenance).exclusions as unknown[]).length === 1, "excluded recipients remain in immutable import evidence");
|
||||
assert(distributionListDrift(distributionEntries.imports, [{ id: "list-1", revision: 4, revision_id: "revision-4", definition_hash: "definition-hash-4" }]).length === 1, "list revision drift is detected without changing frozen recipients");
|
||||
|
||||
void runXlsxImportAssertions();
|
||||
|
||||
async function runXlsxImportAssertions(): Promise<void> {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"include": [
|
||||
"tests/import-utils.test.ts",
|
||||
"src/features/campaigns/utils/bulkImport.ts"
|
||||
"src/features/campaigns/utils/bulkImport.ts",
|
||||
"src/features/campaigns/utils/distributionListImport.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user