Implement governed hybrid campaign delivery
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user