feat: harden campaign delivery and editing

This commit is contained in:
2026-07-30 14:27:04 +02:00
parent 4a120e8009
commit cd223cbb95
34 changed files with 3370 additions and 165 deletions

View File

@@ -17,7 +17,7 @@
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.12",
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -60,6 +60,8 @@ export type CampaignVersionListItem = {
id: string;
campaign_id: string;
version_number: number;
edit_revision: number;
strong_etag: string;
schema_version?: string;
source_filename?: string | null;
source_base_path?: string | null;
@@ -274,6 +276,9 @@ export type CampaignVersionUpdatePayload = {
source_filename?: string | null;
source_base_path?: string | null;
migrate_legacy_mail_settings?: boolean;
base_revision?: number;
reconciliation_kind?: "none" | "auto_merge" | "manual";
resolved_conflict_paths?: string[];
};
export type CampaignPartialValidationPayload = {
@@ -330,6 +335,7 @@ export type CampaignSummary = {
issues?: Record<string, unknown>;
attachments?: Record<string, unknown>;
attempts?: Record<string, unknown>;
postbox_receipts?: Record<string, unknown>;
delivery?: Record<string, unknown>;
recent_failures?: Record<string, unknown>[];
};
@@ -468,8 +474,11 @@ export type CampaignReviewStatePayload = {
};
export type CampaignSendJobPayload = {
kind: "test" | "single_send" | "single_resend";
idempotency_key: string;
reason?: string;
context?: Record<string, unknown>;
include_warnings?: boolean;
dry_run?: boolean;
use_rate_limit?: boolean;
enqueue_imap_task?: boolean;
};
@@ -520,6 +529,7 @@ export type CampaignReportEmailPayload = {
attach_jobs_csv?: boolean;
attach_report_json?: boolean;
dry_run?: boolean;
idempotency_key?: string;
};
export type AggregateReportCount = {
@@ -797,10 +807,12 @@ export async function updateCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload)
payload: CampaignVersionUpdatePayload,
ifMatch: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload)
});
}
@@ -821,10 +833,12 @@ export async function autosaveCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload)
payload: CampaignVersionUpdatePayload,
ifMatch: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload)
});
}
@@ -1021,7 +1035,7 @@ export async function sendCampaignJob(
settings: ApiSettings,
campaignId: string,
jobId: string,
payload: CampaignSendJobPayload = {})
payload: CampaignSendJobPayload)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
method: "POST",

View File

@@ -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> }

View File

