Implement governed hybrid campaign delivery

This commit is contained in:
2026-08-02 13:58:37 +02:00
parent b38597f2be
commit 7733265cc8
40 changed files with 2531 additions and 122 deletions
@@ -21,7 +21,11 @@ import {
type CampaignDistributionRecipient
} from "../../../api/campaigns";
import type { RecipientImportMode } from "../utils/bulkImport";
import { usableChannelSummary } from "../utils/distributionListImport";
import {
usableChannelSummary,
type DistributionRouteSelection,
type DistributionRouteSelections
} from "../utils/distributionListImport";
type RequestedChannel = "email" | "postal" | "internal_mail" | "portal";
type PreviewRow = CampaignDistributionRecipient & { included: boolean };
@@ -46,7 +50,11 @@ export default function DistributionListImportDialog({
sources: CampaignDistributionListSource[];
initialSourceId?: string;
onCancel: () => void;
onImport: (snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) => void;
onImport: (
snapshot: CampaignDistributionListExpansion,
mode: RecipientImportMode,
routeSelections: DistributionRouteSelections
) => void;
}) {
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.id || "");
const [sourceQuery, setSourceQuery] = useState("");
@@ -54,6 +62,9 @@ export default function DistributionListImportDialog({
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 [routeSelections, setRouteSelections] = useState<DistributionRouteSelections>({});
const [previewPage, setPreviewPage] = useState(1);
const [previewPageSize, setPreviewPageSize] = useState(50);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const selectedSource = useMemo(
@@ -71,6 +82,10 @@ export default function DistributionListImportDialog({
...(preview?.recipients ?? []).map((recipient) => ({ ...recipient, included: true })),
...(preview?.excluded ?? []).map((recipient) => ({ ...recipient, included: false }))
], [preview]);
const unresolvedRoutes = useMemo(
() => preview?.recipients.filter((recipient) => !validRouteSelection(recipient, routeSelections[recipient.recipient_key])) ?? [],
[preview, routeSelections]
);
useEffect(() => {
if (filteredSources.some((source) => source.id === selectedSourceId)) return;
@@ -81,6 +96,7 @@ export default function DistributionListImportDialog({
if (!selectedSource) {
setParameters({});
setPreview(null);
setRouteSelections({});
return;
}
setParameters(Object.fromEntries(
@@ -89,6 +105,8 @@ export default function DistributionListImportDialog({
.map((parameter) => [parameter.key, parameter.default])
));
setPreview(null);
setRouteSelections({});
setPreviewPage(1);
setError("");
}, [selectedSource?.id, selectedSource?.revision_id]);
@@ -97,11 +115,13 @@ export default function DistributionListImportDialog({
? [...new Set([...current, channel])]
: current.filter((item) => item !== channel));
setPreview(null);
setRouteSelections({});
}
function updateParameter(parameter: CampaignDistributionListParameter, value: unknown) {
setParameters((current) => ({ ...current, [parameter.key]: normalizeParameterValue(parameter, value) }));
setPreview(null);
setRouteSelections({});
}
function requestPayload(idempotencyKey?: string): CampaignDistributionListExpansionInput {
@@ -120,7 +140,10 @@ export default function DistributionListImportDialog({
setLoading(true);
setError("");
try {
setPreview(await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload()));
const result = await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload());
setPreview(result);
setRouteSelections(defaultRouteSelections(result.recipients));
setPreviewPage(1);
} catch (reason) {
setPreview(null);
setError(reason instanceof Error ? reason.message : String(reason));
@@ -140,7 +163,15 @@ export default function DistributionListImportDialog({
campaignId,
requestPayload(idempotencyKey)
);
onImport(snapshot, mode);
const unresolved = snapshot.recipients.filter(
(recipient) => !validRouteSelection(recipient, routeSelections[recipient.recipient_key])
);
if (unresolved.length > 0) {
setPreview(snapshot);
setError(`${unresolved.length} recipient route${unresolved.length === 1 ? "" : "s"} must be selected again because the frozen expansion changed.`);
return;
}
onImport(snapshot, mode, routeSelections);
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
@@ -162,7 +193,7 @@ export default function DistributionListImportDialog({
<Button onClick={onCancel} disabled={loading}>Cancel</Button>
<Button
variant="primary"
disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated}
disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated || unresolvedRoutes.length > 0}
onClick={() => void freezeAndImport()}
>
Freeze and import
@@ -269,6 +300,7 @@ export default function DistributionListImportDialog({
<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>Route decisions</dt><dd>{unresolvedRoutes.length ? `${unresolvedRoutes.length} required` : "Complete"}</dd></div>
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
</dl>
{preview.stale && (
@@ -281,6 +313,11 @@ export default function DistributionListImportDialog({
The expansion reached a safety limit and cannot be frozen from this dialog.
</DismissibleAlert>
)}
{unresolvedRoutes.length > 0 && (
<DismissibleAlert tone="warning" compact dismissible={false}>
Select one delivery route for every included recipient. A fallback is optional and is only used when the primary channel rejects before accepting the delivery.
</DismissibleAlert>
)}
{preview.diagnostics.map((diagnostic) => (
<DismissibleAlert
key={`${diagnostic.code}:${diagnostic.message}`}
@@ -291,10 +328,21 @@ export default function DistributionListImportDialog({
{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>
)}
<DistributionPreviewGrid
rows={previewRows}
routeSelections={routeSelections}
onRouteChange={(recipientKey, selection) => setRouteSelections((current) => ({
...current,
[recipientKey]: selection
}))}
page={previewPage}
pageSize={previewPageSize}
onPageChange={setPreviewPage}
onPageSizeChange={(pageSize) => {
setPreviewPageSize(pageSize);
setPreviewPage(1);
}}
/>
</>
)}
</Dialog>
@@ -351,11 +399,62 @@ function DistributionParameterField({
);
}
function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) {
function DistributionPreviewGrid({
rows,
routeSelections,
onRouteChange,
page,
pageSize,
onPageChange,
onPageSizeChange
}: {
rows: PreviewRow[];
routeSelections: DistributionRouteSelections;
onRouteChange: (recipientKey: string, selection: DistributionRouteSelection) => void;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}) {
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: "primary",
header: "Primary route",
width: "minmax(210px, 1fr)",
render: (row) => (
<RouteSelect
row={row}
value={routeSelections[row.recipient_key]?.primaryTargetKey ?? ""}
onChange={(value) => onRouteChange(row.recipient_key, {
primaryTargetKey: value,
fallbackTargetKey: ""
})}
/>
),
filterValue: (row) => usableChannelSummary(row.channels)
},
{
id: "fallback",
header: "Fallback",
width: "minmax(210px, 1fr)",
render: (row) => {
const selection = routeSelections[row.recipient_key];
return (
<RouteSelect
row={row}
value={selection?.fallbackTargetKey ?? ""}
primaryTargetKey={selection?.primaryTargetKey ?? ""}
fallback
onChange={(value) => onRouteChange(row.recipient_key, {
primaryTargetKey: selection?.primaryTargetKey ?? "",
fallbackTargetKey: value
})}
/>
);
}
},
{ 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(" · ") }
];
@@ -367,10 +466,87 @@ function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) {
getRowKey={(row) => `${row.included ? "included" : "excluded"}:${row.recipient_key}`}
emptyText="No recipients resolved from this Distribution List."
className="recipient-table-wrap"
pagination={{
mode: "client",
page,
pageSize,
pageSizeOptions: [25, 50, 100, 250],
onPageChange,
onPageSizeChange
}}
/>
);
}
function RouteSelect({
row,
value,
primaryTargetKey = "",
fallback = false,
onChange
}: {
row: PreviewRow;
value: string;
primaryTargetKey?: string;
fallback?: boolean;
onChange: (value: string) => void;
}) {
const usable = row.channels.filter((candidate) => candidate.status === "usable");
const primary = usable.find((candidate) => candidate.target_key === primaryTargetKey) ?? null;
const options = fallback ? usable.filter((candidate) => fallbackAllowed(primary?.channel, candidate.channel)) : usable;
return (
<select
value={value}
disabled={!row.included || (fallback && !primary)}
aria-label={`${fallback ? "Fallback" : "Primary route"} for ${row.display_name || row.recipient_key}`}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{fallback ? "No fallback" : "Select route"}</option>
{options.map((candidate) => (
<option key={candidate.target_key} value={candidate.target_key}>
{routeLabel(candidate.channel)}: {candidate.target}{candidate.preferred ? " (preferred)" : ""}
</option>
))}
</select>
);
}
function defaultRouteSelections(recipients: CampaignDistributionRecipient[]): DistributionRouteSelections {
return Object.fromEntries(recipients.map((recipient) => {
const usable = recipient.channels.filter((candidate) => candidate.status === "usable");
const preferred = usable.filter((candidate) => candidate.preferred);
const primary = preferred.length === 1 ? preferred[0] : usable.length === 1 ? usable[0] : null;
return [recipient.recipient_key, { primaryTargetKey: primary?.target_key ?? "", fallbackTargetKey: "" }];
}));
}
function validRouteSelection(
recipient: CampaignDistributionRecipient,
selection: DistributionRouteSelection | undefined
): boolean {
if (!selection?.primaryTargetKey) return false;
const usable = recipient.channels.filter((candidate) => candidate.status === "usable");
const primary = usable.find((candidate) => candidate.target_key === selection.primaryTargetKey);
if (!primary) return false;
if (!selection.fallbackTargetKey) return true;
const fallback = usable.find((candidate) => candidate.target_key === selection.fallbackTargetKey);
return Boolean(fallback && fallbackAllowed(primary.channel, fallback.channel));
}
function fallbackAllowed(primary: string | undefined, fallback: string): boolean {
if (primary === "email") return fallback === "portal" || fallback === "postal" || fallback === "internal_mail";
if (primary === "portal") return fallback === "email" || fallback === "postal" || fallback === "internal_mail";
return false;
}
function routeLabel(channel: string): string {
if (channel === "email") return "Mail";
if (channel === "portal") return "Postbox";
if (channel === "internal_mail") return "Internal mail";
if (channel === "postal") return "Postal";
return channel;
}
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);