feat: harden campaign delivery and editing
This commit is contained in:
@@ -102,6 +102,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
const version = data.currentVersion;
|
||||
const cards = data.summary?.cards;
|
||||
const delivery = asRecord(data.summary?.delivery);
|
||||
const postboxReceipts = asRecord(data.summary?.postbox_receipts);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||
|
||||
@@ -262,8 +263,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
attempted += 1;
|
||||
try {
|
||||
const sendResponse = await sendCampaignJob(settings, campaignId, jobId, {
|
||||
kind: "single_resend",
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
reason: "Operator requested synchronous resend from the campaign report.",
|
||||
include_warnings: true,
|
||||
dry_run: false,
|
||||
use_rate_limit: true,
|
||||
enqueue_imap_task: false
|
||||
});
|
||||
@@ -338,9 +341,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
version_id: version?.id,
|
||||
include_jobs: false,
|
||||
attach_jobs_csv: attachCsv,
|
||||
attach_report_json: attachJson
|
||||
attach_report_json: attachJson,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
});
|
||||
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
|
||||
setActionMessage(`Report queued for ${recipients.join(", ")}.`);
|
||||
setEmailOpen(false);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
@@ -471,6 +475,30 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Postbox receipt evidence">
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>Evidence state</dt>
|
||||
<dd>
|
||||
<StatusBadge
|
||||
status={
|
||||
postboxReceipts.status === "available"
|
||||
? "success"
|
||||
: postboxReceipts.status === "unavailable"
|
||||
? "warning"
|
||||
: "info"
|
||||
}
|
||||
label={humanize(String(postboxReceipts.status ?? "not applicable"))}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>Accepted deliveries</dt><dd>{String(postboxReceipts.delivery_count ?? 0)}</dd></div>
|
||||
<div><dt>Currently readable</dt><dd>{String(postboxReceipts.currently_readable_delivery_count ?? "—")}</dd></div>
|
||||
<div><dt>Read</dt><dd>{String(postboxReceipts.read_delivery_count ?? "—")}</dd></div>
|
||||
<div><dt>Acknowledged</dt><dd>{String(postboxReceipts.acknowledged_delivery_count ?? "—")}</dd></div>
|
||||
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
||||
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||
<div className="button-row compact-actions">
|
||||
@@ -563,6 +591,13 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<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>
|
||||
</dl>
|
||||
<PostboxTargetEvidenceSection
|
||||
targets={
|
||||
Array.isArray(detail.job.resolved_postbox_targets)
|
||||
? detail.job.resolved_postbox_targets
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<AttachmentEvidenceSection attachments={Array.isArray(detail.job.attachments) ? detail.job.attachments : []} />
|
||||
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
|
||||
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
|
||||
@@ -587,6 +622,93 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
|
||||
}
|
||||
|
||||
function PostboxTargetEvidenceSection({ targets }: { targets: unknown[] }) {
|
||||
const rows = targets.map(asRecord);
|
||||
if (rows.length === 0) return null;
|
||||
const columns: DataGridColumn<Record<string, unknown>>[] = [
|
||||
{
|
||||
id: "target",
|
||||
header: "Frozen Postbox target",
|
||||
width: "minmax(240px, 1fr)",
|
||||
minWidth: 220,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (row) => String(row.address ?? row.name ?? row.postbox_id ?? "—"),
|
||||
render: (row) => (
|
||||
<span className="campaign-evidence-identifiers">
|
||||
<strong>{String(row.name ?? row.address ?? row.postbox_id ?? "—")}</strong>
|
||||
<small>{String(row.address ?? row.address_key ?? "")}</small>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "organization",
|
||||
header: "Organization / function",
|
||||
width: 260,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (row) =>
|
||||
`${String(row.organization_unit_name ?? row.organization_unit_id ?? "")} ${String(row.function_name ?? row.function_id ?? "")}`,
|
||||
render: (row) => (
|
||||
<span className="campaign-evidence-identifiers">
|
||||
<span>{String(row.organization_unit_name ?? row.organization_unit_id ?? "—")}</span>
|
||||
<small>{String(row.function_name ?? row.function_id ?? "—")}</small>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "resolution",
|
||||
header: "Frozen resolution",
|
||||
width: 220,
|
||||
resizable: true,
|
||||
value: (row) =>
|
||||
`${String(row.mode ?? "direct")} ${String(row.context_key ?? "")} ${String(row.template_revision_id ?? "")}`,
|
||||
render: (row) => (
|
||||
<span className="campaign-evidence-identifiers">
|
||||
<span>{humanize(String(row.mode ?? "direct"))}</span>
|
||||
<small>
|
||||
{row.template_revision_id
|
||||
? `Template ${shortEvidenceId(String(row.template_revision_id))}`
|
||||
: "Exact Postbox"}
|
||||
{row.context_key ? ` · Context ${String(row.context_key)}` : ""}
|
||||
</small>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "holders",
|
||||
header: "Build-time holders",
|
||||
width: 150,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
value: (row) => Number(row.holder_count ?? 0),
|
||||
render: (row) =>
|
||||
row.vacant === true ? (
|
||||
<StatusBadge status="warning" label="Vacant" />
|
||||
) : (
|
||||
String(row.holder_count ?? 0)
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<section className="attempt-history-section">
|
||||
<h3>Frozen Postbox targets</h3>
|
||||
<p className="muted small-note">
|
||||
These addresses, organization/function references, and template revisions
|
||||
were resolved during build and are the delivery evidence for this job.
|
||||
</p>
|
||||
<DataGrid
|
||||
id="campaign-postbox-target-evidence"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row, index) =>
|
||||
String(row.target_id ?? row.postbox_id ?? `postbox-target-${index}`)
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type AttachmentEvidenceRow = {
|
||||
id: string;
|
||||
rule: string;
|
||||
@@ -718,6 +840,50 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";
|
||||
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 ?? "—") },
|
||||
...(kind === "postbox" ? [
|
||||
{
|
||||
id: "readability",
|
||||
header: "Current access",
|
||||
width: 155,
|
||||
value: (row: Record<string, unknown>) => {
|
||||
const summary = asRecord(row.receipt_summary);
|
||||
return String(
|
||||
row.receipt_summary_status === "available"
|
||||
? summary.currently_readable === true
|
||||
? "readable"
|
||||
: "unavailable"
|
||||
: row.receipt_summary_status ?? "unavailable"
|
||||
);
|
||||
},
|
||||
render: (row: Record<string, unknown>) => {
|
||||
const summary = asRecord(row.receipt_summary);
|
||||
const available = row.receipt_summary_status === "available";
|
||||
const readable = available && summary.currently_readable === true;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={readable ? "success" : available ? "warning" : "info"}
|
||||
label={
|
||||
readable
|
||||
? "Readable"
|
||||
: available
|
||||
? "Not currently readable"
|
||||
: "Evidence unavailable"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "receipts",
|
||||
header: "Read / acknowledged",
|
||||
width: 170,
|
||||
align: "right" as const,
|
||||
value: (row: Record<string, unknown>) => {
|
||||
const summary = asRecord(row.receipt_summary);
|
||||
return `${String(summary.read_receipt_count ?? 0)} / ${String(summary.acknowledged_receipt_count ?? 0)}`;
|
||||
}
|
||||
}
|
||||
] : []),
|
||||
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
|
||||
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
|
||||
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? row.error_code ?? "")}>{String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—")}</span> }
|
||||
|
||||
Reference in New Issue
Block a user