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
@@ -45,6 +45,7 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"sending",
"smtp_accepted",
"postbox_accepted",
"print_accepted",
"delivered",
"partially_accepted",
"sent",
@@ -54,6 +55,14 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"cancelled"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const PRINT_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"ready",
"accepted",
"failed",
"skipped"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
@@ -162,6 +171,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
{ label: "Postbox accepted", value: cards?.postbox_accepted ?? 0, shortcutId: "postbox_accepted" },
{ label: "Print accepted", value: cards?.print_accepted ?? 0, shortcutId: "print_accepted" },
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" },
{ label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" },
@@ -274,7 +285,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
});
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
const status = String(sendResult.status ?? "submitted");
if (["smtp_accepted", "postbox_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
if (["smtp_accepted", "postbox_accepted", "print_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
} catch (err) {
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
@@ -377,6 +388,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
{ id: "send", header: "Delivery", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
{ id: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, value: (row) => String(row.postbox_status ?? "unknown") },
{ id: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.print_status ?? "unknown")} label={deliveryStatusLabel(String(row.print_status ?? "unknown"))} />, value: (row) => String(row.print_status ?? "unknown") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) },
{
@@ -591,6 +603,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
<div><dt>Print state</dt><dd><StatusBadge status={String(detail.job.print_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.print_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
<div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
@@ -605,6 +618,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<AttachmentEvidenceSection attachments={Array.isArray(detail.job.attachments) ? detail.job.attachments : []} />
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
<AttemptHistoryTable kind="print" rows={detail.attempts.print ?? []} />
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
</div>
}
@@ -918,12 +932,14 @@ function shortEvidenceId(value: string): string {
return value.length > 16 ? `${value.slice(0, 16)}...` : value;
}
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record<string, unknown>[];}) {
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox" | "print";rows: Record<string, unknown>[];}) {
const title = kind === "smtp"
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
: kind === "postbox"
? "Postbox delivery attempts"
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
: kind === "print"
? "Printable output attempts"
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
if (rows.length === 0) {
return (
<section className="attempt-history-section">
@@ -938,6 +954,8 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
kind === "imap" ?
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
kind === "print" ?
{ id: "render", header: "Render", width: 220, sortable: true, filterable: true, value: (row) => String(row.render_id ?? "—"), render: (row) => String(row.render_id ?? "—") } :
kind === "postbox" ?
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
@@ -1005,10 +1023,12 @@ function initialReportGridFilters(): Record<string, string | string[]> {
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
const postbox = statusParameters(params, "postbox_status", POSTBOX_STATUS_OPTIONS);
const print = statusParameters(params, "print_status", PRINT_STATUS_OPTIONS);
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
if (send.length > 0) result.send = send;
if (imap.length > 0) result.imap = imap;
if (postbox.length > 0) result.postbox = postbox;
if (print.length > 0) result.print = print;
if (validation.length > 0) result.validation = validation;
return result;
}
@@ -1040,7 +1060,7 @@ function serializeInitialGridFilters(filters: Record<string, string | string[]>)
}
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "imap" || value === "attempts" || value === "updated") {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "print" || value === "imap" || value === "attempts" || value === "updated") {
return value;
}
return "number";
@@ -44,7 +44,8 @@ import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
import {
distributionListDrift,
materializeDistributionListExpansion
materializeDistributionListExpansion,
type DistributionRouteSelections
} from "./utils/distributionListImport";
import {
AddressHeaderControl,
@@ -72,6 +73,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const { translateText } = usePlatformLanguage();
const filesModuleInstalled = usePlatformModuleInstalled("files");
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const templatesModuleInstalled = usePlatformModuleInstalled("templates");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
@@ -359,9 +361,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
setAddressSourceImportOpen(false);
}
function applyDistributionListImport(snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) {
function applyDistributionListImport(
snapshot: CampaignDistributionListExpansion,
mode: RecipientImportMode,
routeSelections: DistributionRouteSelections
) {
if (locked || !draft) return;
setDraft(materializeDistributionListExpansion(draft, snapshot, mode));
setDraft(materializeDistributionListExpansion(draft, snapshot, mode, routeSelections));
markDirty();
setDistributionListImportOpen(false);
}
@@ -541,6 +547,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
locked,
filesModuleInstalled,
postboxModuleInstalled,
templatesModuleInstalled,
postboxCatalog,
entries: inlineEntries,
fieldDefinitions,
@@ -12,6 +12,7 @@ import {
appendSent,
buildVersion,
cancelCampaign,
downloadCampaignPrintArtifact,
getCampaignDeliveryOptions,
getCampaignJobs,
getCampaignJobsDelta,
@@ -151,6 +152,8 @@ export default function ReviewSendPage({
);
const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary);
const printOutput = asRecord(build.print_output);
const printArtifact = asRecord(printOutput.artifact);
const summary = liveSummary ?? data.summary;
const cards = summary?.cards;
const attachmentSummary = asRecord(summary?.attachments);
@@ -1449,6 +1452,34 @@ export default function ReviewSendPage({
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</div>
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
{getText(printOutput, "render_id") && (
<div className="review-flow-data-section">
<div className="page-heading split">
<div>
<h3>Printable output</h3>
<p className="muted small-note">
Template revision {String(printOutput.template_revision ?? "—")} · {String(printOutput.item_count ?? 0)} recipient item(s) · {String(printOutput.page_count ?? 0)} page(s) · {String(printOutput.output_size_bytes ?? 0)} B
</p>
</div>
{getText(printArtifact, "download_path") && (
<Button
onClick={() => void downloadCampaignPrintArtifact(
settings,
getText(printArtifact, "download_path"),
getText(printArtifact, "filename", "campaign-print-output.html")
).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)))}
>
Download output
</Button>
)}
</div>
<dl className="detail-list">
<div><dt>Template hash</dt><dd><code>{getText(printOutput, "template_hash") || "—"}</code></dd></div>
<div><dt>Input hash</dt><dd><code>{getText(printOutput, "input_hash") || "—"}</code></dd></div>
<div><dt>Output hash</dt><dd><code>{getText(printOutput, "output_sha256") || "—"}</code></dd></div>
</dl>
</div>
)}
<div className="button-row compact-actions review-flow-stage-actions">
<Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted}>
{busy === "build" ? "i18n:govoplan-campaign.building.7cc766ce" : hasBuild ? "i18n:govoplan-campaign.build_again.bd018b93" : "i18n:govoplan-campaign.build_exact_messages.bc53f55e"}
@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
import {
listCampaignPrintTemplates,
previewCampaignAttachments,
type CampaignAttachmentPreviewRule,
type CampaignPrintTemplate
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
@@ -35,6 +40,10 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]);
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
const [printTemplatesAvailable, setPrintTemplatesAvailable] = useState(false);
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
const [printTemplatesError, setPrintTemplatesError] = useState("");
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
@@ -55,6 +64,9 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const delivery = asRecord(displayDraft.delivery);
const printConfig = asRecord(delivery.print);
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
@@ -145,6 +157,28 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
};
}, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]);
useEffect(() => {
let cancelled = false;
setPrintTemplatesLoading(true);
setPrintTemplatesError("");
void listCampaignPrintTemplates(settings, campaignId)
.then((response) => {
if (cancelled) return;
setPrintTemplatesAvailable(response.available);
setPrintTemplates(response.templates);
})
.catch((reason: unknown) => {
if (cancelled) return;
setPrintTemplatesAvailable(false);
setPrintTemplates([]);
setPrintTemplatesError(reason instanceof Error ? reason.message : String(reason));
})
.finally(() => {
if (!cancelled) setPrintTemplatesLoading(false);
});
return () => { cancelled = true; };
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
@@ -159,6 +193,22 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
}
}
function patchPrintConfig(values: Record<string, unknown>) {
if (locked) return;
patch(["delivery", "print"], { ...printConfig, ...values });
}
function selectPrintTemplate(templateId: string) {
const selected = printTemplates.find((item) => item.id === templateId) ?? null;
const profile = selected?.revision?.output_profiles[0] ?? null;
patchPrintConfig({
template_id: selected?.id ?? null,
template_revision: selected?.revision?.revision ?? selected?.current_revision ?? null,
output_format: profile?.output_format ?? getText(printConfig, "output_format", "html"),
profile_id: profile?.id ?? null
});
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
@@ -328,6 +378,64 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
</Card>
<div className="template-side-stack">
<Card title="Printable output">
<LoadingFrame loading={printTemplatesLoading} label="Loading printable templates">
<div className="form-grid">
{printTemplatesError && <DismissibleAlert tone="danger" compact resetKey={printTemplatesError}>{printTemplatesError}</DismissibleAlert>}
{!printTemplatesError && !printTemplatesAvailable && (
<DismissibleAlert tone="info" compact dismissible={false}>
Printable delivery becomes available when the Templates module is enabled. Mail-only Campaigns remain unaffected.
</DismissibleAlert>
)}
{printTemplatesAvailable && (
<>
<FormField label="Print template" help="Used for recipients whose frozen route is postal or internal mail, including safe fallbacks.">
<select
value={getText(printConfig, "template_id")}
disabled={locked}
onChange={(event) => selectPrintTemplate(event.target.value)}
>
<option value="">Select a published template</option>
{printTemplates.map((item) => (
<option key={item.id} value={item.id} disabled={!item.published_revision_id}>
{item.name} · {item.template_type} · r{item.revision?.revision ?? item.current_revision}
</option>
))}
</select>
</FormField>
{selectedPrintTemplate && (
<p className="muted small-note">
{selectedPrintTemplate.description || `${selectedPrintTemplate.template_type} template`}
{selectedPrintTemplate.revision?.required_fields.length
? ` · Required fields: ${selectedPrintTemplate.revision.required_fields.map((field) => field.label || field.path).join(", ")}`
: " · No additional fields required"}
</p>
)}
<FormField label="Output format">
<SegmentedControl
ariaLabel="Printable output format"
value={getText(printConfig, "output_format", "html") as "html" | "text"}
disabled={locked || !selectedPrintTemplate}
size="content"
width="inline"
onChange={(outputFormat) => patchPrintConfig({ output_format: outputFormat })}
options={[
{ id: "html", label: "HTML" },
{ id: "text", label: "Text" }
]}
/>
</FormField>
<ToggleSwitch
label="Store generated output in Files"
checked={getBool(printConfig, "persist_to_files", true)}
disabled={locked || !selectedPrintTemplate}
onChange={(checked) => patchPrintConfig({ persist_to_files: checked })}
/>
</>
)}
</div>
</LoadingFrame>
</Card>
<Card title="i18n:govoplan-campaign.fields.e8b68527">
{invalidNamespacePlaceholders.length > 0 &&
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
@@ -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);
@@ -36,6 +36,7 @@ export type RecipientProfileColumnContext = {
locked: boolean;
filesModuleInstalled: boolean;
postboxModuleInstalled: boolean;
templatesModuleInstalled: boolean;
postboxCatalog: CampaignPostboxCatalog;
entries: Record<string, unknown>[];
fieldDefinitions: ReturnType<typeof getDraftFields>;
@@ -53,7 +54,7 @@ export type RecipientProfileColumnContext = {
removeEntry: (index: number) => void;
};
export function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
export function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, templatesModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{
id: "number",
@@ -109,7 +110,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
value: recipientAddressFilterValue
},
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
...(postboxModuleInstalled ? [{
...(postboxModuleInstalled || templatesModuleInstalled || entries.some((entry) => Boolean(entry.channel_policy || entry.print_target || normalizePostboxTargets(entry.postbox_targets).length)) ? [{
id: "delivery",
header: "Delivery",
width: "minmax(260px, 0.9fr)",
@@ -118,6 +119,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
filterable: true,
render: (entry, index) => {
const targets = normalizePostboxTargets(entry.postbox_targets);
const printTarget = asRecord(entry.print_target);
return (
<div className="campaign-recipient-delivery-cell">
<select
@@ -133,21 +135,31 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
>
<option value="">Campaign default</option>
<option value="mail">Mail</option>
<option value="postbox">Postbox</option>
<option value="mail_and_postbox">Mail and Postbox</option>
<option value="mail_then_postbox">Mail, then Postbox fallback</option>
<option value="postbox_then_mail">Postbox, then Mail fallback</option>
<option value="postbox" disabled={!postboxModuleInstalled}>Postbox</option>
<option value="print" disabled={!templatesModuleInstalled}>Print</option>
<option value="mail_and_postbox" disabled={!postboxModuleInstalled}>Mail and Postbox</option>
<option value="mail_then_postbox" disabled={!postboxModuleInstalled}>Mail, then Postbox fallback</option>
<option value="postbox_then_mail" disabled={!postboxModuleInstalled}>Postbox, then Mail fallback</option>
<option value="mail_then_print" disabled={!templatesModuleInstalled}>Mail, then print fallback</option>
<option value="postbox_then_print" disabled={!postboxModuleInstalled || !templatesModuleInstalled}>Postbox, then print fallback</option>
</select>
<Button
disabled={locked || !postboxCatalog.available}
onClick={() => openPostboxTargetEditor(index)}
>
Postboxes ({targets.length})
</Button>
{postboxModuleInstalled && (
<Button
disabled={locked || !postboxCatalog.available}
onClick={() => openPostboxTargetEditor(index)}
>
Postboxes ({targets.length})
</Button>
)}
{printTarget.target && (
<span className="muted small-note" title={String(printTarget.target)}>
{printTarget.channel === "internal_mail" ? "Internal mail" : "Postal"}: {String(printTarget.target)}
</span>
)}
</div>
);
},
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")}`
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")} ${String(asRecord(entry.print_target).target ?? "")}`
} as DataGridColumn<Record<string, unknown>>] : []),
...(individualAttachmentBasePaths.length > 0 ? [{
id: "attachments",
@@ -61,17 +61,31 @@ export type DistributionListExpansionSnapshot = {
truncated: boolean;
};
export type DistributionRouteSelection = {
primaryTargetKey: string;
fallbackTargetKey?: string;
};
export type DistributionRouteSelections = Record<string, DistributionRouteSelection>;
export function materializeDistributionListExpansion(
draft: JsonRecord,
expansion: DistributionListExpansionSnapshot,
mode: RecipientImportMode
mode: RecipientImportMode,
routeSelections?: DistributionRouteSelections
): 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);
const entry = recipientEntry(
expansion,
recipient,
usedIds,
routeSelections?.[recipient.recipient_key],
routeSelections !== undefined
);
Object.keys(asRecord(entry.fields)).forEach((name) => fieldNames.add(name));
return entry;
});
@@ -145,26 +159,42 @@ export function distributionListDrift(
function recipientEntry(
expansion: DistributionListExpansionSnapshot,
recipient: DistributionRecipientSnapshot,
usedIds: Set<string>
usedIds: Set<string>,
routeSelection?: DistributionRouteSelection,
explicitRouting = false
): 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 selectedRoute = routeSelection
? usableChannels.find((candidate) => candidate.target_key === routeSelection.primaryTargetKey) ?? null
: explicitRouting
? null
: inferredRoute(usableChannels);
const fallbackRoute = routeSelection?.fallbackTargetKey
? usableChannels.find((candidate) => candidate.target_key === routeSelection.fallbackTargetKey) ?? null
: null;
const emailRoute = [selectedRoute, fallbackRoute].find((candidate) => candidate?.channel === "email") ?? null;
const postboxRoute = explicitRouting
? [selectedRoute, fallbackRoute].find((candidate) => candidate?.channel === "portal") ?? null
: null;
const printRoute = explicitRouting
? [selectedRoute, fallbackRoute].find((candidate) => isPrintChannel(candidate?.channel)) ?? null
: null;
const email = emailRoute?.target.trim() ?? "";
const fields = stringFields(recipient.attributes);
const routeReason = selectedRoute
? (selectedRoute.preferred ? "preferred_channel" : "single_usable_channel")
? (routeSelection ? "explicit_user_selection" : selectedRoute.preferred ? "preferred_channel" : "single_usable_channel")
: usableChannels.length > 1
? "explicit_route_required"
: "no_usable_channel";
const channelPolicy = explicitRouting
? routePolicy(selectedRoute, fallbackRoute)
: selectedRoute?.channel === "email"
? "mail"
: null;
return {
id: uniqueRecipientId(recipient.recipient_key, usedIds),
active: recipient.status === "usable" && Boolean(email),
active: recipient.status === "usable" && Boolean(selectedRoute && channelPolicy),
name: recipient.display_name,
email,
from: [],
@@ -176,6 +206,10 @@ function recipientEntry(
merge_cc: true,
merge_bcc: true,
merge_reply_to: true,
channel_policy: channelPolicy,
postbox_targets: postboxRoute ? [postboxTarget(postboxRoute, recipient.display_name)] : [],
merge_postbox_targets: false,
print_target: printRoute ? printTarget(printRoute) : null,
fields,
attachments: [],
combine_attachments: true,
@@ -196,9 +230,7 @@ function recipientEntry(
function_id: recipient.function_id ?? null,
channels: recipient.channels,
selected_route: selectedRoute,
fallback_routes: selectedRoute
? usableChannels.filter((candidate) => candidate.target_key !== selectedRoute.target_key)
: [],
fallback_routes: fallbackRoute ? [fallbackRoute] : [],
route_reason: routeReason,
explanations: recipient.explanations,
attributes: recipient.attributes,
@@ -207,6 +239,54 @@ function recipientEntry(
};
}
function inferredRoute(usableChannels: DistributionChannelCandidateSnapshot[]): DistributionChannelCandidateSnapshot | null {
const preferredChannels = usableChannels.filter((candidate) => candidate.preferred);
if (preferredChannels.length === 1) return preferredChannels[0];
return usableChannels.length === 1 ? usableChannels[0] : null;
}
function routePolicy(
primary: DistributionChannelCandidateSnapshot | null,
fallback: DistributionChannelCandidateSnapshot | null
): string | null {
if (!primary) return null;
if (primary.channel === "email") {
if (fallback?.channel === "portal") return "mail_then_postbox";
if (isPrintChannel(fallback?.channel)) return "mail_then_print";
return fallback ? null : "mail";
}
if (primary.channel === "portal") {
if (fallback?.channel === "email") return "postbox_then_mail";
if (isPrintChannel(fallback?.channel)) return "postbox_then_print";
return fallback ? null : "postbox";
}
return isPrintChannel(primary.channel) && !fallback ? "print" : null;
}
function isPrintChannel(channel: string | undefined): channel is "postal" | "internal_mail" {
return channel === "postal" || channel === "internal_mail";
}
function postboxTarget(candidate: DistributionChannelCandidateSnapshot, label: string): JsonRecord {
return {
id: `distribution-${safeId(candidate.target_key).slice(0, 95)}`,
mode: "direct",
label: label || candidate.target,
address_key: candidate.target
};
}
function printTarget(candidate: DistributionChannelCandidateSnapshot): JsonRecord {
return {
channel: candidate.channel,
target: candidate.target,
target_key: candidate.target_key,
contact_point_id: candidate.contact_point_id ?? null,
locale: candidate.locale ?? null,
decision_provenance: candidate.decision_provenance ?? {}
};
}
function distributionListImportProvenance(
expansion: DistributionListExpansionSnapshot,
mode: RecipientImportMode,
@@ -6,6 +6,7 @@ export type CampaignJobSortColumn =
| "queue"
| "send"
| "postbox"
| "print"
| "imap"
| "attempts"
| "updated";
@@ -33,6 +34,7 @@ const FILTER_PARAMETERS: Record<string, string> = {
queue: "filter_queue",
send: "filter_send",
postbox: "filter_postbox",
print: "filter_print",
imap: "filter_imap",
attempts: "filter_attempts",
evidence: "filter_evidence"
@@ -8,6 +8,8 @@ export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" a
export type ReportGridShortcutId =
| "all"
| "smtp_accepted"
| "postbox_accepted"
| "print_accepted"
| "failed"
| "outcome_unknown"
| "not_attempted"
@@ -20,6 +22,8 @@ export type ReportGridShortcutId =
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
all: {},
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
postbox_accepted: { send: listFilter(["postbox_accepted"]) },
print_accepted: { send: listFilter(["print_accepted"]) },
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
outcome_unknown: { send: listFilter(["outcome_unknown"]) },
not_attempted: { send: listFilter(["not_queued"]) },
@@ -216,6 +216,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
<div className="dashboard-grid">
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
<MetricCard label="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
<MetricCard label="Print accepted" value={countValue(outcomes.print_accepted)} tone="good" />
<MetricCard label="Both channels accepted" value={countValue(outcomes.delivered)} tone="good" />
<MetricCard label="Partially accepted" value={countValue(outcomes.partially_accepted)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" />