feat: integrate operator queue into Campaign

This commit is contained in:
2026-07-29 21:10:37 +02:00
parent 2199187e8b
commit 101f3ccd7d
10 changed files with 164 additions and 64 deletions

View File

@@ -0,0 +1,57 @@
import { lazy } from "react";
import {
ModuleSubnav,
hasAnyScope,
useGuardedNavigate,
type ApiSettings,
type AuthInfo,
type ModuleSubnavGroup
} from "@govoplan/core-webui";
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "../operator/operatorQueueAccess";
const CampaignListPage = lazy(() => import("./CampaignListPage"));
const OperatorQueuePage = lazy(() => import("../operator/OperatorQueuePage"));
export type CampaignModuleSection = "campaigns" | "queue";
export default function CampaignModulePage({
active,
settings,
auth
}: {
active: CampaignModuleSection;
settings: ApiSettings;
auth: AuthInfo;
}) {
const navigate = useGuardedNavigate();
const groups: ModuleSubnavGroup<CampaignModuleSection>[] = [{
items: [
{
id: "campaigns",
label: "i18n:govoplan-campaign.all_campaigns.2bd1ee3a",
primary: true
},
...(hasAnyScope(auth, OPERATOR_QUEUE_ROUTE_SCOPES)
? [{
id: "queue" as const,
label: "i18n:govoplan-campaign.operator_queue.72492fb5"
}]
: [])
]
}];
function select(section: CampaignModuleSection) {
navigate(section === "queue" ? "/campaigns/queue" : "/campaigns");
}
return (
<div className="workspace campaign-module-workspace">
<ModuleSubnav active={active} groups={groups} onSelect={select} />
<section className="workspace-content">
{active === "queue"
? <OperatorQueuePage settings={settings} auth={auth} />
: <CampaignListPage settings={settings} />}
</section>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export const OPERATOR_QUEUE_ROUTE_SCOPES = [
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control"
];

View File

@@ -1,5 +1,5 @@
import { createElement, lazy, useCallback } from "react";
import { useParams } from "react-router-dom";
import { Navigate, useParams } from "react-router-dom";
import {
ResourceAccessBoundary,
type ApiSettings,
@@ -9,17 +9,18 @@ import {
} from "@govoplan/core-webui";
import { getCampaign } from "./api/campaigns";
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css";
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
const AggregateReportsPage = lazy(() => import("./features/reports/AggregateReportsPage"));
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"];
// Preserve the former /operator route identity for saved View projections.
const operatorQueueSurface = "campaigns.route.operator";
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
@@ -87,21 +88,14 @@ export const campaignModule: PlatformWebModule = {
],
navItems: [
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 },
{
to: "/operator",
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 },
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
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, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "campaigns", settings, auth }) },
{ path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) },
{ path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) },
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
uiCapabilities: {

View File

@@ -5,8 +5,10 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
const campaignModulePage = readFileSync(resolve(here, "../src/features/campaigns/CampaignModulePage.tsx"), "utf8");
const moduleSource = readFileSync(resolve(here, "../src/module.ts"), "utf8");
const translations = readFileSync(resolve(here, "../src/i18n/generatedTranslations.ts"), "utf8");
const backendRouter = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/router.py"), "utf8");
const jobQueries = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/services/job_queries.py"), "utf8");
const responseSecurity = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/response_security.py"), "utf8");
assert.match(source, /<DataGrid/);
@@ -56,12 +58,29 @@ 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(jobQueries, /"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\(/);
assert.match(campaignModulePage, /<ModuleSubnav/);
assert.match(campaignModulePage, /id: "campaigns"/);
assert.match(campaignModulePage, /id: "queue"/);
assert.match(campaignModulePage, /navigate\(section === "queue" \? "\/campaigns\/queue" : "\/campaigns"\)/);
assert.match(campaignModulePage, /active === "queue"[\s\S]*<OperatorQueuePage/);
assert.match(moduleSource, /path: "\/campaigns\/queue"/);
assert.match(moduleSource, /path: "\/operator"[\s\S]*createElement\(Navigate, \{ to: "\/campaigns\/queue", replace: true \}\)/);
assert.doesNotMatch(moduleSource, /to: "\/operator"/);
assert.ok(
moduleSource.indexOf('{ path: "/operator"') < moduleSource.indexOf('{ path: "/campaigns/queue"'),
"the canonical Campaign route must replace the legacy alias in the shared view-surface catalogue"
);
assert.equal(
moduleSource.split("surfaceId: operatorQueueSurface").length - 1,
2,
"the legacy redirect and canonical queue route share one configurable view surface"
);
const localized = new Map([
["i18n:govoplan-campaign.claimed.83c87884", ["Claimed", "Beansprucht"]],