Implement governed hybrid campaign delivery
This commit is contained in:
@@ -368,6 +368,39 @@ export type CampaignPostboxCatalog = {
|
||||
organization_units: CampaignPostboxOrganizationUnit[];
|
||||
};
|
||||
|
||||
export type CampaignPrintTemplateOutputProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
output_format: "html" | "text";
|
||||
media_type: string;
|
||||
channel: string;
|
||||
capabilities: string[];
|
||||
page: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignPrintTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
template_type: string;
|
||||
status: string;
|
||||
current_revision: number;
|
||||
current_revision_id: string;
|
||||
published_revision_id?: string | null;
|
||||
description?: string | null;
|
||||
read_only: boolean;
|
||||
revision?: {
|
||||
revision: number;
|
||||
output_profiles: CampaignPrintTemplateOutputProfile[];
|
||||
required_fields: Array<{path: string;label?: string | null;required: boolean;}>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CampaignPrintTemplatesResponse = {
|
||||
available: boolean;
|
||||
templates: CampaignPrintTemplate[];
|
||||
};
|
||||
|
||||
export type CampaignRecipientSnapshotItem = {
|
||||
contact_id: string;
|
||||
display_name: string;
|
||||
@@ -494,6 +527,8 @@ export type CampaignSummary = {
|
||||
needs_attention?: number;
|
||||
sent?: number;
|
||||
smtp_accepted?: number;
|
||||
postbox_accepted?: number;
|
||||
print_accepted?: number;
|
||||
failed?: number;
|
||||
outcome_unknown?: number;
|
||||
not_attempted?: number;
|
||||
@@ -729,6 +764,7 @@ export type CampaignJobDetailResponse = {
|
||||
smtp?: Record<string, unknown>[];
|
||||
imap?: Record<string, unknown>[];
|
||||
postbox?: Record<string, unknown>[];
|
||||
print?: Record<string, unknown>[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -771,6 +807,7 @@ export type AggregateCampaignReport = {
|
||||
outcomes: {
|
||||
smtp_accepted: AggregateReportCount;
|
||||
postbox_accepted: AggregateReportCount;
|
||||
print_accepted: AggregateReportCount;
|
||||
delivered: AggregateReportCount;
|
||||
partially_accepted: AggregateReportCount;
|
||||
failed: AggregateReportCount;
|
||||
@@ -908,6 +945,15 @@ campaignId: string)
|
||||
return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
|
||||
}
|
||||
|
||||
export async function listCampaignPrintTemplates(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
query = "")
|
||||
: Promise<CampaignPrintTemplatesResponse> {
|
||||
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
|
||||
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`);
|
||||
}
|
||||
|
||||
export async function snapshotCampaignRecipientAddressSource(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
@@ -1242,6 +1288,20 @@ versionId?: string)
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadCampaignPrintArtifact(
|
||||
settings: ApiSettings,
|
||||
downloadPath: string,
|
||||
filename: string)
|
||||
: Promise<void> {
|
||||
if (![
|
||||
"/api/v1/campaigns/",
|
||||
"/api/v1/files/"
|
||||
].some((prefix) => downloadPath.startsWith(prefix))) {
|
||||
throw new Error("The printable output download path is invalid.");
|
||||
}
|
||||
await apiDownload(settings, downloadPath, filename || "campaign-print-output.html");
|
||||
}
|
||||
|
||||
export async function emailCampaignReport(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -237,6 +237,24 @@ assert(distributionImports[0].source_type === "distribution_list", "Campaign sto
|
||||
assert((asRecord(distributionImports[0].source_provenance).exclusions as unknown[]).length === 1, "excluded recipients remain in immutable import evidence");
|
||||
assert(distributionListDrift(distributionEntries.imports, [{ id: "list-1", revision: 4, revision_id: "revision-4", definition_hash: "definition-hash-4" }]).length === 1, "list revision drift is detected without changing frozen recipients");
|
||||
|
||||
const explicitlyRoutedDraft = materializeDistributionListExpansion(
|
||||
draft,
|
||||
distributionExpansion,
|
||||
"replace",
|
||||
{
|
||||
"contact:ada": {
|
||||
primaryTargetKey: "email:ada@example.org",
|
||||
fallbackTargetKey: "postal:ada"
|
||||
},
|
||||
"contact:postal": { primaryTargetKey: "postal:only" }
|
||||
}
|
||||
);
|
||||
const explicitlyRoutedEntries = asRecord(explicitlyRoutedDraft.entries).inline as Record<string, unknown>[];
|
||||
assert(explicitlyRoutedEntries[0].channel_policy === "mail_then_print", "an explicit postal fallback is frozen into the delivery policy");
|
||||
assert(asRecord(explicitlyRoutedEntries[0].print_target).target_key === "postal:ada", "the exact printable fallback target is retained");
|
||||
assert(explicitlyRoutedEntries[1].active === true && explicitlyRoutedEntries[1].channel_policy === "print", "print-only recipients remain active without a Mail address");
|
||||
assert(asRecord(explicitlyRoutedEntries[1].distribution_source).route_reason === "explicit_user_selection", "route provenance records the user decision");
|
||||
|
||||
void runXlsxImportAssertions();
|
||||
|
||||
async function runXlsxImportAssertions(): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user