feat(campaign): add durable operator queue controls

This commit is contained in:
2026-07-22 08:39:44 +02:00
parent 62a68792a4
commit 21f3014ac5
10 changed files with 420 additions and 29 deletions

View File

@@ -290,6 +290,10 @@ CAMPAIGN_USER_DOCUMENTATION = (
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
),
related_modules=("mail", "notifications"),
),
_workflow_topic(

View File

@@ -194,12 +194,12 @@ manifest = ModuleManifest(
path="/operator",
label="Operator Queue",
icon="radio-tower",
required_all=("campaigns:campaign:read",),
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control",
"campaigns:campaign:send",
),
order=30,
),
@@ -214,12 +214,12 @@ manifest = ModuleManifest(
path="/operator",
label="Operator Queue",
icon="radio-tower",
required_all=("campaigns:campaign:read",),
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control",
"campaigns:campaign:send",
),
order=30,
),

View File

@@ -284,6 +284,12 @@ def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_auth
assert "campaigns.workflow.queue-delivery" in with_mail
assert "campaigns.workflow.send-small-controlled-run" in with_mail
queue_topic = next(
topic for topic in CAMPAIGN_USER_DOCUMENTATION
if topic.id == "campaigns.workflow.queue-delivery"
)
assert any(link.href == "/operator" for link in queue_topic.links)
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
attachment_scopes = {

View File

@@ -28,7 +28,8 @@
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs"
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"

View File

@@ -1,39 +1,89 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink, RotateCcw, Send } from "lucide-react";
import { Ban, ExternalLink, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import {
cancelCampaign,
getCampaignWorkspaceDelta,
listCampaignsDelta,
pauseCampaign,
resumeCampaign,
retryCampaignJobs,
sendUnattemptedCampaignJobs,
type CampaignSummary
} 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 { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge, TableActionGroup, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import {
StatusBadge,
TableActionGroup,
hasScope,
i18nMessage,
mergeDeltaRows,
useDeltaWatermarks,
useGuardedNavigate,
type AuthInfo
} from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
import {
operatorQueueActionBlocks,
type OperatorQueueAction,
type OperatorQueuePermissions
} from "./operatorQueueModel";
type OperatorRow = {
campaign: CampaignListItem;
summary: CampaignSummary | null;
mode: string | null;
modeSelectedAt: string | null;
failed: number;
retryable: number;
outcomeUnknown: number;
notAttempted: number;
queuedOrActive: number;
queued: number;
paused: number;
imapFailed: number;
queueable: number;
queueableUnattempted: number;
needsAttention: number;
cancellable: number;
};
export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}) {
type QueueMutationAction = "pause" | "resume" | "retry" | "queue-unsent" | "cancel";
export default function OperatorQueuePage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const navigate = useGuardedNavigate();
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [rows, setRows] = useState<OperatorRow[]>([]);
const campaignsRef = useRef<CampaignListItem[]>([]);
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
const loadingRef = useRef(false);
const backgroundLoadRef = useRef<() => Promise<void>>(async () => undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState("");
const [cancelTarget, setCancelTarget] = useState<OperatorRow | null>(null);
const permissions = useMemo<OperatorQueuePermissions>(() => ({
canRead: hasScope(auth, "campaigns:campaign:read")
&& hasScope(auth, "campaigns:report:read")
&& hasScope(auth, "campaigns:recipient:read"),
canControl: hasScope(auth, "campaigns:campaign:control"),
canRetry: hasScope(auth, "campaigns:campaign:retry")
&& hasScope(auth, "campaigns:recipient:read"),
canQueue: hasScope(auth, "campaigns:campaign:queue")
&& hasScope(auth, "campaigns:recipient:read"),
canReconcile: hasScope(auth, "campaigns:campaign:reconcile")
&& hasScope(auth, "campaigns:report:read")
&& hasScope(auth, "campaigns:recipient:read")
}), [auth]);
const settingsKey = useMemo(
() => JSON.stringify({
@@ -51,8 +101,23 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
void load();
}, [settingsKey, resetDeltaWatermark]);
async function load() {
setLoading(true);
useEffect(() => {
backgroundLoadRef.current = () => load(true);
});
const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0);
useEffect(() => {
if (!hasActiveDelivery) return;
const handle = window.setInterval(() => {
void backgroundLoadRef.current();
}, 10_000);
return () => window.clearInterval(handle);
}, [hasActiveDelivery]);
async function load(background = false) {
if (loadingRef.current) return;
loadingRef.current = true;
if (!background) setLoading(true);
setError("");
try {
const campaigns = await loadCampaignsDelta();
@@ -61,21 +126,36 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
if (!background) setLoading(false);
loadingRef.current = false;
}
}
async function runAction(row: OperatorRow, action: "retry" | "unattempted") {
async function runAction(row: OperatorRow, action: QueueMutationAction) {
const key = `${action}:${row.campaign.id}`;
setBusy(key);
setError("");
setMessage("");
try {
const response = action === "retry" ?
await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) :
await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
const response = action === "retry"
? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
: action === "queue-unsent"
? await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
: action === "pause"
? await pauseCampaign(settings, row.campaign.id)
: action === "resume"
? await resumeCampaign(settings, row.campaign.id)
: await cancelCampaign(settings, row.campaign.id);
const result = asRecord(response.result ?? response);
setMessage(i18nMessage("i18n:govoplan-campaign.value_value_value_enqueued.35b33f6d", { value0: row.campaign.name, value1: humanize(String(result.action ?? action)), value2: String(result.enqueued_count ?? 0) }));
setMessage(i18nMessage(
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56",
{
value0: row.campaign.name,
value1: actionLabel(action),
value2: String(actionChangedCount(action, result))
}
));
if (action === "cancel") setCancelTarget(null);
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
await load();
} catch (err) {
@@ -150,31 +230,88 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
notAttempted: acc.notAttempted + row.notAttempted,
queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
paused: acc.paused + row.paused,
imapFailed: acc.imapFailed + row.imapFailed
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, imapFailed: 0 });
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 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: "mode", header: "i18n:govoplan-campaign.mode.a7b93d21", width: 155, sortable: true, filterable: true, value: (row) => row.mode ? humanize(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: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
{ 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: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 150,
width: 330,
sticky: "end",
render: (row) => <TableActionGroup actions={[
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }), icon: <ExternalLink aria-hidden="true" />, onClick: () => navigate(`/campaigns/${row.campaign.id}/report`) },
{ id: "retry", label: "i18n:govoplan-campaign.retry.9f5cd8a2", icon: <RotateCcw aria-hidden="true" />, applicable: row.failed > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "retry") },
{ id: "queue-unsent", label: "i18n:govoplan-campaign.queue_unsent.b0e98610", icon: <Send aria-hidden="true" />, applicable: row.notAttempted > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "unattempted") }
]} />
render: (row) => {
const blocks = operatorQueueActionBlocks(row, permissions);
const disabledReason = (action: OperatorQueueAction) =>
blocks[action] ?? (busy && action !== "details"
? "i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea"
: undefined);
return <TableActionGroup actions={[
{
id: "details",
label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }),
icon: <ExternalLink aria-hidden="true" />,
disabledReason: disabledReason("details"),
onClick: () => navigate(`/campaigns/${row.campaign.id}/report`)
},
{
id: "pause",
label: "i18n:govoplan-campaign.pause_queue.b66e7696",
icon: <Pause aria-hidden="true" />,
disabledReason: disabledReason("pause"),
onClick: () => void runAction(row, "pause")
},
{
id: "resume",
label: "i18n:govoplan-campaign.resume_queue.1f22f964",
icon: <Play aria-hidden="true" />,
disabledReason: disabledReason("resume"),
onClick: () => void runAction(row, "resume")
},
{
id: "retry",
label: "i18n:govoplan-campaign.retry.9f5cd8a2",
icon: <RotateCcw aria-hidden="true" />,
disabledReason: disabledReason("retry"),
onClick: () => void runAction(row, "retry")
},
{
id: "queue-unsent",
label: "i18n:govoplan-campaign.queue_unsent.b0e98610",
icon: <Send aria-hidden="true" />,
disabledReason: disabledReason("queue-unsent"),
onClick: () => void runAction(row, "queue-unsent")
},
{
id: "reconcile",
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`)
},
{
id: "cancel",
label: "i18n:govoplan-campaign.cancel_queued_work.a54fb955",
icon: <Ban aria-hidden="true" />,
variant: "danger",
disabledReason: disabledReason("cancel"),
onClick: () => setCancelTarget(row)
}
]} />;
}
}],
[busy, navigate]);
[busy, navigate, permissions]);
return (
<div className="content-pad workspace-data-page">
@@ -182,6 +319,7 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
<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>
@@ -198,6 +336,7 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
{ 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" }]
} />
</section>
@@ -214,22 +353,46 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
</Card>
</LoadingFrame>
<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"
tone="danger"
busy={Boolean(busy)}
onConfirm={() => {
if (cancelTarget) void runAction(cancelTarget, "cancel");
}}
onCancel={() => setCancelTarget(null)} />
</div>);
}
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): 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 failed = numberValue(cards.failed);
const notAttempted = numberValue(cards.not_attempted);
return {
campaign,
summary,
failed: numberValue(cards.failed),
mode: summary?.current_version?.delivery_mode ?? null,
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? null,
failed,
retryable: numberValue(cards.retryable),
outcomeUnknown: numberValue(cards.outcome_unknown),
notAttempted: numberValue(cards.not_attempted),
notAttempted,
queuedOrActive: numberValue(cards.queued_or_active),
queued,
paused,
imapFailed: numberValue(cards.imap_failed),
queueable: numberValue(cards.queueable),
needsAttention: numberValue(cards.needs_attention)
queueableUnattempted: numberValue(cards.queueable_unattempted),
needsAttention: numberValue(cards.needs_attention),
cancellable: numberValue(cards.cancellable)
};
}
@@ -237,6 +400,21 @@ function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
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 === "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";
}
function actionChangedCount(action: QueueMutationAction, result: Record<string, unknown>): number {
if (action === "pause") return numberValue(result.paused_count);
if (action === "resume") return numberValue(result.resumed_count);
if (action === "cancel") return numberValue(result.cancelled_count);
return numberValue(result.enqueued_count ?? result.selected_count);
}
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")

View File

@@ -0,0 +1,78 @@
export type OperatorQueueAction =
| "details"
| "pause"
| "resume"
| "retry"
| "queue-unsent"
| "reconcile"
| "cancel";
export type OperatorQueuePermissions = {
canRead: boolean;
canControl: boolean;
canRetry: boolean;
canQueue: boolean;
canReconcile: boolean;
};
export type OperatorQueueFacts = {
retryable: number;
outcomeUnknown: number;
queueableUnattempted: number;
queued: number;
paused: number;
cancellable: number;
};
export const OPERATOR_QUEUE_REASON = {
permission: "i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6",
noQueued: "i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e",
noPaused: "i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275",
noFailed: "i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9",
noUnsent: "i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87",
noUnknown: "i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c",
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
} as const;
/**
* Return the reason each fixed queue action is unavailable. A null result means
* the action is permitted and meaningful for the current queue projection.
*/
export function operatorQueueActionBlocks(
facts: OperatorQueueFacts,
permissions: OperatorQueuePermissions
): Record<OperatorQueueAction, string | null> {
return {
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permission,
pause: !permissions.canControl
? OPERATOR_QUEUE_REASON.permission
: facts.queued > 0
? null
: OPERATOR_QUEUE_REASON.noQueued,
resume: !permissions.canControl
? OPERATOR_QUEUE_REASON.permission
: facts.paused > 0
? null
: OPERATOR_QUEUE_REASON.noPaused,
retry: !permissions.canRetry
? OPERATOR_QUEUE_REASON.permission
: facts.retryable > 0
? null
: OPERATOR_QUEUE_REASON.noFailed,
"queue-unsent": !permissions.canQueue
? OPERATOR_QUEUE_REASON.permission
: facts.queueableUnattempted > 0
? null
: OPERATOR_QUEUE_REASON.noUnsent,
reconcile: !permissions.canReconcile
? OPERATOR_QUEUE_REASON.permission
: facts.outcomeUnknown > 0
? null
: OPERATOR_QUEUE_REASON.noUnknown,
cancel: !permissions.canControl
? OPERATOR_QUEUE_REASON.permission
: facts.cancellable > 0
? null
: OPERATOR_QUEUE_REASON.noCancellable
};
}

View File

@@ -4,6 +4,23 @@ export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"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_queued_work.a54fb955": "Cancel queued work",
"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.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.pause_queue.b66e7696": "Pause queue",
"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.reconcile_outcomes.bd81d8a7": "Reconcile outcomes",
"i18n:govoplan-campaign.resume_queue.1f22f964": "Resume queue",
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "You do not have permission for this action.",
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Campaign-scoped Mail profile",
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign stores only this stable profile reference. The Mail module owns, encrypts, authorizes, tests, and resolves all SMTP/IMAP settings and credentials.",
"i18n:govoplan-campaign.configured.668c5fff": "Configured",
@@ -1176,6 +1193,23 @@ export const generatedTranslations: PlatformTranslations = {
"de": {
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"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_queued_work.a54fb955": "Warteschlangenaufträge abbrechen",
"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.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.pause_queue.b66e7696": "Warteschlange 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.reconcile_outcomes.bd81d8a7": "Ergebnisse abstimmen",
"i18n:govoplan-campaign.resume_queue.1f22f964": "Warteschlange fortsetzen",
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "Sie haben keine Berechtigung für diese Aktion.",
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Kampagnenspezifisches Mail-Profil",
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign speichert nur diese stabile Profilreferenz. Das Mail-Modul verwaltet, verschlüsselt, autorisiert, testet und löst alle SMTP-/IMAP-Einstellungen und Zugangsdaten auf.",
"i18n:govoplan-campaign.configured.668c5fff": "Konfiguriert",

View File

@@ -11,7 +11,7 @@ const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePa
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const campaignRead = ["campaigns:campaign:read"];
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"];
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
@@ -31,6 +31,7 @@ export const campaignModule: PlatformWebModule = {
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
iconName: "radio-tower",
anyOf: operatorScopes,
allOf: campaignRead,
order: 30
},
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
@@ -39,7 +40,7 @@ export const campaignModule: PlatformWebModule = {
routes: [
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
{ path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
OPERATOR_QUEUE_REASON,
operatorQueueActionBlocks,
type OperatorQueueFacts,
type OperatorQueuePermissions
} from "../src/features/operator/operatorQueueModel.ts";
const everything: OperatorQueueFacts = {
retryable: 2,
outcomeUnknown: 1,
queueableUnattempted: 3,
queued: 4,
paused: 5,
cancellable: 14
};
const allPermissions: OperatorQueuePermissions = {
canRead: true,
canControl: true,
canRetry: true,
canQueue: true,
canReconcile: true
};
test("enables every fixed queue action when state and permission allow it", () => {
assert.deepEqual(operatorQueueActionBlocks(everything, allPermissions), {
details: null,
pause: null,
resume: null,
retry: null,
"queue-unsent": null,
reconcile: null,
cancel: null
});
});
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 },
allPermissions
),
{
details: null,
pause: OPERATOR_QUEUE_REASON.noQueued,
resume: OPERATOR_QUEUE_REASON.noPaused,
retry: OPERATOR_QUEUE_REASON.noFailed,
"queue-unsent": OPERATOR_QUEUE_REASON.noUnsent,
reconcile: OPERATOR_QUEUE_REASON.noUnknown,
cancel: OPERATOR_QUEUE_REASON.noCancellable
}
);
});
test("permission explanations take precedence over queue state", () => {
const noPermissions: OperatorQueuePermissions = {
canRead: false,
canControl: false,
canRetry: false,
canQueue: false,
canReconcile: false
};
const blocks = operatorQueueActionBlocks(everything, noPermissions);
assert.deepEqual(
Object.values(blocks),
Array.from({ length: 7 }, () => OPERATOR_QUEUE_REASON.permission)
);
});

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
assert.match(source, /<DataGrid/);
assert.match(source, /<TableActionGroup actions=/);
for (const action of ["details", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel"]) {
assert.match(source, new RegExp(`id: "${action}"`));
}
assert.match(source, /useDeltaWatermarks/);
assert.match(source, /window\.setInterval[\s\S]*10_000/);
assert.match(source, /<ConfirmDialog[\s\S]*cancelTarget/);
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision/);
assert.doesNotMatch(source, /window\.alert\s*\(/);