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
@@ -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";
}