feat(campaign): complete durable operator queue

This commit is contained in:
2026-07-22 09:31:33 +02:00
parent f095a3e2c7
commit 99d44eeb8d
5 changed files with 833 additions and 82 deletions

View File

@@ -1,20 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Ban, ExternalLink, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings, CampaignListItem } from "../../types";
import {
cancelCampaign,
getCampaignJobs,
getCampaignSummary,
getCampaignWorkspaceDelta,
listCampaignsDelta,
pauseCampaign,
resumeCampaign,
retryCampaignJobs,
sendUnattemptedCampaignJobs,
type CampaignSummary
type CampaignJobsResponse,
type CampaignSummary,
type CampaignVersionListItem
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import {
DataGrid,
type DataGridColumn,
type DataGridListOption,
type DataGridQueryState
} from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
@@ -31,7 +41,11 @@ import {
} from "@govoplan/core-webui";
import { asRecord, formatDateTime } from "../campaigns/utils/campaignView";
import { deliveryModeLabel } from "../campaigns/utils/deliveryMode";
import { emptyCampaignJobsResponse } from "../campaigns/utils/jobDeltas";
import type { CampaignJobSortColumn } from "../campaigns/utils/jobListQuery";
import {
campaignLifecycleTotals,
campaignVersionWorkItems,
operatorQueueActionBlocks,
type OperatorQueueAction,
type OperatorQueuePermissions
@@ -39,6 +53,8 @@ import {
type OperatorRow = {
campaign: CampaignListItem;
version: CampaignVersionListItem | null;
isCurrent: boolean;
summary: CampaignSummary | null;
mode: string | null;
modeSelectedAt: string | null;
@@ -48,22 +64,64 @@ type OperatorRow = {
notAttempted: number;
queuedOrActive: number;
queued: number;
claimed: number;
sending: number;
completed: number;
pausable: number;
paused: number;
campaignPausable: number;
campaignPaused: number;
imapFailed: number;
queueable: number;
queueableUnattempted: number;
needsAttention: number;
cancellable: number;
campaignCancellable: number;
};
type QueueMutationAction = "pause" | "resume" | "retry" | "queue-unsent" | "cancel";
type CampaignLifecycleFacts = {pausable: number;paused: number;cancellable: number;};
const DEFAULT_JOB_GRID_QUERY: DataGridQueryState = {
sort: { columnId: "number", direction: "asc" },
filters: {}
};
const QUEUE_STATUS_OPTIONS: DataGridListOption[] = ["draft", "queued", "sending", "paused", "cancelled"]
.map((value) => ({ value, label: deliveryStatusOptionLabel(value) }));
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"not_queued",
"skipped",
"queued",
"claimed",
"sending",
"smtp_accepted",
"sent",
"outcome_unknown",
"failed_temporary",
"failed_permanent",
"cancelled"
].map((value) => ({ value, label: deliveryStatusOptionLabel(value) }));
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
"appending",
"appended",
"outcome_unknown",
"failed",
"skipped"
].map((value) => ({ value, label: deliveryStatusOptionLabel(value) }));
export default function OperatorQueuePage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const navigate = useGuardedNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const selectedCampaignId = searchParams.get("campaign") ?? "";
const selectedVersionParam = searchParams.get("version") ?? "";
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [rows, setRows] = useState<OperatorRow[]>([]);
const campaignsRef = useRef<CampaignListItem[]>([]);
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
const summariesRef = useRef<Record<string, Record<string, CampaignSummary | null>>>({});
const versionsRef = useRef<Record<string, CampaignVersionListItem[]>>({});
const loadingRef = useRef(false);
const backgroundLoadRef = useRef<() => Promise<void>>(async () => undefined);
const [loading, setLoading] = useState(true);
@@ -71,9 +129,20 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
const [message, setMessage] = useState("");
const [busy, setBusy] = useState("");
const [cancelTarget, setCancelTarget] = useState<OperatorRow | null>(null);
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const [jobsOwnerKey, setJobsOwnerKey] = useState("");
const [jobsLoading, setJobsLoading] = useState(false);
const [jobsError, setJobsError] = useState("");
const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(50);
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(DEFAULT_JOB_GRID_QUERY);
const jobsRequestRef = useRef(0);
const backgroundJobsLoadRef = useRef<() => Promise<void>>(async () => undefined);
const permissions = useMemo<OperatorQueuePermissions>(() => ({
canRead: hasScope(auth, "campaigns:campaign:read")
&& hasScope(auth, "campaigns:recipient:read"),
canOpenReport: hasScope(auth, "campaigns:campaign:read")
&& hasScope(auth, "campaigns:report:read")
&& hasScope(auth, "campaigns:recipient:read"),
canControl: hasScope(auth, "campaigns:campaign:control"),
@@ -94,26 +163,95 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}),
[settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
const selectedRow = useMemo(
() => rows.find((row) => row.campaign.id === selectedCampaignId && (
selectedVersionParam
? rowVersionId(row) === selectedVersionParam
: rowVersionId(row) === String(row.campaign.current_version_id ?? "")
)) ?? rows.find((row) => row.campaign.id === selectedCampaignId) ?? null,
[rows, selectedCampaignId, selectedVersionParam]
);
const selectedVersionId = rowVersionId(selectedRow);
const selectedJobsKey = selectedRow && selectedVersionId
? `${selectedRow.campaign.id}:${selectedVersionId}:${settingsKey}`
: "";
const selectCampaign = useCallback((campaignId: string, versionId = "") => {
const next = new URLSearchParams(searchParams);
if (campaignId) {
next.set("campaign", campaignId);
if (versionId) next.set("version", versionId);
else next.delete("version");
} else {
next.delete("campaign");
next.delete("version");
}
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const handleJobGridQuery = useCallback((next: DataGridQueryState) => {
setJobGridQuery((current) => dataGridQueriesEqual(current, next) ? current : next);
setJobPage(1);
}, []);
useEffect(() => {
campaignsRef.current = [];
summariesRef.current = {};
versionsRef.current = {};
resetDeltaWatermark();
void load();
}, [settingsKey, resetDeltaWatermark]);
useEffect(() => {
backgroundLoadRef.current = () => load(true);
backgroundJobsLoadRef.current = () => loadSelectedJobs(true);
});
const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0);
useEffect(() => {
if (!hasActiveDelivery) return;
if (!loading && selectedCampaignId && !selectedRow) selectCampaign("");
}, [loading, selectCampaign, selectedCampaignId, selectedRow]);
useEffect(() => {
if (!loading && selectedRow && selectedVersionParam !== rowVersionId(selectedRow)) {
selectCampaign(selectedRow.campaign.id, rowVersionId(selectedRow));
}
}, [loading, selectCampaign, selectedRow, selectedVersionParam]);
useEffect(() => {
jobsRequestRef.current += 1;
setJobs(emptyCampaignJobsResponse());
setJobsLoading(false);
setJobsError("");
setJobPage(1);
setJobGridQuery(DEFAULT_JOB_GRID_QUERY);
setJobsOwnerKey(selectedJobsKey);
}, [selectedJobsKey]);
const jobGridQueryKey = JSON.stringify(jobGridQuery);
useEffect(() => {
if (jobsOwnerKey === selectedJobsKey && selectedJobsKey) void loadSelectedJobs();
}, [jobsOwnerKey, selectedJobsKey, jobPage, jobPageSize, jobGridQueryKey, permissions.canRead]);
const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0 || row.needsAttention > 0);
const selectedJobsActive = jobs.jobs.some((job) => isActiveJob(job));
const selectedQueueActive = Boolean(
selectedRow && (selectedRow.queuedOrActive > 0 || selectedRow.needsAttention > 0)
) || selectedJobsActive;
useEffect(() => {
if (!hasActiveDelivery && !selectedQueueActive) return;
const handle = window.setInterval(() => {
void backgroundLoadRef.current();
if (selectedQueueActive) void backgroundJobsLoadRef.current();
}, 10_000);
return () => window.clearInterval(handle);
}, [hasActiveDelivery]);
}, [hasActiveDelivery, selectedQueueActive]);
useEffect(() => {
const handle = window.setInterval(() => {
void backgroundLoadRef.current();
}, 60_000);
return () => window.clearInterval(handle);
}, []);
async function load(background = false) {
if (loadingRef.current) return;
@@ -122,8 +260,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
setError("");
try {
const campaigns = await loadCampaignsDelta();
const summaries = await loadCampaignSummariesDelta(campaigns);
setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
setRows(await loadCampaignVersionRows(campaigns));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -132,6 +269,37 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
}
async function loadSelectedJobs(background = false) {
if (!selectedRow || !selectedVersionId || !permissions.canRead || jobsOwnerKey !== selectedJobsKey) return;
const requestId = ++jobsRequestRef.current;
if (!background) setJobsLoading(true);
setJobsError("");
try {
const response = await getCampaignJobs(settings, selectedRow.campaign.id, {
versionId: selectedVersionId,
page: jobPage,
pageSize: jobPageSize,
sortBy: campaignJobSortColumn(jobGridQuery.sort?.columnId),
sortDirection: jobGridQuery.sort?.direction ?? "asc",
filters: jobGridQuery.filters
});
if (requestId !== jobsRequestRef.current) return;
setJobs(response);
if (response.pages > 0 && jobPage > response.pages) setJobPage(response.pages);
} catch (err) {
if (requestId === jobsRequestRef.current) {
setJobsError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!background && requestId === jobsRequestRef.current) setJobsLoading(false);
}
}
async function refreshAll() {
await load();
await backgroundJobsLoadRef.current();
}
async function runAction(row: OperatorRow, action: QueueMutationAction) {
const key = `${action}:${row.campaign.id}`;
setBusy(key);
@@ -139,9 +307,15 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
setMessage("");
try {
const response = action === "retry"
? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
? await retryCampaignJobs(settings, row.campaign.id, {
version_id: rowVersionId(row),
enqueue_celery: true
})
: action === "queue-unsent"
? await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
? await sendUnattemptedCampaignJobs(settings, row.campaign.id, {
version_id: rowVersionId(row),
enqueue_celery: true
})
: action === "pause"
? await pauseCampaign(settings, row.campaign.id)
: action === "resume"
@@ -157,8 +331,8 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
));
if (action === "cancel") setCancelTarget(null);
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
await load();
resetDeltaWatermark(operatorCampaignWorkspaceKey(row.campaign.id));
await refreshAll();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -189,92 +363,190 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
return campaigns;
}
async function loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise<Record<string, CampaignSummary | null>> {
async function loadCampaignVersionRows(campaigns: CampaignListItem[]): Promise<OperatorRow[]> {
const summaries = { ...summariesRef.current };
const versions = { ...versionsRef.current };
await Promise.all(campaigns.map(async (campaign) => {
const key = operatorCampaignSummaryKey(campaign.id);
const key = operatorCampaignWorkspaceKey(campaign.id);
let nextWatermark = getDeltaWatermark(key);
let summary = summaries[campaign.id] ?? null;
let campaignVersions = versions[campaign.id] ?? [];
let hasMore = false;
try {
do {
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
includeCurrentVersion: false,
includeVersions: false,
includeSummary: true,
includeVersions: true,
includeSummary: false,
since: nextWatermark
});
if (response.full || response.summary) summary = response.summary;
campaignVersions = response.full
? response.versions
: mergeDeltaRows(
campaignVersions,
response.versions,
response.deleted,
(version) => version.id,
{
deletedResourceType: "campaign_version",
sort: sortVersionsNewestFirst
}
);
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
summaries[campaign.id] = summary;
versions[campaign.id] = campaignVersions;
const campaignSummaries = { ...(summaries[campaign.id] ?? {}) };
await Promise.all(campaignVersions.map(async (version) => {
try {
campaignSummaries[version.id] = await getCampaignSummary(settings, campaign.id, version.id);
} catch {
campaignSummaries[version.id] = campaignSummaries[version.id] ?? null;
}
}));
const versionIds = new Set(campaignVersions.map((version) => version.id));
for (const versionId of Object.keys(campaignSummaries)) {
if (!versionIds.has(versionId)) delete campaignSummaries[versionId];
}
summaries[campaign.id] = campaignSummaries;
} catch {
summaries[campaign.id] = summary;
versions[campaign.id] = campaignVersions;
}
}));
const campaignIds = new Set(campaigns.map((campaign) => campaign.id));
for (const campaignId of Object.keys(versions)) {
if (!campaignIds.has(campaignId)) {
delete versions[campaignId];
delete summaries[campaignId];
}
}
versionsRef.current = versions;
summariesRef.current = summaries;
return summaries;
return campaigns.flatMap((campaign) => toVersionRows(
campaign,
versions[campaign.id] ?? [],
summaries[campaign.id] ?? {}
));
}
function operatorCampaignListKey(): string {
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
}
function operatorCampaignSummaryKey(campaignId: string): string {
return JSON.stringify({ scope: "operator-campaign-summary", campaignId, settingsKey });
function operatorCampaignWorkspaceKey(campaignId: string): string {
return JSON.stringify({ scope: "operator-campaign-workspace", campaignId, settingsKey });
}
const totals = rows.reduce((acc, row) => ({
failed: acc.failed + row.failed,
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
notAttempted: acc.notAttempted + row.notAttempted,
queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
queued: acc.queued + row.queued,
claimed: acc.claimed + row.claimed,
sending: acc.sending + row.sending,
completed: acc.completed + row.completed,
paused: acc.paused + row.paused,
imapFailed: acc.imapFailed + row.imapFailed
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, paused: 0, imapFailed: 0 });
}), {
failed: 0,
outcomeUnknown: 0,
notAttempted: 0,
queued: 0,
claimed: 0,
sending: 0,
completed: 0,
paused: 0,
imapFailed: 0
});
const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [
{ id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
{
id: "version",
header: "i18n:govoplan-campaign.version.2da600bf",
width: 155,
sortable: true,
render: (row) => (
<div className="recipient-outcome-cell">
<strong>{campaignVersionIdentity(row)}</strong>
<span><StatusBadge status={row.isCurrent ? "current" : "historical"} label={row.isCurrent
? "i18n:govoplan-campaign.current.4fc0e2bc"
: "i18n:govoplan-campaign.historical.bb2fe205"} /></span>
</div>
),
value: (row) => campaignVersionIdentity(row),
sortValue: (row) => row.version?.version_number ?? row.summary?.current_version?.version_number ?? -1
},
{ id: "mode", header: "i18n:govoplan-campaign.mode.a7b93d21", width: 155, sortable: true, filterable: true, value: (row) => deliveryModeLabel(row.mode), sortValue: (row) => row.mode ?? "" },
{ id: "attention", header: "i18n:govoplan-campaign.attention.74e0b9c8", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
{ id: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
{ id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
{ id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
{ id: "queued", header: "i18n:govoplan-campaign.queued_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
{ id: "queued", header: "i18n:govoplan-campaign.queued.6a599877", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.queued },
{ id: "claimed", header: "i18n:govoplan-campaign.claimed.83c87884", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.claimed },
{ id: "sending", header: "i18n:govoplan-campaign.sending.ceafde86", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.sending },
{ id: "completed", header: "i18n:govoplan-campaign.completed.1798b3ba", width: 115, align: "right", sortable: true, filterType: "integer", value: (row) => row.completed },
{ id: "paused", header: "i18n:govoplan-campaign.paused.c7dfb6f1", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.paused },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.modeSelectedAt ?? row.campaign.updated_at), sortValue: (row) => row.modeSelectedAt ?? row.campaign.updated_at ?? "" },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at), sortValue: (row) => row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at ?? "" },
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 330,
sticky: "end",
render: (row) => {
const blocks = operatorQueueActionBlocks(row, permissions);
const blocks = operatorQueueActionBlocks({
retryable: row.retryable,
outcomeUnknown: row.outcomeUnknown,
queueableUnattempted: row.queueableUnattempted,
pausable: row.campaignPausable,
paused: row.campaignPaused,
cancellable: row.campaignCancellable
}, permissions);
const disabledReason = (action: OperatorQueueAction) =>
blocks[action] ?? (busy && action !== "details"
? "i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea"
: undefined);
const detailsDisabledReason = disabledReason("details")
?? (!rowVersionId(row)
? "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b"
: row.campaign.id === selectedCampaignId && rowVersionId(row) === selectedVersionParam
? "i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8"
: undefined);
const reportDisabledReason = disabledReason("report")
?? (!rowVersionId(row)
? "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b"
: undefined);
return <TableActionGroup actions={[
{
id: "details",
label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }),
icon: <Eye aria-hidden="true" />,
variant: row.campaign.id === selectedCampaignId && rowVersionId(row) === selectedVersionParam ? "primary" : "secondary",
disabledReason: detailsDisabledReason,
onClick: () => selectCampaign(row.campaign.id, rowVersionId(row))
},
{
id: "report",
label: "i18n:govoplan-campaign.details.dc3decbb",
icon: <ExternalLink aria-hidden="true" />,
disabledReason: disabledReason("details"),
onClick: () => navigate(`/campaigns/${row.campaign.id}/report`)
disabledReason: reportDisabledReason,
onClick: () => {
const versionId = rowVersionId(row);
if (versionId) navigate(`/campaigns/${row.campaign.id}/report?version=${encodeURIComponent(versionId)}`);
}
},
{
id: "pause",
label: "i18n:govoplan-campaign.pause_queue.b66e7696",
label: "i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf",
icon: <Pause aria-hidden="true" />,
disabledReason: disabledReason("pause"),
onClick: () => void runAction(row, "pause")
},
{
id: "resume",
label: "i18n:govoplan-campaign.resume_queue.1f22f964",
label: "i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a",
icon: <Play aria-hidden="true" />,
disabledReason: disabledReason("resume"),
onClick: () => void runAction(row, "resume")
@@ -298,11 +570,11 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
label: "i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7",
icon: <ShieldCheck aria-hidden="true" />,
disabledReason: disabledReason("reconcile"),
onClick: () => navigate(`/campaigns/${row.campaign.id}/report?send_status=outcome_unknown`)
onClick: () => navigate(`/campaigns/${row.campaign.id}/report?version=${encodeURIComponent(rowVersionId(row))}&send_status=outcome_unknown`)
},
{
id: "cancel",
label: "i18n:govoplan-campaign.cancel_queued_work.a54fb955",
label: "i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b",
icon: <Ban aria-hidden="true" />,
variant: "danger",
disabledReason: disabledReason("cancel"),
@@ -312,7 +584,135 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
}],
[busy, navigate, permissions]);
[busy, navigate, permissions, selectCampaign, selectedCampaignId, selectedVersionParam]);
const jobColumns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
{
id: "number",
header: "i18n:govoplan-campaign.job.30c8cb83",
width: 150,
sticky: "start",
sortable: true,
render: (job) => (
<div className="recipient-outcome-cell">
<strong>{shortSafeId(String(job.id ?? ""))}</strong>
<span>{jobEntryIdentity(job)}</span>
</div>
),
value: (job) => Number(job.entry_index ?? 0)
},
{
id: "version",
header: "i18n:govoplan-campaign.version.2da600bf",
width: 145,
render: (job) => jobVersionIdentity(job, selectedRow),
value: (job) => jobVersionIdentity(job, selectedRow)
},
{
id: "recipient",
header: "i18n:govoplan-campaign.recipient.90343260",
width: 240,
resizable: true,
sortable: true,
filterable: true,
value: (job) => String(job.recipient_email ?? "—")
},
{
id: "queue",
header: "i18n:govoplan-campaign.queue.d325fcd9",
width: 125,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: QUEUE_STATUS_OPTIONS, display: "pill" },
render: (job) => <StatusBadge status={String(job.queue_status ?? "unknown")} label={deliveryStatusOptionLabel(String(job.queue_status ?? "unknown"))} />,
value: (job) => String(job.queue_status ?? "unknown")
},
{
id: "send",
header: "i18n:govoplan-campaign.smtp.efff9cca",
width: 150,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: SEND_STATUS_OPTIONS, display: "pill" },
render: (job) => <StatusBadge status={String(job.send_status ?? "unknown")} label={deliveryStatusOptionLabel(String(job.send_status ?? "unknown"))} />,
value: (job) => String(job.send_status ?? "unknown")
},
{
id: "imap",
header: "i18n:govoplan-campaign.imap.271f9ef2",
width: 135,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: IMAP_STATUS_OPTIONS, display: "pill" },
render: (job) => <StatusBadge status={String(job.imap_status ?? "unknown")} label={deliveryStatusOptionLabel(String(job.imap_status ?? "unknown"))} />,
value: (job) => String(job.imap_status ?? "unknown")
},
{
id: "attempts",
header: "i18n:govoplan-campaign.attempts.5a29585e",
width: 105,
align: "right",
sortable: true,
filterable: true,
filterType: "integer",
value: (job) => Number(job.attempt_count ?? 0)
},
{
id: "queued-at",
header: "i18n:govoplan-campaign.queued_at.1ac179b3",
width: 175,
render: (job) => formatDateTime(String(job.queued_at ?? "")),
value: (job) => String(job.queued_at ?? "")
},
{
id: "sent-at",
header: "i18n:govoplan-campaign.sent_at.a6f45556",
width: 175,
render: (job) => formatDateTime(String(job.sent_at ?? "")),
value: (job) => String(job.sent_at ?? "")
},
{
id: "updated",
header: "i18n:govoplan-campaign.updated.f2f8570d",
width: 175,
sortable: true,
render: (job) => formatDateTime(String(job.updated_at ?? "")),
value: (job) => String(job.updated_at ?? "")
},
{
id: "result",
header: "i18n:govoplan-campaign.last_result.110b888b",
width: "minmax(220px, 1fr)",
minWidth: 200,
resizable: true,
render: (job) => {
const result = durableResultText(job.last_error) ?? deliveryResultSummary(job);
return <span className={result === "—" ? "muted" : "recipient-outcome-error"}>{result}</span>;
},
value: (job) => durableResultText(job.last_error) ?? deliveryResultSummary(job)
},
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 72,
sticky: "end",
align: "right",
render: (job) => <TableActionGroup actions={[{
id: "open-recipient-report",
label: "i18n:govoplan-campaign.open_recipient_report.d6892ee4",
icon: <ExternalLink aria-hidden="true" />,
disabledReason: permissions.canOpenReport
? undefined
: "i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6",
onClick: () => {
if (selectedRow && selectedVersionId) navigate(recipientReportPath(selectedRow, selectedVersionId, job));
}
}]} />
}
], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]);
return (
<div className="content-pad workspace-data-page">
@@ -323,23 +723,27 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
<p className="muted small-note">i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
<Button onClick={() => void refreshAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{jobsError && <DismissibleAlert tone="danger" resetKey={jobsError} floating>{jobsError}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
<QueuePressureGrid items={[
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
{ label: "i18n:govoplan-campaign.paused.c7dfb6f1", value: totals.paused, tone: "neutral" },
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
} />
<div className="metric-grid queue-pressure-grid">
<MetricCard label="i18n:govoplan-campaign.queued.6a599877" value={totals.queued} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.claimed.83c87884" value={totals.claimed} tone="info" />
<MetricCard label="i18n:govoplan-campaign.sending.ceafde86" value={totals.sending} tone="info" />
<MetricCard label="i18n:govoplan-campaign.completed.1798b3ba" value={totals.completed} tone="good" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={totals.failed} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={totals.outcomeUnknown} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.unattempted.e7411dd6" value={totals.notAttempted} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.paused.c7dfb6f1" value={totals.paused} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.imap_failed.50dbca55" value={totals.imapFailed} tone="warning" />
</div>
</section>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
@@ -348,17 +752,52 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
id="operator-queue-campaigns"
rows={rows}
columns={columns}
getRowKey={(row) => row.campaign.id}
getRowKey={(row) => `${row.campaign.id}:${rowVersionId(row) || "none"}`}
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
className="data-table compact-table" />
</Card>
</LoadingFrame>
{selectedRow && (
<Card title={i18nMessage("i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70", { value0: selectedRow.campaign.name })}>
<p className="muted small-note">{campaignVersionIdentity(selectedRow)}</p>
{!permissions.canRead ? (
<p>i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6</p>
) : (
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
<DataGrid<Record<string, unknown>>
id={`operator-queue-jobs-${selectedRow.campaign.id}-${selectedVersionId ?? "none"}`}
rows={jobs.jobs}
columns={jobColumns}
getRowKey={(job) => String(job.id ?? "")}
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5"
query={jobGridQuery}
pagination={{
mode: "server",
page: jobPage,
pageSize: jobPageSize,
totalRows: jobs.total,
pageSizeOptions: [25, 50, 100, 200],
disabled: jobsLoading,
onPageChange: setJobPage,
onPageSizeChange: (nextPageSize) => {
setJobPageSize(nextPageSize);
setJobPage(1);
}
}}
onQueryChange={handleJobGridQuery}
/>
</LoadingFrame>
)}
</Card>
)}
<ConfirmDialog
open={Boolean(cancelTarget)}
title="i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e"
message="i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb"
confirmLabel="i18n:govoplan-campaign.cancel_queued_work.a54fb955"
message="i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34"
confirmLabel="i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b"
tone="danger"
busy={Boolean(busy)}
onConfirm={() => {
@@ -369,31 +808,77 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
function toVersionRows(
campaign: CampaignListItem,
versions: CampaignVersionListItem[],
summaries: Record<string, CampaignSummary | null>
): OperatorRow[] {
if (versions.length === 0) return [toRow(campaign, null, false, null, { pausable: 0, paused: 0, cancellable: 0 })];
const lifecycle = campaignLifecycleTotals(versions.map((version) => {
const summary = summaries[version.id];
const cards = asRecord(summary?.cards);
const queueCounts = asRecord(asRecord(summary?.status_counts).queue);
return {
pausable: numberValue(queueCounts.queued),
paused: numberValue(queueCounts.paused),
cancellable: numberValue(cards.cancellable)
};
}));
const workItems = campaignVersionWorkItems(
campaign.id,
campaign.current_version_id,
versions,
summaries
);
return workItems.map((item) => {
const version = versions.find((candidate) => candidate.id === item.versionId) ?? null;
return toRow(campaign, version, item.isCurrent, item.summary, lifecycle);
});
}
function toRow(
campaign: CampaignListItem,
version: CampaignVersionListItem | null,
isCurrent: boolean,
summary: CampaignSummary | null,
lifecycle: CampaignLifecycleFacts
): OperatorRow {
const cards = asRecord(summary?.cards);
const statusCounts = asRecord(summary?.status_counts);
const queueCounts = asRecord(statusCounts.queue);
const queued = numberValue(queueCounts.queued);
const paused = numberValue(queueCounts.paused);
const sendCounts = asRecord(statusCounts.send);
const queued = numberValue(sendCounts.queued);
const claimed = numberValue(sendCounts.claimed);
const sending = numberValue(sendCounts.sending);
const completed = numberValue(sendCounts.smtp_accepted) + numberValue(sendCounts.sent);
const failed = numberValue(cards.failed);
const notAttempted = numberValue(cards.not_attempted);
return {
campaign,
version,
isCurrent,
summary,
mode: summary?.current_version?.delivery_mode ?? null,
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? null,
mode: summary?.current_version?.delivery_mode ?? version?.delivery_mode ?? null,
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? version?.delivery_mode_selected_at ?? null,
failed,
retryable: numberValue(cards.retryable),
outcomeUnknown: numberValue(cards.outcome_unknown),
notAttempted,
queuedOrActive: numberValue(cards.queued_or_active),
queuedOrActive: queued + claimed + sending,
queued,
paused,
claimed,
sending,
completed,
pausable: numberValue(queueCounts.queued),
paused: numberValue(queueCounts.paused),
campaignPausable: lifecycle.pausable,
campaignPaused: lifecycle.paused,
imapFailed: numberValue(cards.imap_failed),
queueable: numberValue(cards.queueable),
queueableUnattempted: numberValue(cards.queueable_unattempted),
needsAttention: numberValue(cards.needs_attention),
cancellable: numberValue(cards.cancellable)
cancellable: numberValue(cards.cancellable),
campaignCancellable: lifecycle.cancellable
};
}
@@ -402,11 +887,11 @@ function numberValue(value: unknown): number {
}
function actionLabel(action: QueueMutationAction): string {
if (action === "pause") return "i18n:govoplan-campaign.pause_queue.b66e7696";
if (action === "resume") return "i18n:govoplan-campaign.resume_queue.1f22f964";
if (action === "pause") return "i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf";
if (action === "resume") return "i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a";
if (action === "retry") return "i18n:govoplan-campaign.retry.9f5cd8a2";
if (action === "queue-unsent") return "i18n:govoplan-campaign.queue_unsent.b0e98610";
return "i18n:govoplan-campaign.cancel_queued_work.a54fb955";
return "i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b";
}
function actionChangedCount(action: QueueMutationAction, result: Record<string, unknown>): number {
@@ -422,12 +907,104 @@ function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListI
);
}
function QueuePressureGrid({ items }: {items: Array<{label: string;value: number;tone: "neutral" | "good" | "warning" | "danger" | "info";}>;}) {
return (
<div className="metric-grid queue-pressure-grid">
{items.map((item) =>
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
)}
</div>);
function sortVersionsNewestFirst(left: CampaignVersionListItem, right: CampaignVersionListItem): number {
return right.version_number - left.version_number || right.id.localeCompare(left.id);
}
function rowVersionId(row: OperatorRow | null): string {
if (!row) return "";
return String(row.version?.id ?? row.summary?.current_version?.id ?? row.campaign.current_version_id ?? "");
}
function campaignVersionIdentity(row: OperatorRow): string {
const versionId = rowVersionId(row);
const versionNumber = row.version?.version_number ?? row.summary?.current_version?.version_number;
if (!versionId) return "—";
return `${versionNumber == null ? "#—" : `#${versionNumber}`} · ${shortSafeId(versionId)}`;
}
function jobVersionIdentity(job: Record<string, unknown>, row: OperatorRow | null): string {
const versionId = String(job.campaign_version_id ?? rowVersionId(row) ?? "");
const versionNumber = row?.version?.version_number ?? row?.summary?.current_version?.version_number;
if (!versionId) return "—";
return `${versionNumber == null ? "#—" : `#${versionNumber}`} · ${shortSafeId(versionId)}`;
}
function shortSafeId(value: string): string {
const normalized = value.trim();
if (!normalized) return "—";
return normalized.length > 8 ? `${normalized.slice(0, 8)}` : normalized;
}
function jobEntryIdentity(job: Record<string, unknown>): string {
const entryId = String(job.entry_id ?? "").trim();
if (entryId) return entryId;
const entryIndex = Number(job.entry_index ?? 0);
return i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: entryIndex || 1 });
}
function deliveryResultSummary(job: Record<string, unknown>): string {
const sendStatus = String(job.send_status ?? "");
if (sendStatus && sendStatus !== "not_queued") return deliveryStatusOptionLabel(sendStatus);
const imapStatus = String(job.imap_status ?? "");
if (imapStatus && imapStatus !== "not_requested") return deliveryStatusOptionLabel(imapStatus);
return "—";
}
function durableResultText(value: unknown): string | null {
const normalized = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
if (!normalized) return null;
return normalized.length > 240 ? `${normalized.slice(0, 239)}` : normalized;
}
function recipientReportPath(row: OperatorRow, versionId: string, job: Record<string, unknown>): string {
const entryId = String(job.entry_id ?? "").trim();
const recipient = String(job.recipient_email ?? "").trim();
const params = new URLSearchParams({ version: versionId, q: entryId || recipient });
return `/campaigns/${row.campaign.id}/report?${params.toString()}`;
}
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") {
return value;
}
return "number";
}
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function isActiveJob(job: Record<string, unknown>): boolean {
return ["queued", "claimed", "sending"].includes(String(job.send_status ?? ""));
}
function deliveryStatusOptionLabel(value: string): string {
const labels: Record<string, string> = {
draft: "i18n:govoplan-campaign.draft.23d33e22",
queued: "i18n:govoplan-campaign.queued.6a599877",
sending: "i18n:govoplan-campaign.sending.ceafde86",
paused: "i18n:govoplan-campaign.paused.c7dfb6f1",
cancelled: "i18n:govoplan-campaign.cancelled.a1bf92ef",
not_queued: "i18n:govoplan-campaign.not_queued.b7f41e1e",
skipped: "i18n:govoplan-campaign.skipped.5a000ad7",
claimed: "i18n:govoplan-campaign.claimed.83c87884",
smtp_accepted: "i18n:govoplan-campaign.smtp_accepted.e3aa7603",
sent: "i18n:govoplan-campaign.sent.35f49dcf",
outcome_unknown: "i18n:govoplan-campaign.outcome_unknown.6e929fca",
failed_temporary: "i18n:govoplan-campaign.temporarily_failed.50d9b1a8",
failed_permanent: "i18n:govoplan-campaign.permanently_failed.32eda23a",
not_requested: "i18n:govoplan-campaign.not_requested.a0f3bce4",
pending: "i18n:govoplan-campaign.pending.96f608c1",
appending: "i18n:govoplan-campaign.appending.167de6b2",
appended: "i18n:govoplan-campaign.appended.979f5a82",
failed: "i18n:govoplan-campaign.failed.09fef5d8"
};
return labels[value] ?? value;
}

View File

@@ -1,5 +1,6 @@
export type OperatorQueueAction =
| "details"
| "report"
| "pause"
| "resume"
| "retry"
@@ -9,6 +10,7 @@ export type OperatorQueueAction =
export type OperatorQueuePermissions = {
canRead: boolean;
canOpenReport: boolean;
canControl: boolean;
canRetry: boolean;
canQueue: boolean;
@@ -19,7 +21,7 @@ export type OperatorQueueFacts = {
retryable: number;
outcomeUnknown: number;
queueableUnattempted: number;
queued: number;
pausable: number;
paused: number;
cancellable: number;
};
@@ -34,6 +36,40 @@ export const OPERATOR_QUEUE_REASON = {
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
} as const;
export type CampaignVersionWorkItem<T> = {
campaignId: string;
versionId: string;
versionNumber: number;
isCurrent: boolean;
summary: T | null;
};
/** Preserve every audit version so work does not disappear when a new draft becomes current. */
export function campaignVersionWorkItems<T>(
campaignId: string,
currentVersionId: string | null | undefined,
versions: Array<{id: string;version_number: number;}>,
summaries: Record<string, T | null>
): CampaignVersionWorkItem<T>[] {
return versions.map((version) => ({
campaignId,
versionId: version.id,
versionNumber: version.version_number,
isCurrent: version.id === currentVersionId,
summary: summaries[version.id] ?? null
}));
}
export type CampaignLifecycleTotals = {pausable: number;paused: number;cancellable: number;};
export function campaignLifecycleTotals(items: CampaignLifecycleTotals[]): CampaignLifecycleTotals {
return items.reduce((totals, item) => ({
pausable: totals.pausable + item.pausable,
paused: totals.paused + item.paused,
cancellable: totals.cancellable + item.cancellable
}), { pausable: 0, paused: 0, cancellable: 0 });
}
/**
* Return the reason each fixed queue action is unavailable. A null result means
* the action is permitted and meaningful for the current queue projection.
@@ -44,9 +80,10 @@ export function operatorQueueActionBlocks(
): Record<OperatorQueueAction, string | null> {
return {
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permissionDenied,
report: permissions.canOpenReport ? null : OPERATOR_QUEUE_REASON.permissionDenied,
pause: !permissions.canControl
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.queued > 0
: facts.pausable > 0
? null
: OPERATOR_QUEUE_REASON.noQueued,
resume: !permissions.canControl

View File

@@ -4,22 +4,40 @@ export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Appended",
"i18n:govoplan-campaign.appending.167de6b2": "Appending",
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Another queue action is still running.",
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Cancel eligible queued work?",
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Cancel all eligible queued work for this Campaign",
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Cancel queued work",
"i18n:govoplan-campaign.claimed.83c87884": "Claimed",
"i18n:govoplan-campaign.current.4fc0e2bc": "Current",
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Delivery jobs for {value0}",
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Eligible jobs that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Eligible jobs in every version of this Campaign that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
"i18n:govoplan-campaign.historical.bb2fe205": "Historical",
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "No cancellable queued work remains.",
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "No failed jobs are eligible for retry.",
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "No paused jobs can be resumed.",
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "No queued jobs can be paused.",
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "No uncertain outcomes require reconciliation.",
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "No unsent jobs are available to queue.",
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Not requested",
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Open recipient report",
"i18n:govoplan-campaign.pause_queue.b66e7696": "Pause queue",
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Pause all queued work for this Campaign",
"i18n:govoplan-campaign.paused.c7dfb6f1": "Paused",
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Queue action for {value0} completed: {value1} ({value2}).",
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Queue refreshes automatically while delivery is active.",
"i18n:govoplan-campaign.queued_at.1ac179b3": "Queued at",
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Reconcile outcomes",
"i18n:govoplan-campaign.resume_queue.1f22f964": "Resume queue",
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Resume all paused work for this Campaign",
"i18n:govoplan-campaign.sending.ceafde86": "Sending",
"i18n:govoplan-campaign.sent_at.a6f45556": "Sent at",
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Temporarily failed",
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Permanently failed",
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "This Campaign queue is already selected.",
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "You do not have permission for this action.",
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Campaign reports available to you",
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "Cancelled {value0} unsent message(s); {value1} protected outcome(s) and {value2} excluded/skipped message(s) were retained.",
@@ -1269,22 +1287,40 @@ export const generatedTranslations: PlatformTranslations = {
"de": {
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
"i18n:govoplan-campaign.appending.167de6b2": "Wird abgelegt",
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Eine andere Warteschlangenaktion wird noch ausgeführt.",
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Geeignete Warteschlangenaufträge abbrechen?",
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Alle geeigneten Warteschlangenaufträge dieser Kampagne abbrechen",
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Warteschlangenaufträge abbrechen",
"i18n:govoplan-campaign.claimed.83c87884": "Beansprucht",
"i18n:govoplan-campaign.current.4fc0e2bc": "Aktuell",
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Versandaufträge für {value0}",
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Geeignete Aufträge, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Geeignete Aufträge in allen Versionen dieser Kampagne, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
"i18n:govoplan-campaign.historical.bb2fe205": "Historisch",
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "Es sind keine abbrechbaren Warteschlangenaufträge mehr vorhanden.",
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "Keine fehlgeschlagenen Aufträge können erneut versucht werden.",
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "Es sind keine pausierten Aufträge zum Fortsetzen vorhanden.",
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "Es sind keine wartenden Aufträge zum Pausieren vorhanden.",
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "Es müssen keine ungeklärten Ergebnisse abgestimmt werden.",
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "Es sind keine ungesendeten Aufträge für die Warteschlange verfügbar.",
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Nicht angefordert",
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Empfängerbezogenen Bericht öffnen",
"i18n:govoplan-campaign.pause_queue.b66e7696": "Warteschlange pausieren",
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Alle eingereihten Aufträge dieser Kampagne pausieren",
"i18n:govoplan-campaign.paused.c7dfb6f1": "Pausiert",
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Warteschlangenaktion für {value0} abgeschlossen: {value1} ({value2}).",
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Die Warteschlange wird während eines laufenden Versands automatisch aktualisiert.",
"i18n:govoplan-campaign.queued_at.1ac179b3": "Eingereiht am",
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Ergebnisse abstimmen",
"i18n:govoplan-campaign.resume_queue.1f22f964": "Warteschlange fortsetzen",
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Alle pausierten Aufträge dieser Kampagne fortsetzen",
"i18n:govoplan-campaign.sending.ceafde86": "Im Versand",
"i18n:govoplan-campaign.sent_at.a6f45556": "Gesendet am",
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Vorübergehend fehlgeschlagen",
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Endgültig fehlgeschlagen",
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "Diese Kampagnenwarteschlange ist bereits ausgewählt.",
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "Sie haben keine Berechtigung für diese Aktion.",
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Für Sie verfügbare Kampagnenberichte",
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "{value0} ungesendete Nachricht(en) wurden abgebrochen; {value1} geschützte Ergebnisse und {value2} ausgeschlossene/übersprungene Nachricht(en) wurden beibehalten.",
@@ -1583,7 +1619,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.can_view.1b3e4006": "Can view",
"i18n:govoplan-campaign.cancel_filename_changes.b558598e": "Cancel filename changes",
"i18n:govoplan-campaign.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Cancelled",
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Abgebrochen",
"i18n:govoplan-campaign.captured_mailbox_preview_is_disabled_for_this_ru.6559458d": "Captured mailbox preview is disabled for this run. Recipient outcomes remain available.",
"i18n:govoplan-campaign.captured_mailbox_preview_requires_the_mail_devel.9b3d9f0d": "Captured mailbox preview requires the Mail development mailbox API.",
"i18n:govoplan-campaign.captured_messages.a833d293": "Captured messages",
@@ -1615,7 +1651,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.compatibility_notes.7d648a6d": "Compatibility notes",
"i18n:govoplan-campaign.complete_a_successful_mock_delivery_first.bc2a587d": "Complete a successful mock delivery first.",
"i18n:govoplan-campaign.complete_review.4c2ed8c8": "Complete review",
"i18n:govoplan-campaign.completed.1798b3ba": "Completed",
"i18n:govoplan-campaign.completed.1798b3ba": "Abgeschlossen",
"i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b": "Compose the subject and body. Merge fields can later be inserted from the field picker.",
"i18n:govoplan-campaign.concurrency.2ec390bf": "Concurrency",
"i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2": "Configure campaign-wide attachment behavior and global matching rules.",
@@ -2018,7 +2054,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.not_claimed.aa712c1b": "Not claimed",
"i18n:govoplan-campaign.not_configured.67f2141f": "not configured",
"i18n:govoplan-campaign.not_loaded_yet.9968c191": "Not loaded yet",
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Not queued",
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Nicht eingereiht",
"i18n:govoplan-campaign.not_reviewed.0a0e3cff": "Not reviewed",
"i18n:govoplan-campaign.not_run.9e019cd5": "Not run",
"i18n:govoplan-campaign.not_sent.587c501e": "Not sent",
@@ -2050,7 +2086,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.optional.0c6c4102": "Optional",
"i18n:govoplan-campaign.or_click_to_select_file.0e72f25d": "or click to select file",
"i18n:govoplan-campaign.outcome_unknown.4383023a": ", outcome unknown",
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Outcome unknown",
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Ergebnis ungeklärt",
"i18n:govoplan-campaign.overview.0efc2e6b": "Übersicht",
"i18n:govoplan-campaign.owner_type.6b86eacc": "Owner type",
"i18n:govoplan-campaign.owner.719379ae": "Owner:",
@@ -2113,7 +2149,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.queued_jobs_are_waiting_for_a_background_deliver.cc703afb": "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs.",
"i18n:govoplan-campaign.queued_jobs_are_waiting_if_the_counts_do_not_cha.7d2164d6": "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running.",
"i18n:govoplan-campaign.queued_jobs_are_waiting_in_the_database_but_back.5b1fadfd": "Queued jobs are waiting in the database, but background delivery workers are disabled on this server. Use Send queued now for a small dev run, or start Redis/Celery with CELERY_ENABLED=true.",
"i18n:govoplan-campaign.queued.6a599877": "Queued",
"i18n:govoplan-campaign.queued.6a599877": "Eingereiht",
"i18n:govoplan-campaign.queueing_pending_imap_append_jobs.31dd388e": "Queueing pending IMAP append jobs...",
"i18n:govoplan-campaign.quoted_contents.f7fd335a": "Quoted contents",
"i18n:govoplan-campaign.rate_limit.d08e55f5": "Rate limit",
@@ -2295,10 +2331,10 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Show review candidates only",
"i18n:govoplan-campaign.showing.163d8174": "Showing",
"i18n:govoplan-campaign.size.b7152342": "Größe",
"i18n:govoplan-campaign.skipped.5a000ad7": "Skipped",
"i18n:govoplan-campaign.skipped.5a000ad7": "Übersprungen",
"i18n:govoplan-campaign.skipped.6b98496c": ", skipped",
"i18n:govoplan-campaign.smtp_accepted.a5d0dccc": ", SMTP accepted",
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "SMTP accepted",
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "Von SMTP angenommen",
"i18n:govoplan-campaign.smtp_attempts.eb0a9ca6": "SMTP attempts",
"i18n:govoplan-campaign.smtp_credentials.10f75c8a": "). SMTP credentials:",
"i18n:govoplan-campaign.smtp_failed.0ce5516d": "SMTP failed",

View File

@@ -3,6 +3,8 @@ import test from "node:test";
import {
OPERATOR_QUEUE_REASON,
campaignLifecycleTotals,
campaignVersionWorkItems,
operatorQueueActionBlocks,
type OperatorQueueFacts,
type OperatorQueuePermissions
@@ -12,13 +14,14 @@ const everything: OperatorQueueFacts = {
retryable: 2,
outcomeUnknown: 1,
queueableUnattempted: 3,
queued: 4,
pausable: 4,
paused: 5,
cancellable: 14
};
const allPermissions: OperatorQueuePermissions = {
canRead: true,
canOpenReport: true,
canControl: true,
canRetry: true,
canQueue: true,
@@ -28,6 +31,7 @@ const allPermissions: OperatorQueuePermissions = {
test("enables every fixed queue action when state and permission allow it", () => {
assert.deepEqual(operatorQueueActionBlocks(everything, allPermissions), {
details: null,
report: null,
pause: null,
resume: null,
retry: null,
@@ -40,11 +44,12 @@ test("enables every fixed queue action when state and permission allow it", () =
test("retains every unavailable action with a state-specific explanation", () => {
assert.deepEqual(
operatorQueueActionBlocks(
{ retryable: 0, outcomeUnknown: 0, queueableUnattempted: 0, queued: 0, paused: 0, cancellable: 0 },
{ retryable: 0, outcomeUnknown: 0, queueableUnattempted: 0, pausable: 0, paused: 0, cancellable: 0 },
allPermissions
),
{
details: null,
report: null,
pause: OPERATOR_QUEUE_REASON.noQueued,
resume: OPERATOR_QUEUE_REASON.noPaused,
retry: OPERATOR_QUEUE_REASON.noFailed,
@@ -58,6 +63,7 @@ test("retains every unavailable action with a state-specific explanation", () =>
test("permission explanations take precedence over queue state", () => {
const noPermissions: OperatorQueuePermissions = {
canRead: false,
canOpenReport: false,
canControl: false,
canRetry: false,
canQueue: false,
@@ -66,6 +72,43 @@ test("permission explanations take precedence over queue state", () => {
const blocks = operatorQueueActionBlocks(everything, noPermissions);
assert.deepEqual(
Object.values(blocks),
Array.from({ length: 7 }, () => OPERATOR_QUEUE_REASON.permissionDenied)
Array.from({ length: 8 }, () => OPERATOR_QUEUE_REASON.permissionDenied)
);
});
test("allows in-page job selection without report permission while keeping report access disabled", () => {
const blocks = operatorQueueActionBlocks(everything, {
...allPermissions,
canOpenReport: false
});
assert.equal(blocks.details, null);
assert.equal(blocks.report, OPERATOR_QUEUE_REASON.permissionDenied);
});
test("keeps historical active work visible after a new draft becomes current", () => {
const items = campaignVersionWorkItems(
"campaign-1",
"version-2",
[
{ id: "version-2", version_number: 2 },
{ id: "version-1", version_number: 1 }
],
{
"version-2": { state: "draft", queued: 0, failed: 0 },
"version-1": { state: "active", queued: 3, failed: 1 }
}
);
assert.deepEqual(items.map((item) => [item.versionId, item.isCurrent]), [
["version-2", true],
["version-1", false]
]);
assert.deepEqual(items[1].summary, { state: "active", queued: 3, failed: 1 });
});
test("campaign-wide lifecycle totals are not multiplied across version rows", () => {
assert.deepEqual(campaignLifecycleTotals([
{ pausable: 3, paused: 1, cancellable: 4 },
{ pausable: 0, paused: 2, cancellable: 2 }
]), { pausable: 3, paused: 3, cancellable: 6 });
});

View File

@@ -5,14 +5,72 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
const translations = readFileSync(resolve(here, "../src/i18n/generatedTranslations.ts"), "utf8");
const backendRouter = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/router.py"), "utf8");
const responseSecurity = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/response_security.py"), "utf8");
assert.match(source, /<DataGrid/);
assert.match(source, /<TableActionGroup actions=/);
for (const action of ["details", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel"]) {
for (const action of ["details", "report", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel", "open-recipient-report"]) {
assert.match(source, new RegExp(`id: "${action}"`));
}
assert.match(source, /useDeltaWatermarks/);
assert.match(source, /useSearchParams/);
assert.match(source, /searchParams\.get\("campaign"\)/);
assert.match(source, /searchParams\.get\("version"\)/);
assert.match(source, /next\.set\("campaign", campaignId\)[\s\S]*next\.set\("version", versionId\)/);
assert.match(source, /setSearchParams\(next, \{ replace: true \}\)/);
assert.match(source, /window\.setInterval[\s\S]*10_000/);
assert.match(source, /window\.setInterval[\s\S]*60_000/);
assert.match(source, /<ConfirmDialog[\s\S]*cancelTarget/);
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision/);
assert.match(source, /includeVersions: true/);
assert.match(source, /getCampaignSummary\(settings, campaign\.id, version\.id\)/);
assert.match(source, /campaignVersionWorkItems/);
assert.match(source, /getCampaignJobs\(settings, selectedRow\.campaign\.id,[\s\S]*versionId: selectedVersionId/);
assert.doesNotMatch(source, /getCampaignJobDetail|getCampaignJobsDelta|diagnostics/);
assert.match(source, /mode: "server"/);
assert.match(source, /totalRows: jobs\.total/);
assert.match(source, /query=\{jobGridQuery\}/);
assert.match(source, /onQueryChange=\{handleJobGridQuery\}/);
assert.match(source, /pageSizeOptions: \[25, 50, 100, 200\]/);
assert.match(source, /jobsRequestRef\.current/);
assert.match(source, /requestId !== jobsRequestRef\.current/);
assert.match(source, /selectedQueueActive[\s\S]*backgroundJobsLoadRef/);
assert.match(source, /getRowKey=\{\(row\) => `\$\{row\.campaign\.id\}:\$\{rowVersionId\(row\)/);
assert.match(source, /row\.campaign\.id === selectedCampaignId && rowVersionId\(row\) === selectedVersionParam/);
assert.match(source, /retryCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
assert.match(source, /sendUnattemptedCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
assert.match(source, /report\?version=\$\{encodeURIComponent\(rowVersionId\(row\)\)\}&send_status=outcome_unknown/);
for (const count of ["queued", "claimed", "sending", "completed"]) {
assert.match(source, new RegExp(`row\\.${count}`));
assert.match(source, new RegExp(`totals\\.${count}`));
}
for (const field of ["campaign_version_id", "entry_id", "entry_index", "queue_status", "send_status", "imap_status", "attempt_count", "queued_at", "sent_at", "updated_at", "last_error"]) {
assert.match(source, new RegExp(field));
}
assert.match(source, /deliveryResultSummary[\s\S]*send_status[\s\S]*imap_status/);
assert.match(source, /durableResultText[\s\S]*slice\(0, 239\)/);
assert.match(source, /permissions\.canOpenReport[\s\S]*you_do_not_have_permission/);
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision|eml_sha256|message_id_header|smtp_response|provider_response|resolved_attachments/);
assert.match(backendRouter, /"last_error": public_delivery_result_message\([\s\S]*last_error=job\.last_error/);
assert.match(responseSecurity, /Map persisted provider text to a stable business-safe explanation/);
assert.match(responseSecurity, /SMTP delivery failed; an operator can inspect restricted diagnostics\./);
assert.doesNotMatch(source, /window\.alert\s*\(/);
assert.doesNotMatch(source, /function QueuePressureGrid|function OperatorJob/);
assert.doesNotMatch(source, /humanize\(/);
const localized = new Map([
["i18n:govoplan-campaign.claimed.83c87884", ["Claimed", "Beansprucht"]],
["i18n:govoplan-campaign.sending.ceafde86", ["Sending", "Im Versand"]],
["i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70", ["Delivery jobs for {value0}", "Versandaufträge für {value0}"]],
["i18n:govoplan-campaign.open_recipient_report.d6892ee4", ["Open recipient report", "Empfängerbezogenen Bericht öffnen"]],
["i18n:govoplan-campaign.queued_at.1ac179b3", ["Queued at", "Eingereiht am"]],
["i18n:govoplan-campaign.sent_at.a6f45556", ["Sent at", "Gesendet am"]],
["i18n:govoplan-campaign.current.4fc0e2bc", ["Current", "Aktuell"]],
["i18n:govoplan-campaign.historical.bb2fe205", ["Historical", "Historisch"]],
["i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf", ["Pause all queued work for this Campaign", "Alle eingereihten Aufträge dieser Kampagne pausieren"]]
]);
for (const [key, values] of localized) {
assert.equal(translations.split(`"${key}"`).length - 1, 2, `${key} occurs once per language`);
for (const value of values) assert(translations.includes(`"${key}": ${JSON.stringify(value)}`));
}