feat(campaign-ui): add explicit job retry controls

This commit is contained in:
2026-07-20 20:08:29 +02:00
parent 4b9eb79065
commit fce6dd1138
2 changed files with 142 additions and 5 deletions

View File

@@ -313,6 +313,8 @@ export type CampaignAttachmentPreviewFile = {
checksum_sha256?: string;
size_bytes?: number;
content_type?: string | null;
linked_to_campaign?: boolean;
share?: Record<string, unknown> | null;
};
export type CampaignAttachmentPreviewRule = {
@@ -334,6 +336,8 @@ export type CampaignAttachmentPreviewRule = {
zip_filename?: string | null;
matches: CampaignAttachmentPreviewFile[];
match_count: number;
linked_match_count?: number;
unlinked_match_count?: number;
issues: Record<string, unknown>[];
};
@@ -341,15 +345,37 @@ export type CampaignAttachmentPreviewResponse = {
campaign_id: string;
version_id: string;
shared_file_count: number;
candidate_file_count?: number;
matched_file_count?: number;
linked_file_count?: number;
unlinked_file_count?: number;
rules: CampaignAttachmentPreviewRule[];
linkable_files?: CampaignAttachmentPreviewFile[];
unused_shared_files: CampaignAttachmentPreviewFile[];
};
export type CampaignAttachmentPreviewPayload = {
include_unmatched?: boolean;
include_unlinked_candidates?: boolean;
campaign_json?: Record<string, unknown>;
};
export type CampaignAttachmentLinkMatchesPayload = {
campaign_json?: Record<string, unknown> | null;
dry_run?: boolean;
};
export type CampaignAttachmentLinkMatchesResponse = {
campaign_id: string;
version_id: string;
matched_file_count: number;
already_linked_file_count: number;
linked_file_count: number;
dry_run?: boolean;
linked_files: CampaignAttachmentPreviewFile[];
linkable_files: CampaignAttachmentPreviewFile[];
};
export type CampaignMockSendPayload = {
version_id?: string | null;
send?: boolean;
@@ -365,6 +391,13 @@ export type CampaignReviewStatePayload = {
reviewed_message_keys: string[];
};
export type CampaignSendJobPayload = {
include_warnings?: boolean;
dry_run?: boolean;
use_rate_limit?: boolean;
enqueue_imap_task?: boolean;
};
export type CampaignJobsQuery = {
versionId?: string;
@@ -702,11 +735,12 @@ versionId: string)
export async function validateVersion(
settings: ApiSettings,
versionId: string,
checkFiles = false)
checkFiles = false,
linkUnsharedMatches = false)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
method: "POST",
body: JSON.stringify({ check_files: checkFiles })
body: JSON.stringify({ check_files: checkFiles, link_unshared_matches: linkUnsharedMatches })
});
}
@@ -735,6 +769,19 @@ payload: CampaignAttachmentPreviewPayload = {})
);
}
export function linkCampaignAttachmentMatches(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignAttachmentLinkMatchesPayload = {})
: Promise<CampaignAttachmentLinkMatchesResponse> {
return apiFetch<CampaignAttachmentLinkMatchesResponse>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/link-matches`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function getCampaignSummary(
settings: ApiSettings,
campaignId: string,
@@ -846,6 +893,18 @@ payload: Record<string, unknown> = {})
});
}
export async function sendCampaignJob(
settings: ApiSettings,
campaignId: string,
jobId: string,
payload: CampaignSendJobPayload = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function resolveCampaignJobOutcome(
settings: ApiSettings,
campaignId: string,

View File

@@ -7,6 +7,7 @@ import {
getCampaignJobsDelta,
resolveCampaignJobOutcome,
retryCampaignJobs,
sendCampaignJob,
sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse,
type CampaignJobsResponse } from
@@ -155,7 +156,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}, [loadJobs]);
async function reloadAll() {
await Promise.all([reload(), loadJobs()]);
resetDeltaWatermark(jobsQueryKey);
jobPageCursorsRef.current = { 1: null };
jobsRef.current = emptyCampaignJobsResponse();
setJobs(emptyCampaignJobsResponse());
await Promise.all([reload({ force: true }), loadJobs()]);
}
async function runExplicitAction(action: "retry" | "unattempted") {
@@ -177,6 +182,67 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}
}
const failedRowsOnPage = useMemo(
() => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")),
[jobs.jobs]
);
async function retryFailedSynchronously(rows: Record<string, unknown>[]) {
if (!version || busyAction || rows.length === 0) return;
setBusyAction(rows.length === 1 ? `retry-sync:${String(rows[0].id ?? "")}` : "retry-sync-page");
setActionError("");
setActionMessage("");
let attempted = 0;
let accepted = 0;
let skipped = 0;
const failures: string[] = [];
try {
for (const row of rows) {
const jobId = String(row.id ?? "");
if (!jobId) {
skipped += 1;
continue;
}
const sendStatus = String(row.send_status ?? "");
const queueResponse = await retryCampaignJobs(settings, campaignId, {
version_id: version.id,
job_ids: [jobId],
include_permanent: sendStatus === "failed_permanent",
enqueue_celery: false
});
const queueResult = asRecord(queueResponse.result ?? queueResponse);
if (Number(queueResult.selected_count ?? 0) < 1) {
skipped += 1;
const skippedRows = Array.isArray(queueResult.skipped) ? queueResult.skipped.map(asRecord) : [];
const reason = String(skippedRows[0]?.reason ?? "not selected for retry");
failures.push(`${shortJobId(jobId)}: ${reason}`);
continue;
}
attempted += 1;
try {
const sendResponse = await sendCampaignJob(settings, campaignId, jobId, {
include_warnings: true,
dry_run: false,
use_rate_limit: true,
enqueue_imap_task: false
});
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
const status = String(sendResult.status ?? "submitted");
if (status === "smtp_accepted" || status === "already_accepted") accepted += 1;
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
} catch (err) {
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const failed = failures.length;
setActionMessage(`Synchronous retry finished: ${attempted} attempted, ${accepted} accepted, ${failed} failed, ${skipped} skipped.`);
if (failures.length > 0) setActionError(failures.slice(0, 5).join("\n"));
await reloadAll();
} finally {
setBusyAction("");
}
}
async function reconcileOutcome() {
if (!reconcile || busyAction) return;
setBusyAction("reconcile");
@@ -300,13 +366,14 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
return (
<div className="button-row compact-actions">
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
</div>);
}
}],
[busyAction]);
[busyAction, retryFailedSynchronously]);
return (
<div className="content-pad workspace-data-page">
@@ -318,7 +385,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div className="button-row compact-actions">
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Discard</Button>
</div>
</div>
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
@@ -350,6 +417,9 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
<div className="button-row compact-actions">
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
</Button>
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
</div>
</Card>
@@ -492,3 +562,11 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
</section>);
}
function retryableFailedStatus(status: string): boolean {
return status === "failed_temporary" || status === "failed_permanent";
}
function shortJobId(jobId: string): string {
return jobId.length > 12 ? `${jobId.slice(0, 12)}...` : jobId;
}