feat: harden campaign delivery and editing
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) }]} /> }];
|
||||
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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` : "—"
|
||||
|
||||
Reference in New Issue
Block a user