@@ -1,22 +1,26 @@
import { useEffect, useState } from "react";
import { lazy, useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { useGuardedNavigate } from "@govoplan/core-webui";
import {
ConcurrencyConflictProvider,
useGuardedNavigate
} from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
import ReviewSendPage from "./ReviewSendPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
const GlobalSettingsPage = lazy(() => import("./GlobalSettingsPage"));
const RecipientDataPage = lazy(() => import("./RecipientDataPage"));
const TemplateDataPage = lazy(() => import("./TemplateDataPage"));
const AttachmentsDataPage = lazy(() => import("./AttachmentsDataPage"));
const MailSettingsPage = lazy(() => import("./MailSettingsPage"));
const ReviewSendPage = lazy(() => import("./ReviewSendPage"));
const CreateWizard = lazy(() => import("./wizard/CreateWizard"));
const ReviewWizard = lazy(() => import("./wizard/ReviewWizard"));
const SendWizard = lazy(() => import("./wizard/SendWizard"));
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
@@ -37,7 +41,11 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
};
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
return <CampaignWorkspaceInner settings={settings} auth={auth} />;
return (
<ConcurrencyConflictProvider>
<CampaignWorkspaceInner settings={settings} auth={auth} />
</ConcurrencyConflictProvider>
);
}
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {

View File

@@ -36,7 +36,7 @@ import {
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
@@ -159,6 +159,8 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState<Set<string>>(() => new Set());
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
const [singleSendConfirmIndex, setSingleSendConfirmIndex] = useState<number | null>(null);
const [singleMessageActionKind, setSingleMessageActionKind] = useState<"test" | "single_send" | "single_resend">("single_send");
const [singleMessageResendReason, setSingleMessageResendReason] = useState("");
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [mockClearFirst, setMockClearFirst] = useState(true);
const [mockAppendSent, setMockAppendSent] = useState(true);
@@ -1103,8 +1105,11 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setError("");
try {
const response = await sendCampaignJob(settings, campaignId, jobId, {
kind: singleMessageActionKind,
idempotency_key: crypto.randomUUID(),
reason: singleMessageActionKind === "single_resend" ? singleMessageResendReason.trim() : undefined,
context: { surface: "campaign_review_preview" },
include_warnings: true,
dry_run: false,
use_rate_limit: true,
enqueue_imap_task: backgroundWorkersEnabled
});
@@ -1112,18 +1117,20 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const sendResult = asRecord(actionResult.result);
const status = String(sendResult.status ?? "submitted");
const attemptCount = Number(sendResult.attempt_number ?? row.attempt_count ?? 0);
setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ?
const finalSendStatus = String(actionResult.final_send_status ?? "");
if (singleMessageActionKind !== "test") setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ?
{
...item,
send_status: status === "already_accepted" ? "smtp_accepted" : status,
queue_status: ["smtp_accepted", "already_accepted"].includes(status) ? "draft" : item.queue_status,
send_status: finalSendStatus || (status === "already_accepted" ? "smtp_accepted" : status),
queue_status: ["smtp_accepted", "already_accepted", "accepted", "accepted_with_refusals"].includes(finalSendStatus || status) ? "draft" : item.queue_status,
imap_status: sendResult.imap_status ?? item.imap_status,
attempt_count: Number.isFinite(attemptCount) ? attemptCount : item.attempt_count,
last_error: sendResult.message ?? ""
} :
item));
setMessage(`Single message send finished: ${humanize(status)}.`);
setMessage(`${humanize(singleMessageActionKind)} finished: ${humanize(String(actionResult.status ?? status))}.`);
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setSelectedBuiltIndex(null);
await refreshQueueStatus(true);
await reload();
@@ -1146,6 +1153,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setNewlyReviewedRequiredKeys(new Set());
setSelectedBuiltIndex(null);
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setMockResult(null);
setSelectedMockMessage(null);
resetDeltaWatermark();
@@ -1660,7 +1668,18 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
canStartSingleMessageSend={canStartSingleMessageSend}
singleMessageSendBusy={busy === "send"}
onSelect={openBuiltMessageAtIndex}
onSendSingle={(targetIndex) => setSingleSendConfirmIndex(targetIndex)}
onSendSingle={(targetIndex) => {
const target = filteredBuiltReviewRows[targetIndex];
const status = String(target?.send_status ?? "not_queued");
setSingleMessageActionKind(
["not_queued", "cancelled", "queued"].includes(status)
&& Number(target?.attempt_count ?? 0) === 0
? "single_send"
: "single_resend"
);
setSingleMessageResendReason("");
setSingleSendConfirmIndex(targetIndex);
}}
onClose={() => setSelectedBuiltIndex(null)} />
}
@@ -1724,16 +1743,103 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
onCancel={() => setCancelDeliveryConfirmOpen(false)}
onConfirm={() => void runDeliveryControl("cancel")} />
<ConfirmDialog
<Dialog
open={singleSendConfirmRow !== null}
title="Send this one message now?"
message={singleSendConfirmRow ? `This sends only the currently selected built message to ${formatAddressList(asRecord(singleSendConfirmRow.resolved_recipients).to) || String(singleSendConfirmRow.recipient_email ?? "the resolved recipient")}. The send is recorded as a normal SMTP attempt in the delivery protocol.` : ""}
confirmLabel="Send one message"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
tone="danger"
busy={busy === "send"}
onCancel={() => setSingleSendConfirmIndex(null)}
onConfirm={() => void sendSingleBuiltMessage()} />
title="One-message delivery"
portal
closeDisabled={busy === "send"}
onClose={() => {
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
}}
footer={
<>
<Button
disabled={busy === "send"}
onClick={() => {
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
}}
>
i18n:govoplan-campaign.cancel.77dfd213
</Button>
<Button
variant="danger"
disabled={
busy === "send"
|| (singleMessageActionKind === "single_resend" && !singleMessageResendReason.trim())
}
onClick={() => void sendSingleBuiltMessage()}
>
{busy === "send" ? "Sending..." : "Confirm action"}
</Button>
</>
}
>
{singleSendConfirmRow && (
<div className="form-grid">
<SegmentedControl
value={singleMessageActionKind}
width="fill"
size="equal"
ariaLabel="One-message action"
options={[
{ id: "test", label: "Test" },
{
id: "single_send",
label: "Initial send",
disabled: !(
["not_queued", "cancelled", "queued"].includes(String(singleSendConfirmRow.send_status ?? "not_queued"))
&& Number(singleSendConfirmRow.attempt_count ?? 0) === 0
)
},
{
id: "single_resend",
label: "Resend",
disabled: ["claimed", "sending", "outcome_unknown"].includes(String(singleSendConfirmRow.send_status ?? ""))
}
]}
onChange={setSingleMessageActionKind}
/>
<p className="muted small-note">
{singleMessageActionKind === "test"
? "Transports this exact rendered message once without changing its official delivery state."
: singleMessageActionKind === "single_resend"
? "Transports this exact message again. A successful resend can complete it; a failed resend preserves an earlier success."
: "Makes the first official delivery attempt and changes only this message after SMTP acceptance."}
</p>
<dl className="detail-grid">
<div>
<dt>Recipient</dt>
<dd>{formatAddressList(asRecord(singleSendConfirmRow.resolved_recipients).to) || String(singleSendConfirmRow.recipient_email ?? "—")}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{String(version?.version_number ?? "—")}</dd>
</div>
<div>
<dt>Message</dt>
<dd><code>{String(singleSendConfirmRow.eml_sha256 ?? "—").slice(0, 16)}</code></dd>
</div>
<div>
<dt>State</dt>
<dd>{humanize(String(singleSendConfirmRow.send_status ?? "not_queued"))}</dd>
</div>
</dl>
{singleMessageActionKind === "single_resend" && (
<label>
Reason
<textarea
value={singleMessageResendReason}
maxLength={2000}
rows={3}
onChange={(event) => setSingleMessageResendReason(event.target.value)}
/>
</label>
)}
</div>
)}
</Dialog>
{selectedDeliveryJobDetail &&
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
@@ -1776,6 +1882,23 @@ reviewedKeys: Set<string>)
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
value: (row) => String(row.validation_status ?? "unknown")
},
{
id: "postboxTargets",
header: "Postbox targets",
width: 145,
align: "right",
sortable: true,
filterType: "integer",
value: (row) => Number(row.postbox_target_count ?? 0),
render: (row) => {
const count = Number(row.postbox_target_count ?? 0);
return count > 0 ? (
<StatusBadge status="info" label={`${count} frozen`} />
) : (
<span className="muted"></span>
);
}
},
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];

View File

@@ -1,6 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
revisionConflictFromError,
threeWayMerge,
useConcurrencyConflictResolver
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import {
autosaveCampaignVersion,
getCampaignVersion,
type CampaignVersionDetail,
type CampaignVersionUpdatePayload
} from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
@@ -36,6 +46,26 @@ function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a";
}
const CAMPAIGN_PROTECTED_MERGE_PATHS = [
"/current_flow",
"/current_step",
"/workflow_state",
"/is_complete",
"/editor_state",
"/source_filename",
"/source_base_path",
"/migrate_legacy_mail_settings",
"/campaign_json/delivery",
"/campaign_json/server",
"/campaign_json/security",
"/campaign_json/signatures",
"/campaign_json/encryption",
"/campaign_json/_retention",
"/campaign_json/campaign/owner_user_id",
"/campaign_json/campaign/owner_group_id",
"/campaign_json/campaign/status"
] as const;
export function useCampaignDraftEditor({
settings,
campaignId,
@@ -60,6 +90,10 @@ export function useCampaignDraftEditor({
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
const onLoadedRef = useRef(onLoaded);
const baseDraftRef = useRef<Record<string, unknown> | null>(null);
const baseRevisionRef = useRef<number | null>(null);
const baseEtagRef = useRef<string | null>(null);
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
useEffect(() => {
loadedLabelRef.current = loadedLabel;
@@ -77,6 +111,11 @@ export function useCampaignDraftEditor({
if (!version) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
baseDraftRef.current = (
transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft
);
baseRevisionRef.current = version.edit_revision;
baseEtagRef.current = version.strong_etag;
setDraft(loadedDraft);
setDirty(false);
setLocalError("");
@@ -102,15 +141,100 @@ export function useCampaignDraftEditor({
setLocalError("");
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draftToSave,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...(extraPayload?.() ?? {})
});
const additionalPayload = extraPayload?.() ?? {};
let mutation = campaignVersionMutation(
version,
draftToSave,
{
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...additionalPayload
}
);
let mergeBase = campaignVersionMutation(
version,
baseDraftRef.current ?? draftToSave
);
let expectedRevision = baseRevisionRef.current ?? version.edit_revision;
let expectedEtag = baseEtagRef.current ?? version.strong_etag;
let reconciliationKind: "none" | "auto_merge" | "manual" = "none";
let resolvedConflictPaths: string[] = [];
let saved: CampaignVersionDetail | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
...mutation,
base_revision: expectedRevision,
reconciliation_kind: reconciliationKind,
resolved_conflict_paths: resolvedConflictPaths
}, expectedEtag);
break;
} catch (error) {
const conflict = revisionConflictFromError(error);
if (!conflict) throw error;
const latest = await getCampaignVersion(
settings,
campaignId,
version.id
);
const latestLoaded = transformLoadedDraftRef.current?.(
latest,
ensureCampaignDraft(latest)
) ?? ensureCampaignDraft(latest);
const latestDraft = (
transformDraftBeforeSaveRef.current?.(latestLoaded) ?? latestLoaded
);
const latestMutation = campaignVersionMutation(
latest,
latestDraft
);
const merge = threeWayMerge(
mergeBase,
mutation,
latestMutation,
{
protectedPaths: CAMPAIGN_PROTECTED_MERGE_PATHS,
stableIdFields: ["id", "entry_id"]
}
);
if (merge.conflicts.length === 0) {
mutation = merge.value;
reconciliationKind = "auto_merge";
resolvedConflictPaths = merge.appliedPaths;
} else {
const resolution = await resolveConcurrencyConflict({
resourceLabel: `Campaign version ${latest.version_number}`,
merge
});
if (resolution.action === "cancel") return false;
if (resolution.action === "reload") {
setDirty(false);
await reload({ force: true });
return false;
}
mutation = resolution.value;
reconciliationKind = "manual";
resolvedConflictPaths = resolution.resolvedPaths;
}
mergeBase = latestMutation;
expectedRevision = latest.edit_revision;
expectedEtag = latest.strong_etag;
}
}
if (!saved) {
throw new Error(
"The campaign changed repeatedly while it was being saved. Reload and try again."
);
}
setDraft(getCampaignJson(saved));
baseDraftRef.current = getCampaignJson(saved);
baseRevisionRef.current = saved.edit_revision;
baseEtagRef.current = saved.strong_etag;
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
@@ -122,7 +246,7 @@ export function useCampaignDraftEditor({
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setError, settings, version, workflowState]);
const discardDraft = useCallback(async () => {
if (version) {
@@ -162,3 +286,23 @@ export function useCampaignDraftEditor({
saveDraft
};
}
function campaignVersionMutation(
version: CampaignVersionDetail,
campaignJson: Record<string, unknown>,
overrides: Partial<CampaignVersionUpdatePayload> = {}
): CampaignVersionUpdatePayload {
return {
campaign_json: campaignJson,
current_flow: overrides.current_flow ?? version.current_flow ?? null,
current_step: overrides.current_step ?? version.current_step ?? null,
workflow_state: overrides.workflow_state ?? version.workflow_state ?? null,
is_complete: overrides.is_complete ?? version.is_complete ?? false,
editor_state: overrides.editor_state ?? version.editor_state ?? {},
source_filename: overrides.source_filename ?? version.source_filename ?? null,
source_base_path: overrides.source_base_path ?? version.source_base_path ?? null,
migrate_legacy_mail_settings: (
overrides.migrate_legacy_mail_settings ?? false
)
};
}

View File

@@ -112,10 +112,10 @@ export default function BuiltMessagePreview({
<Button
variant="primary"
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Send only this built message"}
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
onClick={() => onSendSingle(index)}
>
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
{singleMessageSendBusy ? "Sending..." : "Message actions..."}
</Button>
}
onClose={onClose}
@@ -139,28 +139,15 @@ function singleMessageSendDisabledReason(
return `This message is ${humanize(validationStatus)}.`;
}
const sendStatus = String(row.send_status ?? "not_queued");
const queueStatus = String(row.queue_status ?? "draft");
if (["smtp_accepted", "sent"].includes(sendStatus)) {
return "This message was already accepted by SMTP.";
}
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
return `This message is in delivery state ${humanize(sendStatus)}.`;
}
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) {
return "Use the explicit retry action for failed messages.";
}
if (sendStatus === "queued" && queueStatus === "queued") return "";
if (
["not_queued", "cancelled"].includes(sendStatus) &&
["draft", "cancelled"].includes(queueStatus)
) {
return "";
}
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
return "";
}
function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
const postboxTargets = asArray(row.resolved_postbox_targets).map(asRecord);
return [
{
label: "i18n:govoplan-campaign.from.3f66052a",
@@ -182,6 +169,23 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
label: "i18n:govoplan-campaign.validation.dd74d182",
value: String(row.validation_status ?? "—")
},
{
label: "Frozen Postbox targets",
value: postboxTargets.length
? postboxTargets
.map((target) => {
const label = String(
target.name ?? target.address ?? target.postbox_id ?? "Postbox"
);
const address = String(target.address ?? "");
const context = target.context_key
? ` [${String(target.context_key)}]`
: "";
return `${label}${address && address !== label ? ` <${address}>` : ""}${context}`;
})
.join(", ")
: null
},
{
label: "i18n:govoplan-campaign.mime_size.c8b9d519",
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"