Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -30,7 +30,7 @@
|
||||
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
|
||||
"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-workflow": "node --experimental-strip-types --test tests/review-workflow-guidance.test.ts && node tests/review-workflow-guidance-ui-structure.test.mjs",
|
||||
"test:review-workflow": "node --experimental-strip-types --test tests/review-workflow-guidance.test.ts tests/bulk-message-review.test.ts tests/built-message-state.test.ts tests/validation-issue-groups.test.ts && node tests/review-workflow-guidance-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",
|
||||
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
|
||||
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||
|
||||
+44
-10
@@ -5,6 +5,7 @@ import {
|
||||
type ReferenceOptionProvider
|
||||
} from "@govoplan/core-webui";
|
||||
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
|
||||
import { campaignVersionUpdateForRequest } from "../features/campaigns/utils/editorState";
|
||||
export {
|
||||
fetchResourceAccessExplanation,
|
||||
fetchResourceAccessExplanationSubjects
|
||||
@@ -340,6 +341,7 @@ export type CampaignScheduleCreate = {
|
||||
};
|
||||
|
||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||
review_build_token?: string | null;
|
||||
raw_json: Record<string, unknown>;
|
||||
campaign_json?: Record<string, unknown>;
|
||||
mail_profile_migration_required?: boolean;
|
||||
@@ -936,6 +938,7 @@ export type CampaignSendNowPayload = {
|
||||
};
|
||||
|
||||
export type CampaignAppendSentPayload = {
|
||||
version_id?: string;
|
||||
dry_run?: boolean;
|
||||
enqueue_celery?: boolean;
|
||||
run_inline?: boolean;
|
||||
@@ -1017,6 +1020,7 @@ export type CampaignAttachmentLinkMatchesResponse = {
|
||||
};
|
||||
|
||||
export type CampaignMockSendPayload = {
|
||||
use_reviewed_build?: boolean;
|
||||
version_id?: string | null;
|
||||
send?: boolean;
|
||||
include_warnings?: boolean;
|
||||
@@ -1027,6 +1031,10 @@ export type CampaignMockSendPayload = {
|
||||
};
|
||||
|
||||
export type CampaignReviewStatePayload = {
|
||||
merge_progress?: boolean;
|
||||
build_token?: string | null;
|
||||
base_revision?: number | null;
|
||||
decision_category_key?: string | null;
|
||||
inspection_complete: boolean;
|
||||
reviewed_message_keys: string[];
|
||||
issue_decisions: Array<{
|
||||
@@ -1517,7 +1525,8 @@ options: CampaignWorkspaceQuery = {})
|
||||
export async function getCampaignWorkspaceDelta(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignWorkspaceQuery = {})
|
||||
options: CampaignWorkspaceQuery = {},
|
||||
requestOptions?: Pick<RequestInit, "cache" | "signal">)
|
||||
: Promise<CampaignWorkspaceDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
@@ -1527,7 +1536,7 @@ options: CampaignWorkspaceQuery = {})
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
|
||||
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`, requestOptions);
|
||||
}
|
||||
|
||||
export async function listCampaignVersions(
|
||||
@@ -1542,7 +1551,9 @@ settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
||||
// Conflict reconciliation and discard/reload require the current revision,
|
||||
// never an older coalesced or short-lived cached read.
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { cache: "no-store" });
|
||||
}
|
||||
|
||||
export async function unlockCampaignVersionValidation(
|
||||
@@ -1595,7 +1606,7 @@ ifMatch: string)
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "If-Match": ifMatch },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1607,7 +1618,7 @@ payload: CampaignVersionUpdatePayload = {})
|
||||
: Promise<CampaignCreateResponse> {
|
||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1621,7 +1632,7 @@ ifMatch: string)
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
|
||||
method: "POST",
|
||||
headers: { "If-Match": ifMatch },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1719,24 +1730,46 @@ versionId?: string)
|
||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
|
||||
}
|
||||
|
||||
export type CampaignDeliveryProgress = {
|
||||
delivery_mode?: string | null;
|
||||
workflow_state?: string;
|
||||
campaign_id: string;
|
||||
version_id: string;
|
||||
generated_at: string;
|
||||
total_jobs: number;
|
||||
smtp: { total: number; processed: number; accepted: number; active: number; pending: number; failed: number; outcome_unknown: number; excluded: number; paused: number; cancelled: number };
|
||||
imap: { total: number; processed: number; appended: number; active: number; pending: number; failed: number; outcome_unknown: number; excluded: number };
|
||||
status_counts: { send: Record<string, number>; queue: Record<string, number>; imap: Record<string, number> };
|
||||
};
|
||||
|
||||
export async function getCampaignDeliveryProgress(
|
||||
settings: ApiSettings, campaignId: string, versionId: string, signal?: AbortSignal
|
||||
): Promise<CampaignDeliveryProgress> {
|
||||
return apiFetch<CampaignDeliveryProgress>(settings,
|
||||
`/api/v1/campaigns/${campaignId}/delivery-progress?version_id=${encodeURIComponent(versionId)}`,
|
||||
{ cache: "no-store", signal });
|
||||
}
|
||||
|
||||
export async function getCampaignJobs(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery = {})
|
||||
options: CampaignJobsQuery = {},
|
||||
init?: Pick<RequestInit, "cache" | "signal">)
|
||||
: Promise<CampaignJobsResponse> {
|
||||
const params = campaignJobsQueryParams(options);
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
|
||||
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`, init);
|
||||
}
|
||||
|
||||
export async function getCampaignJobsDelta(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
|
||||
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {},
|
||||
init?: Pick<RequestInit, "cache" | "signal">)
|
||||
: Promise<CampaignJobsDeltaResponse> {
|
||||
const params = campaignJobsQueryParams(options);
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
|
||||
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`, init);
|
||||
}
|
||||
|
||||
export async function getCampaignJobDetail(
|
||||
@@ -1941,6 +1974,7 @@ payload: CampaignAppendSentPayload = {})
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
version_id: payload.version_id,
|
||||
dry_run: payload.dry_run ?? false,
|
||||
enqueue_celery: payload.enqueue_celery ?? true,
|
||||
run_inline: payload.run_inline ?? false
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type CampaignDeliveryPolicyScope = "system" | "tenant";
|
||||
export type CampaignDeliveryPolicy = {
|
||||
scope: CampaignDeliveryPolicyScope;
|
||||
synchronous_send_max_recipients: number | null;
|
||||
revision: string;
|
||||
max_configurable_recipients: number;
|
||||
effective_max_recipients: number;
|
||||
inherited_max_recipients: number;
|
||||
absolute_max_recipients: number;
|
||||
deployment_ceiling_explicit: boolean;
|
||||
deployment_max_recipients: number;
|
||||
};
|
||||
|
||||
export function getCampaignDeliveryPolicy(settings: ApiSettings, scope: CampaignDeliveryPolicyScope, signal?: AbortSignal) {
|
||||
return apiFetch<CampaignDeliveryPolicy>(settings, `/api/v1/campaigns/settings/delivery-policy/${scope}`, { cache: "no-store", signal });
|
||||
}
|
||||
|
||||
export function saveCampaignDeliveryPolicy(settings: ApiSettings, scope: CampaignDeliveryPolicyScope, value: number | null, revision: string) {
|
||||
return apiFetch<CampaignDeliveryPolicy>(settings, `/api/v1/campaigns/settings/delivery-policy/${scope}`, {
|
||||
method: "PUT", body: JSON.stringify({ synchronous_send_max_recipients: value, expected_revision: revision })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type CampaignRecoveryChannel = "smtp" | "imap";
|
||||
export type CampaignInlineRecoveryAction = "retry" | "send-unattempted";
|
||||
export type CampaignInlineRecoveryResult = {
|
||||
selected_count: number;
|
||||
remaining_count?: number;
|
||||
attempted_count?: number;
|
||||
sent_count?: number;
|
||||
failed_count?: number;
|
||||
outcome_unknown_count?: number;
|
||||
enqueued_count?: number;
|
||||
run_inline?: boolean;
|
||||
};
|
||||
|
||||
export async function runCampaignInlineRecovery(settings: ApiSettings, campaignId: string, action: CampaignInlineRecoveryAction, payload: {
|
||||
version_id: string;
|
||||
job_ids: string[];
|
||||
include_permanent?: boolean;
|
||||
}): Promise<CampaignInlineRecoveryResult> {
|
||||
const response = await apiFetch<{ result: CampaignInlineRecoveryResult }>(settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/jobs/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...payload, run_inline: true, enqueue_celery: false })
|
||||
});
|
||||
return response.result;
|
||||
}
|
||||
|
||||
export async function recoverCampaignJobClaim(settings: ApiSettings, campaignId: string, jobId: string, payload: {
|
||||
channel: CampaignRecoveryChannel;
|
||||
expected_revision: string;
|
||||
note: string;
|
||||
}): Promise<void> {
|
||||
await apiFetch(settings, `/api/v1/campaigns/${encodeURIComponent(campaignId)}/jobs/${encodeURIComponent(jobId)}/recover-claim`, {
|
||||
method: "POST", body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
+12
-6
@@ -72,8 +72,10 @@ export async function testMailProfileSmtp(
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
|
||||
): Promise<MailConnectionTestResponse & { protocol: "smtp" }> {
|
||||
const result = await runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
|
||||
if (result.protocol !== "smtp") throw new Error("Unexpected protocol in SMTP test response.");
|
||||
return { ...result, protocol: result.protocol };
|
||||
}
|
||||
|
||||
export async function testMailProfileImap(
|
||||
@@ -82,8 +84,10 @@ export async function testMailProfileImap(
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
|
||||
): Promise<MailConnectionTestResponse & { protocol: "imap" }> {
|
||||
const result = await runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
|
||||
if (result.protocol !== "imap") throw new Error("Unexpected protocol in IMAP test response.");
|
||||
return { ...result, protocol: result.protocol };
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(
|
||||
@@ -92,8 +96,10 @@ export async function listMailProfileImapFolders(
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailImapFolderListResponse> {
|
||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
|
||||
): Promise<MailImapFolderListResponse & { protocol: "imap" }> {
|
||||
const result = await runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
|
||||
if (result.protocol !== "imap") throw new Error("Unexpected protocol in IMAP folder response.");
|
||||
return { ...result, protocol: result.protocol };
|
||||
}
|
||||
|
||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout, Card, DescriptionItem, DescriptionList, FormField, PageActionBar,
|
||||
ToggleSwitch, adminErrorMessage, usePlatformLanguage, useUnsavedDraftGuard, type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import { getCampaignDeliveryPolicy, saveCampaignDeliveryPolicy, type CampaignDeliveryPolicy, type CampaignDeliveryPolicyScope } from "../../api/deliveryPolicy";
|
||||
|
||||
// This optional administration screen owns its bilingual copy and loads lazily.
|
||||
const labels = {
|
||||
en: {
|
||||
system: "System Campaign delivery", tenant: "Tenant Campaign delivery", title: "Interactive delivery limit",
|
||||
description: "Set the recipient-job limit for one Send now request. This is not a campaign-size limit; background workers process larger campaigns separately.",
|
||||
inherit: "Inherit the parent or default limit", limit: "Maximum recipient jobs per Send now request", help: "0 disables Send now. Larger values hold requests open longer and may reach proxy timeouts; Mail provider rate limits still apply. Saving policy never sends messages or changes existing reviews.",
|
||||
effective: "Saved effective limit", parent: "Inherited limit", maximum: "Maximum allowed here", deployment: "Explicit deployment ceiling", absent: "Not set; the absolute safety maximum is 500.",
|
||||
saved: "Campaign delivery policy saved.", invalid: "Enter a whole number within the permitted range.", readOnly: "You may inspect this policy but do not have permission to change it.",
|
||||
reload: "Reload saved delivery policy", save: "Save", discard: "Discard", ceiling: "Tenant settings can only narrow system policy. An explicit deployment ceiling cannot be raised in this screen."
|
||||
},
|
||||
de: {
|
||||
system: "Systemweiter Campaign-Versand", tenant: "Mandantenweiter Campaign-Versand", title: "Grenze für interaktiven Versand",
|
||||
description: "Legen Sie die Empfängerauftragsgrenze für eine Anfrage „Jetzt senden“ fest. Sie begrenzt nicht die Kampagnengröße; Hintergrund-Worker verarbeiten größere Kampagnen getrennt.",
|
||||
inherit: "Übergeordnete Grenze oder Standard erben", limit: "Maximale Empfängeraufträge je Anfrage „Jetzt senden“", help: "0 deaktiviert „Jetzt senden“. Höhere Werte halten Anfragen länger offen und können Proxy-Zeitlimits erreichen; Mail-Anbieterraten gelten weiterhin. Speichern versendet keine Nachrichten und ändert keine bestehenden Prüfungen.",
|
||||
effective: "Gespeicherte wirksame Grenze", parent: "Geerbte Grenze", maximum: "Hier maximal zulässig", deployment: "Ausdrückliche Bereitstellungsgrenze", absent: "Nicht gesetzt; die absolute Sicherheitsgrenze beträgt 500.",
|
||||
saved: "Campaign-Versandrichtlinie gespeichert.", invalid: "Geben Sie eine ganze Zahl im zulässigen Bereich ein.", readOnly: "Sie dürfen diese Richtlinie ansehen, besitzen aber keine Änderungsberechtigung.",
|
||||
reload: "Gespeicherte Versandrichtlinie neu laden", save: "Speichern", discard: "Verwerfen", ceiling: "Mandanteneinstellungen dürfen die Systemrichtlinie nur einschränken. Eine ausdrücklich gesetzte Bereitstellungsgrenze lässt sich hier nicht erhöhen."
|
||||
}
|
||||
};
|
||||
|
||||
export default function CampaignDeliveryPolicyPanel({ settings, scope, canWrite }: {
|
||||
settings: ApiSettings; scope: CampaignDeliveryPolicyScope; canWrite: boolean;
|
||||
}) {
|
||||
const { language } = usePlatformLanguage();
|
||||
const text = labels[language.startsWith("de") ? "de" : "en"];
|
||||
const [saved, setSaved] = useState<CampaignDeliveryPolicy | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [inherit, setInherit] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const generation = useRef(0);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
const inFlight = useRef<Promise<boolean> | null>(null);
|
||||
const dirty = Boolean(saved && (inherit !== (saved.synchronous_send_max_recipients === null) || (!inherit && draft !== String(saved.synchronous_send_max_recipients))));
|
||||
const valid = inherit || (/^\d+$/.test(draft) && Number(draft) <= (saved?.max_configurable_recipients ?? 0));
|
||||
|
||||
function adopt(state: CampaignDeliveryPolicy) {
|
||||
setSaved(state); setInherit(state.synchronous_send_max_recipients === null);
|
||||
setDraft(String(state.synchronous_send_max_recipients ?? state.inherited_max_recipients));
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
request.current?.abort();
|
||||
const controller = new AbortController(); request.current = controller;
|
||||
const current = ++generation.current;
|
||||
setLoading(true); setError(""); setSuccess("");
|
||||
try {
|
||||
const response = await getCampaignDeliveryPolicy(settings, scope, controller.signal);
|
||||
if (current === generation.current) adopt(response);
|
||||
} catch (cause) {
|
||||
if (current === generation.current && !controller.signal.aborted) setError(adminErrorMessage(cause));
|
||||
} finally { if (current === generation.current) setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSaved(null);
|
||||
setSaving(false); inFlight.current = null;
|
||||
void reload();
|
||||
return () => { ++generation.current; request.current?.abort(); };
|
||||
}, [scope, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
function discard() { if (saved && !inFlight.current) { adopt(saved); setError(""); setSuccess(""); } }
|
||||
function save(): Promise<boolean> {
|
||||
if (inFlight.current) return inFlight.current;
|
||||
if (!dirty) return Promise.resolve(true);
|
||||
if (!saved || !canWrite || !valid || loading) return Promise.resolve(false);
|
||||
const current = generation.current;
|
||||
setSaving(true); setError(""); setSuccess("");
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const response = await saveCampaignDeliveryPolicy(settings, scope, inherit ? null : Number(draft), saved.revision);
|
||||
if (current !== generation.current) return false;
|
||||
adopt(response); setSuccess(text.saved); return true;
|
||||
} catch (cause) {
|
||||
if (current === generation.current) setError(adminErrorMessage(cause));
|
||||
return false;
|
||||
} finally {
|
||||
if (current === generation.current) { inFlight.current = null; setSaving(false); }
|
||||
}
|
||||
})();
|
||||
inFlight.current = operation;
|
||||
return operation;
|
||||
}
|
||||
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
|
||||
const disabled = !canWrite || saving || loading;
|
||||
return <AdminPageLayout archetype="editor" title={text[scope]} description={text.description}
|
||||
loading={loading && !saved} error={error} success={success}
|
||||
interfaceId={`campaigns.admin.${scope}-delivery`} helpModuleId="campaigns" helpTopicId="campaigns.admin.delivery-policy"
|
||||
actions={<PageActionBar variant="editor" state={saving ? "saving" : !valid ? "invalid" : error && dirty ? "save-failed" : dirty ? "dirty" : "clean"}
|
||||
refreshable reloadAction={{ onReload: () => void reload(), label: text.reload, loading, disabled: saving }}
|
||||
saveAction={{ label: text.save, onClick: () => void save(), disabled: !canWrite || !saved || loading }}
|
||||
discardAction={{ label: text.discard, onClick: discard, disabled: saving }} />}>
|
||||
{saved && <Card title={text.title}>
|
||||
{!canWrite && <p>{text.readOnly}</p>}
|
||||
<ToggleSwitch label={text.inherit} checked={inherit} disabled={disabled} onChange={setInherit} />
|
||||
<FormField label={text.limit} help={text.help}>
|
||||
<input type="number" min={0} max={saved.max_configurable_recipients} step={1} value={draft}
|
||||
aria-invalid={!valid} disabled={disabled || inherit} onChange={(event) => setDraft(event.target.value)} />
|
||||
</FormField>
|
||||
{!valid && <p role="alert">{text.invalid}</p>}
|
||||
<p>{text.ceiling}</p>
|
||||
<DescriptionList>
|
||||
<DescriptionItem term={text.effective}>{saved.effective_max_recipients}</DescriptionItem>
|
||||
<DescriptionItem term={text.parent}>{saved.inherited_max_recipients}</DescriptionItem>
|
||||
<DescriptionItem term={text.maximum}>{saved.max_configurable_recipients}</DescriptionItem>
|
||||
<DescriptionItem term={text.deployment}>{saved.deployment_ceiling_explicit ? saved.deployment_max_recipients : text.absent}</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</Card>}
|
||||
</AdminPageLayout>;
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
getCampaignArchiveEncryptionPolicy,
|
||||
type CampaignArchiveEncryptionPolicy
|
||||
} from "../../api/campaigns";
|
||||
import type { CampaignArchiveEncryptionPolicy } from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||
@@ -14,6 +11,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
@@ -21,7 +19,7 @@ import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { asArray, asRecord, getCampaignJson, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
||||
@@ -29,21 +27,11 @@ import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePa
|
||||
import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
import CampaignArchiveEncryptionPolicyNotice, { UNAVAILABLE_ARCHIVE_POLICY } from "./components/CampaignArchiveEncryptionPolicyNotice";
|
||||
|
||||
type PathChooserState = {index: number;};
|
||||
type IndividualDisableState = {index: number;usageCount: number;};
|
||||
|
||||
const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
|
||||
available: false,
|
||||
allowed_password_encryption_methods: ["aes"],
|
||||
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
||||
policy_hash: "",
|
||||
source_path: [],
|
||||
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
|
||||
diagnostics: [],
|
||||
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||
};
|
||||
|
||||
export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
@@ -59,7 +47,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
||||
const [archivePolicy, setArchivePolicy] = useState<CampaignArchiveEncryptionPolicy>(UNAVAILABLE_ARCHIVE_POLICY);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, setDraft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -94,7 +82,10 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
||||
!archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10
|
||||
)
|
||||
);
|
||||
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid;
|
||||
const zipChanged = JSON.stringify(attachments.zip ?? null) !== JSON.stringify(asRecord(getCampaignJson(version).attachments).zip ?? null);
|
||||
// An unchanged ZIP policy must not lock unrelated source/rule corrections.
|
||||
// The backend rechecks changed ZIP settings and all actual archive use.
|
||||
const canSave = dirty && !locked && Boolean(draft) && (!zipChanged || !zipArchiveNameValidation.message && !legacyConfigurationInvalid);
|
||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||
const attachmentPreviewEntry = useMemo(
|
||||
@@ -118,22 +109,6 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
||||
return () => {cancelled = true;};
|
||||
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY);
|
||||
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
|
||||
.then((policy) => { if (!cancelled) setArchivePolicy(policy); })
|
||||
.catch((cause) => {
|
||||
if (!cancelled) {
|
||||
setArchivePolicy({
|
||||
...UNAVAILABLE_ARCHIVE_POLICY,
|
||||
reason: cause instanceof Error ? cause.message : String(cause)
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||
if (locked) return;
|
||||
const normalized = ensureAttachmentBasePaths(paths);
|
||||
@@ -321,16 +296,17 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
||||
mode="workspace"
|
||||
title="i18n:govoplan-campaign.attachments.6771ade6"
|
||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||
headerLoading={loading}
|
||||
headerLoading={loading || saving}
|
||||
error={error}
|
||||
actions={<PageActionBar
|
||||
variant="editor"
|
||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||
contextActions={filesModuleInstalled ? <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button> : undefined}
|
||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current editor blocker before saving." : undefined }}
|
||||
/>}
|
||||
notices={(localError || locked) ? <>
|
||||
notices={(localError || locked || version?.mail_profile_migration_required) ? <>
|
||||
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
</> : undefined}
|
||||
@@ -346,11 +322,12 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
||||
</MetricGrid>
|
||||
|
||||
<Card id="campaign-attachment-sources" tabIndex={-1} title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
||||
{filesModuleInstalled && !managedFilesAvailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.file_chooser_unavailable</DismissibleAlert>}
|
||||
<div className="admin-table-surface attachment-sources-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-attachment-sources`}
|
||||
rows={basePaths}
|
||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||
getRowKey={(basePath) => basePath.id}
|
||||
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
|
||||
@@ -425,11 +402,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||
<DismissibleAlert tone={legacyZipCryptoAllowed ? "warning" : "info"} dismissible={false} compact>
|
||||
<strong>{archivePolicy.legacy_label}</strong>: {archivePolicy.reason}
|
||||
{archivePolicy.source_path.length > 0 && <> Source: {archivePolicy.source_path.map((step) => step.label).join(" → ")}.</>}
|
||||
{!canUseLegacyZipCrypto && <> Your account does not have the dedicated legacy-encryption permission.</>}
|
||||
</DismissibleAlert>
|
||||
<CampaignArchiveEncryptionPolicyNotice settings={settings} auth={auth} campaignId={campaignId} onPolicyChange={setArchivePolicy} />
|
||||
<div className="attachment-zip-master-toggle">
|
||||
<ToggleSwitch
|
||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||
@@ -481,7 +454,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={managedFilesAvailable}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={attachmentPreviewContext}
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)} />
|
||||
|
||||
@@ -806,6 +779,7 @@ type AttachmentSourceColumnContext = {
|
||||
basePaths: AttachmentBasePath[];
|
||||
fileSpaces: FilesFileSpace[];
|
||||
managedFilesAvailable: boolean;
|
||||
filesModuleInstalled: boolean;
|
||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||
setIndividualEligibility: (index: number, checked: boolean) => void;
|
||||
addBasePath: (afterIndex?: number) => void;
|
||||
@@ -814,7 +788,7 @@ type AttachmentSourceColumnContext = {
|
||||
setPathChooser: (state: PathChooserState | null) => void;
|
||||
};
|
||||
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||
return [
|
||||
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||
{
|
||||
@@ -830,9 +804,9 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAv
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
||||
disabled={locked}
|
||||
disabled={locked || filesModuleInstalled && !managedFilesAvailable}
|
||||
readOnly={managedFilesAvailable}
|
||||
tabIndex={managedFilesAvailable ? -1 : undefined}
|
||||
tabIndex={0}
|
||||
placeholder="i18n:govoplan-campaign.attachments_placeholder"
|
||||
onChange={(event) => {
|
||||
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
@@ -22,7 +23,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, setDraft, displayDraft, dirty, saving, saveState, setSaveState, localError, setLocalError, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -155,15 +156,16 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
||||
mode="workspace"
|
||||
title="i18n:govoplan-campaign.fields.e8b68527"
|
||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||
headerLoading={loading}
|
||||
headerLoading={loading || saving}
|
||||
error={error}
|
||||
actions={<PageActionBar
|
||||
variant="editor"
|
||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: saveFields, disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current field-definition blocker before saving." : undefined }}
|
||||
/>}
|
||||
notices={(localError || fieldNameWarning || locked) ? <>
|
||||
notices={(localError || fieldNameWarning || locked || version?.mail_profile_migration_required) ? <>
|
||||
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DescriptionList } from "@govoplan/core-webui";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, RotateCcw, Search, X } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
downloadCampaignJobsCsv,
|
||||
emailCampaignReport,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getCampaignJobs,
|
||||
resolveCampaignJobOutcome,
|
||||
retryCampaignJobs,
|
||||
sendCampaignJob,
|
||||
sendUnattemptedCampaignJobs,
|
||||
type CampaignJobDetailResponse,
|
||||
type CampaignJobsResponse,
|
||||
@@ -17,15 +16,19 @@ import {
|
||||
"../../api/campaigns";
|
||||
import { ContentGrid, Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
||||
import { LoadingFrame, TableActionGroup, ToggleSwitch, hasScope, i18nMessage } from "@govoplan/core-webui";
|
||||
import { recoverCampaignJobClaim, runCampaignInlineRecovery, type CampaignInlineRecoveryAction } from "../../api/deliveryRecovery";
|
||||
import ReportRecipients, { reportRecipientSearchText } from "./reporting/ReportRecipients";
|
||||
import ReportRecoveryDialog from "./reporting/ReportRecoveryDialog";
|
||||
import { reportClaimRecovery, reportRecoveryHints, reportRetryableFailure, reportUnattempted, type ReportReconciliation } from "./reporting/reportRecovery";
|
||||
import CampaignDeliveryProgressDialog from "./review/CampaignDeliveryProgressDialog";
|
||||
import { SEND_STATUS_OPTIONS, IMAP_STATUS_OPTIONS, deliveryStatusLabel } from "./utils/deliveryStatusOptions";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||
import { emptyCampaignJobsResponse } from "./utils/jobDeltas";
|
||||
@@ -38,24 +41,6 @@ import {
|
||||
type ReportGridShortcutId
|
||||
} from "./utils/reportGridShortcuts";
|
||||
|
||||
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_queued",
|
||||
"skipped",
|
||||
"queued",
|
||||
"claimed",
|
||||
"sending",
|
||||
"smtp_accepted",
|
||||
"postbox_accepted",
|
||||
"print_accepted",
|
||||
"delivered",
|
||||
"partially_accepted",
|
||||
"sent",
|
||||
"outcome_unknown",
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"cancelled"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const PRINT_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"ready",
|
||||
@@ -77,16 +62,6 @@ const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"skipped"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"pending",
|
||||
"appending",
|
||||
"appended",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"skipped"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const VALIDATION_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"ready",
|
||||
"warning",
|
||||
@@ -106,17 +81,25 @@ map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
const JOB_GRID_QUERY_DELAY_MS = 300;
|
||||
|
||||
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
|
||||
|
||||
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
export default function CampaignReportPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const version = data.currentVersion;
|
||||
const cards = data.summary?.cards;
|
||||
const sendCounts = data.summary?.status_counts?.send ?? {};
|
||||
const imapCounts = data.summary?.status_counts?.imap ?? {};
|
||||
const delivery = asRecord(data.summary?.delivery);
|
||||
const postboxReceipts = asRecord(data.summary?.postbox_receipts);
|
||||
const retention = data.summary?.retention;
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||
const canRetry = hasScope(auth, "campaigns:campaign:retry");
|
||||
const canSend = hasScope(auth, "campaigns:campaign:send");
|
||||
const canQueue = hasScope(auth, "campaigns:campaign:queue");
|
||||
const canReconcile = hasScope(auth, "campaigns:campaign:reconcile");
|
||||
const workersAvailable = delivery.background_workers_enabled === true || delivery.celery_enabled === true;
|
||||
const reportContext = JSON.stringify([campaignId, version?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant.id, auth.user.id]);
|
||||
const reportContextRef = useRef(reportContext);
|
||||
reportContextRef.current = reportContext;
|
||||
|
||||
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||
const jobsRequestRef = useRef(0);
|
||||
@@ -140,7 +123,18 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
const [emailRecipients, setEmailRecipients] = useState("");
|
||||
const [attachCsv, setAttachCsv] = useState(true);
|
||||
const [attachJson, setAttachJson] = useState(false);
|
||||
const [reconcile, setReconcile] = useState<ReconcileRequest>(null);
|
||||
const [reconcile, setReconcile] = useState<ReportReconciliation | null>(null);
|
||||
const pendingAction = useRef(false);
|
||||
const [progress, setProgress] = useState<{ versionId: string; requestState: "running" | "finished" | "interrupted"; requestError?: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
++jobsRequestRef.current;
|
||||
setJobs(emptyCampaignJobsResponse());
|
||||
setDetail(null); setReconcile(null); setProgress(null);
|
||||
setActionError(""); setActionMessage(""); setBusyAction("");
|
||||
pendingAction.current = false;
|
||||
setPage(1);
|
||||
}, [reportContext]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
@@ -172,6 +166,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
|
||||
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
|
||||
{ label: "i18n:govoplan-campaign.delivery_status_sending", value: (sendCounts.claimed ?? 0) + (sendCounts.sending ?? 0), shortcutId: "smtp_active" },
|
||||
{ label: "i18n:govoplan-campaign.delivery_status_queued", value: sendCounts.queued ?? 0, shortcutId: "smtp_queued" },
|
||||
{ label: "Postbox accepted", value: cards?.postbox_accepted ?? 0, shortcutId: "postbox_accepted" },
|
||||
{ label: "Print accepted", value: cards?.print_accepted ?? 0, shortcutId: "print_accepted" },
|
||||
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
|
||||
@@ -181,13 +177,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
{ label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" }
|
||||
];
|
||||
const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||
{ label: "i18n:govoplan-campaign.delivery_status_pending", value: imapCounts.pending ?? 0, shortcutId: "imap_pending" },
|
||||
{ label: "i18n:govoplan-campaign.delivery_status_appending", value: imapCounts.appending ?? 0, shortcutId: "imap_active" },
|
||||
{ label: "i18n:govoplan-campaign.delivery_status_outcome_unknown", value: imapCounts.outcome_unknown ?? 0, shortcutId: "imap_unknown" },
|
||||
{ label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" },
|
||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" },
|
||||
{ label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" }
|
||||
];
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
if (!campaignId) return;
|
||||
if (!campaignId || !version?.id) return;
|
||||
const requestId = ++jobsRequestRef.current;
|
||||
setJobsLoading(true);
|
||||
setActionError("");
|
||||
@@ -200,16 +199,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId),
|
||||
sortDirection: appliedJobGridQuery.sort?.direction ?? "asc",
|
||||
filters: appliedJobGridQuery.filters
|
||||
});
|
||||
if (requestId !== jobsRequestRef.current) return;
|
||||
}, { cache: "no-store" });
|
||||
if (requestId !== jobsRequestRef.current || reportContext !== reportContextRef.current) return;
|
||||
setJobs(response);
|
||||
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
||||
} catch (err) {
|
||||
if (requestId === jobsRequestRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||
if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (requestId === jobsRequestRef.current) setJobsLoading(false);
|
||||
if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setJobsLoading(false);
|
||||
}
|
||||
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery]);
|
||||
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery, reportContext]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
@@ -220,7 +219,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
}
|
||||
|
||||
async function runExplicitAction(action: "retry" | "unattempted") {
|
||||
if (!version || busyAction) return;
|
||||
if (!version || busyAction || pendingAction.current || !workersAvailable || (action === "retry" ? !canRetry : !canQueue)) return;
|
||||
pendingAction.current = true;
|
||||
setBusyAction(action);
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
@@ -228,94 +228,74 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
const response = action === "retry" ?
|
||||
await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
|
||||
await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
||||
if (reportContext !== reportContextRef.current) return;
|
||||
const result = asRecord(response.result ?? response);
|
||||
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
|
||||
}
|
||||
}
|
||||
|
||||
const failedRowsOnPage = useMemo(
|
||||
() => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")),
|
||||
() => jobs.jobs.filter((row) => reportRetryableFailure(row) && String(row.id ?? "")),
|
||||
[jobs.jobs]
|
||||
);
|
||||
const unattemptedRowsOnPage = useMemo(() => jobs.jobs.filter(row => reportUnattempted(row) && String(row.id ?? "")), [jobs.jobs]);
|
||||
|
||||
async function retryFailedSynchronously(rows: Record<string, unknown>[]) {
|
||||
if (!version || busyAction || rows.length === 0) return;
|
||||
setBusyAction(rows.length === 1 ? `retry-sync:${String(rows[0].id ?? "")}` : "retry-sync-page");
|
||||
async function runInlineRecovery(action: CampaignInlineRecoveryAction, rows: Record<string, unknown>[]) {
|
||||
if (!version || busyAction || pendingAction.current || !canSend || (action === "retry" ? !canRetry : !canQueue) || rows.length === 0) return;
|
||||
const jobIds = [...new Set(rows.map(row => String(row.id ?? "")).filter(Boolean))];
|
||||
if (!jobIds.length) return;
|
||||
pendingAction.current = true;
|
||||
setBusyAction(`inline:${action}`);
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
let attempted = 0;
|
||||
let accepted = 0;
|
||||
let skipped = 0;
|
||||
const failures: string[] = [];
|
||||
const versionId = version.id;
|
||||
setProgress({ versionId, requestState: "running" });
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const jobId = String(row.id ?? "");
|
||||
if (!jobId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const sendStatus = String(row.send_status ?? "");
|
||||
const queueResponse = await retryCampaignJobs(settings, campaignId, {
|
||||
version_id: version.id,
|
||||
job_ids: [jobId],
|
||||
include_permanent: sendStatus === "failed_permanent",
|
||||
enqueue_celery: false
|
||||
});
|
||||
const queueResult = asRecord(queueResponse.result ?? queueResponse);
|
||||
if (Number(queueResult.selected_count ?? 0) < 1) {
|
||||
skipped += 1;
|
||||
const skippedRows = Array.isArray(queueResult.skipped) ? queueResult.skipped.map(asRecord) : [];
|
||||
const reason = String(skippedRows[0]?.reason ?? "not selected for retry");
|
||||
failures.push(`${shortJobId(jobId)}: ${reason}`);
|
||||
continue;
|
||||
}
|
||||
attempted += 1;
|
||||
try {
|
||||
const sendResponse = await sendCampaignJob(settings, campaignId, jobId, {
|
||||
kind: "single_resend",
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
reason: "Operator requested synchronous resend from the campaign report.",
|
||||
include_warnings: true,
|
||||
use_rate_limit: true,
|
||||
enqueue_imap_task: false
|
||||
});
|
||||
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
|
||||
const status = String(sendResult.status ?? "submitted");
|
||||
if (["smtp_accepted", "postbox_accepted", "print_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
|
||||
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
|
||||
} catch (err) {
|
||||
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
const failed = failures.length;
|
||||
setActionMessage(`Synchronous retry finished: ${attempted} attempted, ${accepted} accepted, ${failed} failed, ${skipped} skipped.`);
|
||||
if (failures.length > 0) setActionError(failures.slice(0, 5).join("\n"));
|
||||
await reloadAll();
|
||||
const result = await runCampaignInlineRecovery(settings, campaignId, action, {
|
||||
version_id: versionId, job_ids: jobIds,
|
||||
...(action === "retry" ? { include_permanent: rows.some(row => row.send_status === "failed_permanent") } : {})
|
||||
});
|
||||
if (reportContext !== reportContextRef.current) return;
|
||||
setProgress({ versionId, requestState: "finished" });
|
||||
setActionMessage(i18nMessage("i18n:govoplan-campaign.report_inline_result", {
|
||||
value0: result.attempted_count ?? 0, value1: result.sent_count ?? 0,
|
||||
value2: result.failed_count ?? 0, value3: result.outcome_unknown_count ?? 0, value4: result.remaining_count ?? 0
|
||||
}));
|
||||
try { await reloadAll(); }
|
||||
catch { if (reportContext === reportContextRef.current) setActionError("i18n:govoplan-campaign.report_acknowledged_refresh_failed"); }
|
||||
} catch (cause) {
|
||||
if (reportContext !== reportContextRef.current) return;
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setActionError(message);
|
||||
setProgress({ versionId, requestState: "interrupted", requestError: message });
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileOutcome() {
|
||||
if (!reconcile || busyAction) return;
|
||||
async function reconcileOutcome(note: string) {
|
||||
if (!reconcile || busyAction || pendingAction.current || !canReconcile || !note.trim()) return;
|
||||
pendingAction.current = true;
|
||||
setBusyAction("reconcile");
|
||||
setActionError("");
|
||||
try {
|
||||
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
||||
setActionMessage(reconcile.decision === "smtp_accepted" ?
|
||||
"i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
|
||||
"i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
|
||||
if (reconcile.kind === "claim") {
|
||||
await recoverCampaignJobClaim(settings, campaignId, reconcile.jobId, { channel: reconcile.channel, expected_revision: reconcile.revision, note });
|
||||
} else {
|
||||
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision, note);
|
||||
}
|
||||
if (reportContext !== reportContextRef.current) return;
|
||||
setActionMessage(reconcile.kind === "claim" ? "i18n:govoplan-campaign.report_claim_recovered" : "i18n:govoplan-campaign.report_evidence_recorded");
|
||||
setReconcile(null);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
try { await reloadAll(); }
|
||||
catch { if (reportContext === reportContextRef.current) setActionError("i18n:govoplan-campaign.report_acknowledged_refresh_failed"); }
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,11 +303,12 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
setBusyAction("detail");
|
||||
setActionError("");
|
||||
try {
|
||||
setDetail(await getCampaignJobDetail(settings, campaignId, jobId));
|
||||
const response = await getCampaignJobDetail(settings, campaignId, jobId);
|
||||
if (reportContext === reportContextRef.current) setDetail(response);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
if (reportContext === reportContextRef.current) setBusyAction("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,13 +357,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (row) =>
|
||||
<div className="recipient-outcome-cell">
|
||||
<strong>{String(row.recipient_email ?? "—")}</strong>
|
||||
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
|
||||
</div>,
|
||||
|
||||
value: (row) => String(row.recipient_email ?? "—")
|
||||
render: (row) => <ReportRecipients row={row} />,
|
||||
value: reportRecipientSearchText
|
||||
},
|
||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||
@@ -422,7 +398,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
width: "minmax(220px, 1fr)",
|
||||
maxWidth: 720,
|
||||
resizable: true,
|
||||
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
|
||||
render: (row) => <div className="recipient-outcome-cell"><span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>
|
||||
{reportRecoveryHints(row).map(hint => <span key={hint}>{hint}</span>)}</div>,
|
||||
value: (row) => String(row.last_error ?? "—")
|
||||
},
|
||||
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) },
|
||||
@@ -434,16 +411,23 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
render: (row) => {
|
||||
const id = String(row.id ?? "");
|
||||
const status = String(row.send_status ?? "");
|
||||
const smtpClaim = reportClaimRecovery(row, "smtp");
|
||||
const imapClaim = reportClaimRecovery(row, "imap");
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
|
||||
{ id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
|
||||
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
|
||||
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
|
||||
{ id: "retry", label: "i18n:govoplan-campaign.report_retry_now", icon: <RotateCcw aria-hidden="true" />, applicable: reportRetryableFailure(row), disabled: !id || !canRetry || !canSend || Boolean(busyAction), onClick: () => void runInlineRecovery("retry", [row]) },
|
||||
{ id: "unattempted", label: "i18n:govoplan-campaign.report_send_unattempted_now", icon: <RotateCcw aria-hidden="true" />, applicable: reportUnattempted(row), disabled: !id || !canQueue || !canSend || Boolean(busyAction), onClick: () => void runInlineRecovery("send-unattempted", [row]) },
|
||||
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "smtp_accepted" }) },
|
||||
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "not_sent" }) },
|
||||
{ id: "recover-smtp", label: "i18n:govoplan-campaign.report_recover_smtp_claim", icon: <RotateCcw aria-hidden="true" />, applicable: smtpClaim.eligible, disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "claim", jobId: id, channel: "smtp", revision: smtpClaim.revision }) },
|
||||
{ id: "recover-imap", label: "i18n:govoplan-campaign.report_recover_imap_claim", icon: <RotateCcw aria-hidden="true" />, applicable: imapClaim.eligible, disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "claim", jobId: id, channel: "imap", revision: imapClaim.revision }) },
|
||||
{ id: "imap-appended", label: "i18n:govoplan-campaign.report_record_imap_appended", icon: <Check aria-hidden="true" />, applicable: row.imap_status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "imap_appended" }) },
|
||||
{ id: "imap-not-appended", label: "i18n:govoplan-campaign.report_record_imap_not_appended", icon: <X aria-hidden="true" />, variant: "danger", applicable: row.imap_status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "imap_not_appended" }) }
|
||||
]} />;
|
||||
|
||||
}
|
||||
}],
|
||||
[busyAction, retryFailedSynchronously]);
|
||||
[busyAction, canRetry, canSend, canQueue, canReconcile, runInlineRecovery]);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
@@ -457,7 +441,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
actions={<PageActionBar
|
||||
variant="detail"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading }}
|
||||
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading, disabled: Boolean(busyAction) }}
|
||||
primaryActions={<>
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||
@@ -533,12 +517,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
||||
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
|
||||
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
||||
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || !canRetry || !workersAvailable || Boolean(busyAction)}>i18n:govoplan-campaign.report_queue_retry_workers</Button>
|
||||
<Button onClick={() => void runInlineRecovery("retry", failedRowsOnPage)} disabled={!version || !canRetry || !canSend || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
||||
{i18nMessage("i18n:govoplan-campaign.report_retry_page_now", { value0: failedRowsOnPage.length })}
|
||||
</Button>
|
||||
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || !canQueue || !workersAvailable || Boolean(busyAction)}>i18n:govoplan-campaign.report_queue_unattempted_workers</Button>
|
||||
<Button onClick={() => void runInlineRecovery("send-unattempted", unattemptedRowsOnPage)} disabled={!version || !canQueue || !canSend || Boolean(busyAction) || unattemptedRowsOnPage.length === 0}>
|
||||
{i18nMessage("i18n:govoplan-campaign.report_send_page_now", { value0: unattemptedRowsOnPage.length })}
|
||||
</Button>
|
||||
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||
</div>
|
||||
{!workersAvailable && <p className="muted">i18n:govoplan-campaign.report_workers_unavailable</p>}
|
||||
<p className="muted">i18n:govoplan-campaign.report_inline_scope_help</p>
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
|
||||
@@ -612,7 +601,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
{detail &&
|
||||
<div className="stacked-sections">
|
||||
<DescriptionList variant="inline">
|
||||
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.recipients.78cbf8eb</dt><dd><ReportRecipients row={detail.job} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
|
||||
@@ -640,17 +629,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(reconcile)}
|
||||
title={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_smtp_acceptance.c40f8c9d" : "i18n:govoplan-campaign.record_message_as_not_sent.42e4faf8"}
|
||||
message={reconcile?.decision === "smtp_accepted" ?
|
||||
"i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
|
||||
"i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
|
||||
confirmLabel={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_accepted.023d6747" : "i18n:govoplan-campaign.record_not_sent.b376b4ed"}
|
||||
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
|
||||
busy={busyAction === "reconcile"}
|
||||
onConfirm={() => void reconcileOutcome()}
|
||||
onCancel={() => setReconcile(null)} />
|
||||
{reconcile && <ReportRecoveryDialog key={`${reconcile.jobId}-${reconcile.kind}-${reconcile.kind === "claim" ? reconcile.channel : reconcile.decision}`}
|
||||
request={reconcile} onConfirm={reconcileOutcome} onClose={() => setReconcile(null)} />}
|
||||
{progress && <CampaignDeliveryProgressDialog settings={settings} campaignId={campaignId} versionId={progress.versionId}
|
||||
kind="smtp" requestState={progress.requestState} requestError={progress.requestError}
|
||||
onClose={() => { if (!pendingAction.current) setProgress(null); }} />}
|
||||
|
||||
</PageLayout>);
|
||||
|
||||
@@ -1049,10 +1032,6 @@ function initialReportGridFilters(): Record<string, string | string[]> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function deliveryStatusLabel(status: string): string | undefined {
|
||||
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
|
||||
}
|
||||
|
||||
function calendarRsvpStatus(row: Record<string, unknown>): string {
|
||||
const invitation = asRecord(row.calendar_invitation);
|
||||
return String(invitation.rsvp_status || "—");
|
||||
@@ -1086,11 +1065,3 @@ function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
|
||||
}
|
||||
return "number";
|
||||
}
|
||||
|
||||
function retryableFailedStatus(status: string): boolean {
|
||||
return status === "failed_temporary" || status === "failed_permanent" || status === "partially_accepted";
|
||||
}
|
||||
|
||||
function shortJobId(jobId: string): string {
|
||||
return jobId.length > 12 ? `${jobId.slice(0, 12)}...` : jobId;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<Route path="policy" element={<Navigate to="../policies" replace />} />
|
||||
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="report" element={<CampaignReportPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||
<Route path="work" element={hasScope(auth, "campaigns:assignment:read") ? <CampaignWorkPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||
|
||||
@@ -15,7 +15,9 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { PolicyRow } from "@govoplan/core-webui";
|
||||
import { PolicyTable } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||
import CampaignAccessCard from "./components/CampaignAccessCard";
|
||||
import CampaignArchiveEncryptionPolicyNotice from "./components/CampaignArchiveEncryptionPolicyNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { hasScope } from "@govoplan/core-webui";
|
||||
@@ -63,7 +65,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -183,21 +185,23 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
mode="workspace"
|
||||
title={pageTitle}
|
||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||
headerLoading={loading}
|
||||
headerLoading={loading || saving}
|
||||
error={error}
|
||||
actions={<PageActionBar
|
||||
variant="editor"
|
||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||
/>}
|
||||
notices={(localError || locked) ? <>
|
||||
notices={(localError || locked || version?.mail_profile_migration_required) ? <>
|
||||
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
</> : undefined}
|
||||
>
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<CampaignArchiveEncryptionPolicyNotice settings={settings} auth={auth} campaignId={campaignId} />
|
||||
{isPolicyView ?
|
||||
<>
|
||||
{canReadRetentionPolicy &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FormGrid,
|
||||
Button,
|
||||
Card,
|
||||
@@ -33,10 +33,11 @@ import {
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { getBool, getText } from "./utils/draftEditor";
|
||||
import { campaignMailProfileReferenceOnly } from "./utils/mailProfileReference";
|
||||
import { campaignMailProfileListOptions, campaignMailProfileReferenceOnly } from "./utils/mailProfileReference";
|
||||
|
||||
type MailSettingsView = "settings" | "policy";
|
||||
|
||||
@@ -63,6 +64,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||
const profileLoadGeneration = useRef(0);
|
||||
const [migrationSaving, setMigrationSaving] = useState(false);
|
||||
const [profileError, setProfileError] = useState("");
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
@@ -81,7 +84,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const migrationRequired = version?.mail_profile_migration_required === true;
|
||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, displayDraft, dirty, saving, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -104,30 +107,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const smtpServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "smtp" && item.is_active);
|
||||
const imapServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "imap" && item.is_active);
|
||||
const selectedSmtpServer = smtpServers.find((item) => item.id === getText(server, "smtp_server_id"))
|
||||
?? smtpServers.find((item) => item.is_default)
|
||||
?? smtpServers[0]
|
||||
?? null;
|
||||
const selectedImapServer = imapServers.find((item) => item.id === getText(server, "imap_server_id"))
|
||||
?? imapServers.find((item) => item.is_default)
|
||||
?? imapServers[0]
|
||||
?? null;
|
||||
const smtpServerId = getText(server, "smtp_server_id");
|
||||
const imapServerId = getText(server, "imap_server_id");
|
||||
const selectedSmtpServer = (smtpServerId
|
||||
? smtpServers.find((item) => item.id === smtpServerId)
|
||||
: smtpServers.find((item) => item.is_default) ?? smtpServers[0]) ?? null;
|
||||
const selectedImapServer = (imapServerId
|
||||
? imapServers.find((item) => item.id === imapServerId)
|
||||
: imapServers.find((item) => item.is_default) ?? imapServers[0]) ?? null;
|
||||
const selectedSmtpCredential = selectedSmtpServer?.credentials.find((item) => item.id === getText(server, "smtp_credential_id"))
|
||||
?? selectedSmtpServer?.credentials.find((item) => item.is_default)
|
||||
?? selectedSmtpServer?.credentials[0]
|
||||
?? null;
|
||||
const selectedImapCredential = selectedImapServer?.credentials.find((item) => item.id === getText(server, "imap_credential_id"))
|
||||
?? selectedImapServer?.credentials.find((item) => item.is_default)
|
||||
?? selectedImapServer?.credentials[0]
|
||||
?? null;
|
||||
const delivery = asRecord(displayDraft.delivery);
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||
const selectedProfileHasImap = Boolean(selectedImapServer);
|
||||
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
||||
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
|
||||
const canMigrate = migrationRequired && !locked && !saving && Boolean(draft) && Boolean(selectedProfile) && !profilesLoading;
|
||||
const canSave = dirty && !locked && !saving && !migrationSaving && Boolean(draft) && (!migrationRequired || canMigrate);
|
||||
|
||||
useEffect(() => {
|
||||
profileLoadGeneration.current += 1;
|
||||
if (!mailModuleInstalled) {
|
||||
setMailProfiles([]);
|
||||
setPolicyProfiles([]);
|
||||
@@ -135,25 +136,41 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
return;
|
||||
}
|
||||
void refreshMailProfiles();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, mailModuleInstalled]);
|
||||
return () => {
|
||||
profileLoadGeneration.current += 1;
|
||||
};
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, mailModuleInstalled, view]);
|
||||
|
||||
async function refreshMailProfiles() {
|
||||
if (!mailModuleInstalled) return;
|
||||
const generation = ++profileLoadGeneration.current;
|
||||
setProfilesLoading(true);
|
||||
setProfileError("");
|
||||
try {
|
||||
const [allowedProfiles, visibleProfiles] = await Promise.all([
|
||||
listMailServerProfiles(settings, false, campaignId),
|
||||
listMailServerProfiles(settings, true)
|
||||
]);
|
||||
setMailProfiles(allowedProfiles);
|
||||
setPolicyProfiles(visibleProfiles);
|
||||
const options = campaignMailProfileListOptions(view, campaignId);
|
||||
const profiles = await listMailServerProfiles(settings, options.includeInactive, options.campaignId);
|
||||
if (generation !== profileLoadGeneration.current) return;
|
||||
if (isPolicyView) setPolicyProfiles(profiles);
|
||||
else setMailProfiles(profiles);
|
||||
} catch (err) {
|
||||
setMailProfiles([]);
|
||||
setPolicyProfiles([]);
|
||||
if (generation !== profileLoadGeneration.current) return;
|
||||
if (isPolicyView) setPolicyProfiles([]);
|
||||
else setMailProfiles([]);
|
||||
setProfileError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setProfilesLoading(false);
|
||||
if (generation === profileLoadGeneration.current) setProfilesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateMailProfile() {
|
||||
if (!canMigrate || migrationSaving) return;
|
||||
setMigrationSaving(true);
|
||||
try {
|
||||
// Explicit consent is needed even when the existing public profile
|
||||
// reference is unchanged, so this action deliberately works on a clean draft.
|
||||
await saveDraft("manual");
|
||||
} finally {
|
||||
setMigrationSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,10 +207,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
?? null;
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
protocol === "smtp" ? selected?.id : selectedSmtpServer?.id,
|
||||
protocol === "smtp" ? credential?.id : selectedSmtpCredential?.id,
|
||||
protocol === "imap" ? selected?.id : selectedImapServer?.id,
|
||||
protocol === "imap" ? credential?.id : selectedImapCredential?.id
|
||||
protocol === "smtp" ? selected?.id : getText(server, "smtp_server_id"),
|
||||
protocol === "smtp" ? credential?.id : getText(server, "smtp_credential_id"),
|
||||
protocol === "imap" ? selected?.id : getText(server, "imap_server_id"),
|
||||
protocol === "imap" ? credential?.id : getText(server, "imap_credential_id")
|
||||
));
|
||||
if (protocol === "smtp") setSmtpTestResult(null);
|
||||
else {
|
||||
@@ -206,10 +223,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
if (!selectedProfile || locked) return;
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
selectedSmtpServer?.id,
|
||||
protocol === "smtp" ? credentialId : selectedSmtpCredential?.id,
|
||||
selectedImapServer?.id,
|
||||
protocol === "imap" ? credentialId : selectedImapCredential?.id
|
||||
protocol === "smtp" ? selectedSmtpServer?.id : getText(server, "smtp_server_id"),
|
||||
protocol === "smtp" ? credentialId : getText(server, "smtp_credential_id"),
|
||||
protocol === "imap" ? selectedImapServer?.id : getText(server, "imap_server_id"),
|
||||
protocol === "imap" ? credentialId : getText(server, "imap_credential_id")
|
||||
));
|
||||
if (protocol === "smtp") setSmtpTestResult(null);
|
||||
else {
|
||||
@@ -282,7 +299,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedSmtpServer?.id,
|
||||
selectedSmtpCredential?.id,
|
||||
getText(server, "smtp_credential_id") || undefined,
|
||||
campaignId
|
||||
));
|
||||
} else {
|
||||
@@ -290,7 +307,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedImapServer?.id,
|
||||
selectedImapCredential?.id,
|
||||
getText(server, "imap_credential_id") || undefined,
|
||||
campaignId
|
||||
));
|
||||
}
|
||||
@@ -312,7 +329,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedImapServer?.id,
|
||||
selectedImapCredential?.id,
|
||||
getText(server, "imap_credential_id") || undefined,
|
||||
campaignId
|
||||
));
|
||||
} catch (err) {
|
||||
@@ -333,7 +350,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
mode="workspace"
|
||||
title={isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}
|
||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||
headerLoading={loading}
|
||||
headerLoading={loading || migrationSaving || saving}
|
||||
error={error}
|
||||
actions={isPolicyView ? (
|
||||
<PageActionBar
|
||||
@@ -344,7 +361,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
) : (
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||
state={loading || migrationSaving || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current mail-settings blocker before saving." : undefined }}
|
||||
/>
|
||||
@@ -359,9 +376,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
<>
|
||||
{!mailModuleInstalled && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.install_and_enable_the_mail_module_to_select_a_d.01c75fc4</DismissibleAlert>}
|
||||
|
||||
{migrationRequired && <DismissibleAlert tone="warning" dismissible={false}>
|
||||
i18n:govoplan-campaign.this_version_contains_legacy_campaign_local_mail.44c7a6fd
|
||||
</DismissibleAlert>}
|
||||
{migrationRequired && <LegacyMailMigrationNotice
|
||||
campaignId={campaignId}
|
||||
versionId={version?.id}
|
||||
showSettingsLink={isPolicyView}
|
||||
onMigrate={!isPolicyView && !locked ? () => void migrateMailProfile() : undefined}
|
||||
canMigrate={canMigrate}
|
||||
busy={migrationSaving}
|
||||
/>}
|
||||
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissible={false}>{profileError}</DismissibleAlert>}
|
||||
|
||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && <MailProfilePolicyEditor
|
||||
settings={settings}
|
||||
@@ -390,38 +414,44 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
|
||||
{selectedProfileId && !selectedProfile && <option value={selectedProfileId}>i18n:govoplan-campaign.unavailable_selected_profile</option>}
|
||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="SMTP server">
|
||||
<select value={selectedSmtpServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
|
||||
<option value="">No SMTP server</option>
|
||||
<select value={smtpServerId} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
|
||||
<option value="">i18n:govoplan-campaign.inherit_mail_server</option>
|
||||
{smtpServerId && !selectedSmtpServer && <option value={smtpServerId}>i18n:govoplan-campaign.unavailable_selected_server</option>}
|
||||
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="SMTP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
||||
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
||||
<option value="">No credential</option>
|
||||
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={getText(server, "smtp_credential_id")} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
||||
<option value="">i18n:govoplan-campaign.inherit_mail_credential_when_allowed</option>
|
||||
{getText(server, "smtp_credential_id") && !selectedSmtpServer?.credentials.some((item) => item.is_active && item.id === getText(server, "smtp_credential_id")) && <option value={getText(server, "smtp_credential_id")}>i18n:govoplan-campaign.unavailable_selected_credential</option>}
|
||||
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="IMAP server">
|
||||
<select value={selectedImapServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
|
||||
<option value="">No IMAP server</option>
|
||||
<select value={imapServerId} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
|
||||
<option value="">i18n:govoplan-campaign.inherit_mail_server</option>
|
||||
{imapServerId && !selectedImapServer && <option value={imapServerId}>i18n:govoplan-campaign.unavailable_selected_server</option>}
|
||||
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="IMAP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
||||
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
||||
<option value="">No credential</option>
|
||||
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={getText(server, "imap_credential_id")} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
||||
<option value="">i18n:govoplan-campaign.inherit_mail_credential_when_allowed</option>
|
||||
{getText(server, "imap_credential_id") && !selectedImapServer?.credentials.some((item) => item.is_active && item.id === getText(server, "imap_credential_id")) && <option value={getText(server, "imap_credential_id")}>i18n:govoplan-campaign.unavailable_selected_credential</option>}
|
||||
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.explicit_mail_credential_help</p>
|
||||
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
||||
{selectedProfile && <MetricGrid spacing="inset">
|
||||
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
||||
@@ -435,7 +465,6 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
</div>
|
||||
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
|
||||
{imapTestResult && <DismissibleAlert tone={imapTestResult.ok ? "success" : "danger"} resetKey={`${imapTestResult.protocol}:${imapTestResult.message}`} floating>{imapTestResult.message}</DismissibleAlert>}
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||
</Card>}
|
||||
|
||||
<Dialog variant="administration" size="wide"
|
||||
@@ -541,8 +570,8 @@ function mailReference(
|
||||
}
|
||||
|
||||
function serverEndpointLabel(config: MailServerEndpoint["config"]): string {
|
||||
const host = typeof config.host === "string" && config.host ? config.host : "No host";
|
||||
return config.port ? `${host}:${config.port}` : host;
|
||||
const host = "host" in config && typeof config.host === "string" && config.host ? config.host : "No host";
|
||||
return "port" in config && config.port ? `${host}:${config.port}` : host;
|
||||
}
|
||||
|
||||
function credentialLabel(credential: MailCredentialEnvelope): string {
|
||||
|
||||
@@ -53,14 +53,13 @@ import {
|
||||
AddressHeaderControl,
|
||||
HeaderAddressEditorDialog,
|
||||
RecipientAddressEditorDialog,
|
||||
entryWithAddressList,
|
||||
entryWithAddressValues,
|
||||
formatAddressCollectionForClipboard,
|
||||
getAddressColumn,
|
||||
getEntryAddresses,
|
||||
headerAddressValues,
|
||||
hiddenRecipientAddressMatch,
|
||||
recipientAddressFilterValue,
|
||||
recipientAddressOverlayColumns,
|
||||
recipientAddressSummary,
|
||||
recipientHeaderRows,
|
||||
type AddressFieldKey,
|
||||
@@ -110,7 +109,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, setDraft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -293,17 +292,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
values: HeaderAddressValues,
|
||||
merges: EntryAddressMergeValues
|
||||
) {
|
||||
updateEntry(index, (entry) => {
|
||||
let nextEntry = entry;
|
||||
for (const column of recipientAddressOverlayColumns) {
|
||||
if (!(column.key in values)) continue;
|
||||
nextEntry = entryWithAddressList(nextEntry, column.key, values[column.key] ?? []);
|
||||
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
|
||||
nextEntry = { ...nextEntry, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
|
||||
delete nextEntry[column.mergeKey.replace("merge_", "combine_")];
|
||||
}
|
||||
return nextEntry;
|
||||
});
|
||||
updateEntry(index, (entry) => entryWithAddressValues(entry, values, merges));
|
||||
setRecipientAddressEditorIndex(null);
|
||||
}
|
||||
|
||||
@@ -417,6 +406,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
campaignId={campaignId}
|
||||
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
version={version}
|
||||
versions={data.versions}
|
||||
saveState={saveState}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
||||
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
|
||||
@@ -79,7 +80,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, setDraft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -478,17 +479,18 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
mode="workspace"
|
||||
title="i18n:govoplan-campaign.template.3ec1ae06"
|
||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||
headerLoading={loading}
|
||||
headerLoading={loading || saving}
|
||||
error={error}
|
||||
success={contentLibraryNotice}
|
||||
actions={<PageActionBar
|
||||
variant="editor"
|
||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||
contextActions={<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>}
|
||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||
/>}
|
||||
notices={(localError || locked) ? <>
|
||||
notices={(localError || locked || version?.mail_profile_migration_required) ? <>
|
||||
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
</> : undefined}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { Dialog, DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
|
||||
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
@@ -161,6 +161,7 @@ export function AttachmentRulesDataGrid({
|
||||
onChange
|
||||
}: AttachmentRulesTableProps) {
|
||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||
const [chooserError, setChooserError] = useState("");
|
||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
||||
const managedFilesAvailable = Boolean(ManagedFileChooser);
|
||||
@@ -187,7 +188,12 @@ export function AttachmentRulesDataGrid({
|
||||
}
|
||||
|
||||
function openFileChooser(ruleIndex: number) {
|
||||
if (!managedFilesAvailable) return;
|
||||
if (disabled) return;
|
||||
setChooserError("");
|
||||
if (!managedFilesAvailable) {
|
||||
setChooserError("i18n:govoplan-campaign.file_chooser_unavailable");
|
||||
return;
|
||||
}
|
||||
if (onOpenFileChooser) {
|
||||
onOpenFileChooser(ruleIndex);
|
||||
return;
|
||||
@@ -200,7 +206,10 @@ export function AttachmentRulesDataGrid({
|
||||
basePaths.find((item) => item.path === currentPath) ?? (
|
||||
!explicitlyReferenced ? basePaths[0] : undefined) ??
|
||||
null;
|
||||
if (!basePath) return;
|
||||
if (!basePath) {
|
||||
setChooserError("i18n:govoplan-campaign.choose_available_attachment_source");
|
||||
return;
|
||||
}
|
||||
setFileChooser({ ruleIndex, basePath });
|
||||
}
|
||||
|
||||
@@ -220,10 +229,11 @@ export function AttachmentRulesDataGrid({
|
||||
|
||||
return (
|
||||
<>
|
||||
{(chooserError || filesModuleInstalled && !managedFilesAvailable) && <DismissibleAlert tone="warning" dismissible={false}>{chooserError || "i18n:govoplan-campaign.file_chooser_unavailable"}</DismissibleAlert>}
|
||||
<DataGrid
|
||||
id={id}
|
||||
rows={rules}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||
getRowKey={(rule, index) => String(rule.id ?? index)}
|
||||
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
|
||||
@@ -242,7 +252,7 @@ export function AttachmentRulesDataGrid({
|
||||
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||
previewContext={previewContext}
|
||||
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
||||
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context ?? {}, false)}
|
||||
onClose={() => setFileChooser(null)}
|
||||
onSelectAttachment={selectAttachment} />
|
||||
|
||||
@@ -316,7 +326,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
|
||||
value={getText(rule, "file_filter")}
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
readOnly={filesModuleInstalled}
|
||||
tabIndex={filesModuleInstalled ? -1 : undefined}
|
||||
tabIndex={0}
|
||||
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
|
||||
onChange={(event) => {
|
||||
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
hasScope,
|
||||
useGuardedNavigate,
|
||||
usePlatformModuleInstalled
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo } from "../../../types";
|
||||
import {
|
||||
getCampaignArchiveEncryptionPolicy,
|
||||
type CampaignArchiveEncryptionPolicy
|
||||
} from "../../../api/campaigns";
|
||||
|
||||
export const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
|
||||
available: false,
|
||||
allowed_password_encryption_methods: ["aes"],
|
||||
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
||||
policy_hash: "",
|
||||
source_path: [],
|
||||
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
|
||||
diagnostics: [],
|
||||
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||
};
|
||||
|
||||
export default function CampaignArchiveEncryptionPolicyNotice({
|
||||
settings,
|
||||
auth,
|
||||
campaignId,
|
||||
onPolicyChange
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
campaignId: string;
|
||||
onPolicyChange?: (policy: CampaignArchiveEncryptionPolicy) => void;
|
||||
}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const policyInstalled = usePlatformModuleInstalled("policy");
|
||||
const [policy, setPolicy] = useState(UNAVAILABLE_ARCHIVE_POLICY);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const allowed = policy.available && policy.allowed_password_encryption_methods.includes("zip_standard");
|
||||
const canReadPolicy = policyInstalled && hasScope(auth, "admin:policies:read");
|
||||
const canUseLegacy = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setPolicy(UNAVAILABLE_ARCHIVE_POLICY);
|
||||
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
|
||||
.then((loaded) => { if (!cancelled) setPolicy(loaded); })
|
||||
.catch((cause) => {
|
||||
if (!cancelled) setPolicy({
|
||||
...UNAVAILABLE_ARCHIVE_POLICY,
|
||||
reason: cause instanceof Error ? cause.message : String(cause)
|
||||
});
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignId, refresh, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => { onPolicyChange?.(policy); }, [onPolicyChange, policy]);
|
||||
|
||||
return <DismissibleAlert tone={allowed ? "warning" : "info"} dismissible={false} compact>
|
||||
<strong>{policy.legacy_label}</strong>: {policy.reason}
|
||||
{policy.source_path.length > 0 && <p>{policy.source_path.map((step) => step.label).join(" → ")}</p>}
|
||||
<p>i18n:govoplan-campaign.archive_policy_enable_guidance</p>
|
||||
{!canUseLegacy && <p>i18n:govoplan-campaign.archive_policy_permission_missing</p>}
|
||||
{!policyInstalled && <p>i18n:govoplan-campaign.archive_policy_module_missing</p>}
|
||||
{canReadPolicy && <Button
|
||||
helpContextId="campaign.archive-encryption"
|
||||
helpModuleId="campaigns"
|
||||
onClick={() => navigate("/admin?section=system-campaign-archive-encryption")}
|
||||
>i18n:govoplan-campaign.archive_policy_open_system</Button>}
|
||||
{canReadPolicy && <Button
|
||||
onClick={() => navigate("/admin?section=tenant-campaign-archive-encryption")}
|
||||
>i18n:govoplan-campaign.archive_policy_open_tenant</Button>}
|
||||
<Button disabled={loading} onClick={() => setRefresh((value) => value + 1)}>
|
||||
i18n:govoplan-campaign.archive_policy_reload
|
||||
</Button>
|
||||
</DismissibleAlert>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webu
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
import LockedVersionNotice from "./LockedVersionNotice";
|
||||
import LegacyMailMigrationNotice from "./LegacyMailMigrationNotice";
|
||||
import VersionLine from "./VersionLine";
|
||||
|
||||
type CampaignDraftPageScaffoldProps = {
|
||||
@@ -10,6 +11,7 @@ type CampaignDraftPageScaffoldProps = {
|
||||
campaignId: string;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
saving?: boolean;
|
||||
version: CampaignVersionDetail | null;
|
||||
versions: CampaignVersionListItem[];
|
||||
saveState: string;
|
||||
@@ -30,6 +32,7 @@ export default function CampaignDraftPageScaffold({
|
||||
campaignId,
|
||||
title,
|
||||
loading,
|
||||
saving = false,
|
||||
version,
|
||||
versions,
|
||||
saveState,
|
||||
@@ -54,12 +57,13 @@ export default function CampaignDraftPageScaffold({
|
||||
error={error || ""}
|
||||
actions={<PageActionBar
|
||||
variant="editor"
|
||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: onReload }}
|
||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: onSave, disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||
/>}
|
||||
notices={(localError || locked) ?
|
||||
notices={(localError || locked || version?.mail_profile_migration_required) ?
|
||||
<>
|
||||
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
</> : undefined}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, DismissibleAlert, useGuardedNavigate } from "@govoplan/core-webui";
|
||||
|
||||
type LegacyMailMigrationNoticeProps = {
|
||||
campaignId: string;
|
||||
versionId?: string;
|
||||
showSettingsLink?: boolean;
|
||||
onMigrate?: () => void;
|
||||
canMigrate?: boolean;
|
||||
busy?: boolean;
|
||||
};
|
||||
|
||||
export default function LegacyMailMigrationNotice({
|
||||
campaignId,
|
||||
versionId,
|
||||
showSettingsLink = true,
|
||||
onMigrate,
|
||||
canMigrate = false,
|
||||
busy = false
|
||||
}: LegacyMailMigrationNoticeProps) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const query = versionId ? `?version=${encodeURIComponent(versionId)}` : "";
|
||||
|
||||
return (
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
<p>i18n:govoplan-campaign.legacy_mail_migration_required</p>
|
||||
<div className="button-row compact-actions">
|
||||
{showSettingsLink && <Button
|
||||
onClick={() => navigate(`/campaigns/${encodeURIComponent(campaignId)}/mail-settings${query}`)}
|
||||
disabled={busy}
|
||||
>i18n:govoplan-campaign.open_mail_settings</Button>}
|
||||
{onMigrate && <Button variant="primary" onClick={onMigrate} disabled={!canMigrate || busy}>
|
||||
{busy ? "i18n:govoplan-campaign.migrating_mail_profile" : "i18n:govoplan-campaign.migrate_selected_mail_profile"}
|
||||
</Button>}
|
||||
</div>
|
||||
</DismissibleAlert>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export type CampaignMessagePreviewOverlayProps = {
|
||||
navigation?: CampaignMessagePreviewNavigation;
|
||||
actions?: ReactNode;
|
||||
closeLabel?: string;
|
||||
closeDisabled?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -56,6 +57,7 @@ export default function CampaignMessagePreviewOverlay({
|
||||
navigation,
|
||||
actions,
|
||||
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
||||
closeDisabled = false,
|
||||
onClose
|
||||
}: CampaignMessagePreviewOverlayProps) {
|
||||
const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
|
||||
@@ -67,7 +69,7 @@ export default function CampaignMessagePreviewOverlay({
|
||||
const dialogPanel = contentRef.current?.closest<HTMLElement>("[data-dialog-stack-state]");
|
||||
if (dialogPanel?.dataset.dialogStackState !== "topmost") return;
|
||||
if (isEditableTarget(event.target)) return;
|
||||
if (!navigation) return;
|
||||
if (!navigation || closeDisabled) return;
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
if (navigation.index > 0) navigation.onPrevious();
|
||||
@@ -85,7 +87,7 @@ export default function CampaignMessagePreviewOverlay({
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [navigation]);
|
||||
}, [navigation, closeDisabled]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -93,6 +95,7 @@ export default function CampaignMessagePreviewOverlay({
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
closeLabel={closeLabel}
|
||||
closeDisabled={closeDisabled}
|
||||
closeOnBackdrop={false}
|
||||
backdropClassName="overlay-backdrop message-preview-backdrop"
|
||||
className="modal-panel template-preview-modal message-preview-modal"
|
||||
@@ -101,7 +104,7 @@ export default function CampaignMessagePreviewOverlay({
|
||||
footerClassName="modal-footer"
|
||||
footer={<>
|
||||
{actions && <div className="button-row compact-actions">{actions}</div>}
|
||||
<Button variant="primary" onClick={onClose}>{closeLabel}</Button>
|
||||
<Button variant="primary" onClick={onClose} disabled={closeDisabled}>{closeLabel}</Button>
|
||||
</>}
|
||||
>
|
||||
<div ref={contentRef} className="message-preview-content">
|
||||
@@ -113,11 +116,11 @@ export default function CampaignMessagePreviewOverlay({
|
||||
</div>
|
||||
{navigation &&
|
||||
<div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
|
||||
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={closeDisabled || navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={closeDisabled || navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
|
||||
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={closeDisabled || navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={closeDisabled || navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||
</div>
|
||||
}
|
||||
</ActionToolbar>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
|
||||
import {
|
||||
revisionConflictFromError,
|
||||
threeWayMerge,
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "../../../api/campaigns";
|
||||
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
|
||||
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
|
||||
import { clientCampaignEditorState } from "../utils/editorState";
|
||||
import { campaignMailReferencesUnchanged } from "../utils/mailProfileReference";
|
||||
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
|
||||
|
||||
type StepValue = string | (() => string | null | undefined);
|
||||
@@ -93,6 +95,16 @@ export function useCampaignDraftEditor({
|
||||
const baseDraftRef = useRef<Record<string, unknown> | null>(null);
|
||||
const baseRevisionRef = useRef<number | null>(null);
|
||||
const baseEtagRef = useRef<string | null>(null);
|
||||
const loadedVersionIdRef = useRef<string | null>(null);
|
||||
const contextKey = JSON.stringify([campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
const contextRef = useRef(contextKey);
|
||||
const loadedContextRef = useRef(contextKey);
|
||||
contextRef.current = contextKey;
|
||||
const draftRef = useRef<Record<string, unknown> | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const editSequenceRef = useRef(0);
|
||||
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const discardGenerationRef = useRef(0);
|
||||
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,13 +114,37 @@ export function useCampaignDraftEditor({
|
||||
onLoadedRef.current = onLoaded;
|
||||
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
|
||||
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [draft, setDraftState] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirtyState] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
|
||||
const [localError, setLocalError] = useState("");
|
||||
|
||||
const setDraft = useCallback((update: SetStateAction<Record<string, unknown> | null>) => {
|
||||
const next = typeof update === "function" ? update(draftRef.current) : update;
|
||||
if (next === draftRef.current) return;
|
||||
draftRef.current = next;
|
||||
editSequenceRef.current += 1;
|
||||
setDraftState(next);
|
||||
}, []);
|
||||
const setDirty = useCallback((update: SetStateAction<boolean>) => {
|
||||
dirtyRef.current = typeof update === "function" ? update(dirtyRef.current) : update;
|
||||
setDirtyState(dirtyRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
if (!version) {
|
||||
if (loadedContextRef.current !== contextKey) {
|
||||
draftRef.current = null;
|
||||
setDraftState(null);
|
||||
setDirty(false);
|
||||
loadedVersionIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (loadedContextRef.current === contextKey && loadedVersionIdRef.current === version.id && (
|
||||
dirtyRef.current || saveInFlightRef.current || version.edit_revision < (baseRevisionRef.current ?? 0)
|
||||
)) return;
|
||||
const initialDraft = ensureCampaignDraft(version);
|
||||
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
|
||||
baseDraftRef.current = (
|
||||
@@ -116,32 +152,51 @@ export function useCampaignDraftEditor({
|
||||
);
|
||||
baseRevisionRef.current = version.edit_revision;
|
||||
baseEtagRef.current = version.strong_etag;
|
||||
setDraft(loadedDraft);
|
||||
loadedVersionIdRef.current = version.id;
|
||||
loadedContextRef.current = contextKey;
|
||||
draftRef.current = loadedDraft;
|
||||
setDraftState(loadedDraft);
|
||||
setDirty(false);
|
||||
setLocalError("");
|
||||
setSaveState(loadedLabelRef.current(version));
|
||||
onLoadedRef.current?.(version, loadedDraft);
|
||||
}, [version]);
|
||||
}, [contextKey, version, setDirty]);
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
editSequenceRef.current += 1;
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
}, []);
|
||||
}, [setDirty]);
|
||||
|
||||
const patch = useCallback((path: string[], value: unknown) => {
|
||||
if (locked) return;
|
||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||
markDirty();
|
||||
}, [locked, markDirty]);
|
||||
}, [locked, markDirty, setDraft]);
|
||||
|
||||
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
|
||||
if (!draft || !version || locked) return false;
|
||||
const saveDraft = useCallback((_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
|
||||
if (saveInFlightRef.current) return saveInFlightRef.current;
|
||||
const submittedDraft = draftRef.current;
|
||||
if (!submittedDraft || !version || locked) return Promise.resolve(false);
|
||||
discardGenerationRef.current += 1;
|
||||
const submittedSequence = editSequenceRef.current;
|
||||
const submittedVersionId = version.id;
|
||||
const stillCurrent = () => loadedVersionIdRef.current === submittedVersionId && contextRef.current === contextKey;
|
||||
setSaving(true);
|
||||
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
|
||||
const draftToSave = transformDraftBeforeSaveRef.current?.(submittedDraft) ?? submittedDraft;
|
||||
const additionalPayload = extraPayload?.() ?? {};
|
||||
if (version.mail_profile_migration_required && !additionalPayload.migrate_legacy_mail_settings
|
||||
&& !campaignMailReferencesUnchanged(baseDraftRef.current ?? getCampaignJson(version), draftToSave)) {
|
||||
// Unchanged Mail references allow independent content/archive repairs;
|
||||
// the server retains legacy transport without migrating it. Changing
|
||||
// the reference still needs the explicit audited Mail settings action.
|
||||
throw new Error("i18n:govoplan-campaign.legacy_mail_migration_required");
|
||||
}
|
||||
let mutation = campaignVersionMutation(
|
||||
version,
|
||||
draftToSave,
|
||||
@@ -181,6 +236,7 @@ export function useCampaignDraftEditor({
|
||||
campaignId,
|
||||
version.id
|
||||
);
|
||||
if (!stillCurrent()) return false;
|
||||
const latestLoaded = transformLoadedDraftRef.current?.(
|
||||
latest,
|
||||
ensureCampaignDraft(latest)
|
||||
@@ -211,10 +267,20 @@ export function useCampaignDraftEditor({
|
||||
resourceLabel: `Campaign version ${latest.version_number}`,
|
||||
merge
|
||||
});
|
||||
if (resolution.action === "cancel") return false;
|
||||
if (!stillCurrent()) return false;
|
||||
if (resolution.action === "cancel") {
|
||||
setSaveState("i18n:govoplan-campaign.save_cancelled");
|
||||
return false;
|
||||
}
|
||||
if (resolution.action === "reload") {
|
||||
draftRef.current = latestLoaded;
|
||||
setDraftState(latestLoaded);
|
||||
baseDraftRef.current = latestDraft;
|
||||
baseRevisionRef.current = latest.edit_revision;
|
||||
baseEtagRef.current = latest.strong_etag;
|
||||
setDirty(false);
|
||||
await reload({ force: true });
|
||||
setSaveState(loadedLabelRef.current(latest));
|
||||
onLoadedRef.current?.(latest, latestLoaded);
|
||||
return false;
|
||||
}
|
||||
mutation = resolution.value;
|
||||
@@ -231,35 +297,83 @@ export function useCampaignDraftEditor({
|
||||
"The campaign changed repeatedly while it was being saved. Reload and try again."
|
||||
);
|
||||
}
|
||||
setDraft(getCampaignJson(saved));
|
||||
baseDraftRef.current = getCampaignJson(saved);
|
||||
baseRevisionRef.current = saved.edit_revision;
|
||||
baseEtagRef.current = saved.strong_etag;
|
||||
setDirty(false);
|
||||
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
||||
onSaved?.(saved);
|
||||
await reload();
|
||||
return true;
|
||||
if (!stillCurrent()) return false;
|
||||
const savedInitial = ensureCampaignDraft(saved);
|
||||
const savedDraft = transformLoadedDraftRef.current?.(saved, savedInitial) ?? savedInitial;
|
||||
const newerEdits = editSequenceRef.current !== submittedSequence;
|
||||
if (!newerEdits) {
|
||||
draftRef.current = savedDraft;
|
||||
setDraftState(savedDraft);
|
||||
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(savedDraft) ?? savedDraft;
|
||||
baseRevisionRef.current = saved.edit_revision;
|
||||
baseEtagRef.current = saved.strong_etag;
|
||||
setDirty(false);
|
||||
}
|
||||
// A late local edit keeps its original merge base and revision. The next
|
||||
// explicit save reconciles against the acknowledged server revision,
|
||||
// rather than silently overwriting concurrent changes merged into it.
|
||||
if (newerEdits) setDirty(true);
|
||||
setSaveState(newerEdits ? "i18n:govoplan-campaign.saved_newer_changes_pending"
|
||||
: `Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
||||
try {
|
||||
if (!newerEdits) {
|
||||
onSaved?.(saved);
|
||||
await reload();
|
||||
}
|
||||
} catch {
|
||||
// The POST acknowledgement is authoritative. A failed refresh or
|
||||
// observer must not report a committed mutation as a failed save.
|
||||
setError("i18n:govoplan-campaign.saved_refresh_failed");
|
||||
}
|
||||
return !newerEdits && !dirtyRef.current;
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(text);
|
||||
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
|
||||
if (stillCurrent()) {
|
||||
setLocalError(text);
|
||||
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setError, settings, version, workflowState]);
|
||||
})();
|
||||
const pending = operation.finally(() => {
|
||||
saveInFlightRef.current = null;
|
||||
setSaving(false);
|
||||
});
|
||||
saveInFlightRef.current = pending;
|
||||
return pending;
|
||||
}, [campaignId, contextKey, currentFlow, currentStep, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setDirty, setError, settings, version, workflowState]);
|
||||
|
||||
const discardDraft = useCallback(async () => {
|
||||
if (version) {
|
||||
const initialDraft = ensureCampaignDraft(version);
|
||||
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
|
||||
setDraft(loadedDraft);
|
||||
if (saveInFlightRef.current || !version) return;
|
||||
const generation = ++discardGenerationRef.current;
|
||||
const sequence = editSequenceRef.current;
|
||||
const stillCurrent = () => generation === discardGenerationRef.current
|
||||
&& loadedVersionIdRef.current === version.id && contextRef.current === contextKey;
|
||||
try {
|
||||
// Do not discard local work until a fresh version was actually read.
|
||||
const latest = await getCampaignVersion(settings, campaignId, version.id);
|
||||
if (!stillCurrent()) return;
|
||||
if (sequence !== editSequenceRef.current || saveInFlightRef.current
|
||||
|| latest.edit_revision < (baseRevisionRef.current ?? 0)) {
|
||||
setLocalError("i18n:govoplan-campaign.discard_superseded");
|
||||
return;
|
||||
}
|
||||
const initialDraft = ensureCampaignDraft(latest);
|
||||
const loadedDraft = transformLoadedDraftRef.current?.(latest, initialDraft) ?? initialDraft;
|
||||
draftRef.current = loadedDraft;
|
||||
setDraftState(loadedDraft);
|
||||
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft;
|
||||
baseRevisionRef.current = latest.edit_revision;
|
||||
baseEtagRef.current = latest.strong_etag;
|
||||
setDirty(false);
|
||||
setLocalError("");
|
||||
setSaveState(loadedLabelRef.current(version));
|
||||
onLoadedRef.current?.(version, loadedDraft);
|
||||
setSaveState(loadedLabelRef.current(latest));
|
||||
onLoadedRef.current?.(latest, loadedDraft);
|
||||
await reload({ force: true });
|
||||
} catch (err) {
|
||||
if (stillCurrent()) setLocalError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
await reload({ force: true });
|
||||
}, [reload, version]);
|
||||
}, [campaignId, contextKey, reload, setDirty, settings, version]);
|
||||
|
||||
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
||||
title: unsavedTitle,
|
||||
@@ -275,6 +389,7 @@ export function useCampaignDraftEditor({
|
||||
setDraft,
|
||||
displayDraft: draft ?? ensureCampaignDraft(null),
|
||||
dirty,
|
||||
saving,
|
||||
setDirty,
|
||||
saveState,
|
||||
setSaveState,
|
||||
@@ -298,7 +413,7 @@ function campaignVersionMutation(
|
||||
current_step: overrides.current_step ?? version.current_step ?? null,
|
||||
workflow_state: overrides.workflow_state ?? version.workflow_state ?? null,
|
||||
is_complete: overrides.is_complete ?? version.is_complete ?? false,
|
||||
editor_state: overrides.editor_state ?? version.editor_state ?? {},
|
||||
editor_state: clientCampaignEditorState(overrides.editor_state ?? version.editor_state),
|
||||
source_filename: overrides.source_filename ?? version.source_filename ?? null,
|
||||
source_base_path: overrides.source_base_path ?? version.source_base_path ?? null,
|
||||
migrate_legacy_mail_settings: (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ApiError } from "@govoplan/core-webui";
|
||||
import { getCampaignVersion, updateCampaignReviewState, type CampaignReviewStatePayload, type CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import { storedMessageReviewState } from "../review/builtMessageQuery";
|
||||
|
||||
type ReviewProgressDelta = Pick<CampaignReviewStatePayload, "reviewed_message_keys" | "issue_decisions" | "decision_category_key"> & {
|
||||
inspection_complete?: boolean;
|
||||
};
|
||||
|
||||
/** A decision ACK is durable progress, not a full workspace reload or rebuild. */
|
||||
export function useCampaignReviewProgress(settings: ApiSettings, campaignId: string, version: CampaignVersionDetail | null) {
|
||||
const buildToken = storedMessageReviewState(version).buildToken;
|
||||
const key = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, version?.id, buildToken]);
|
||||
const currentKey = useRef(key);
|
||||
currentKey.current = key;
|
||||
const latest = useRef({ key, version });
|
||||
if (latest.current.key !== key || (version && version.edit_revision > (latest.current.version?.edit_revision ?? -1))) {
|
||||
latest.current = { key, version };
|
||||
}
|
||||
const [, renderAcknowledgement] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const effectiveVersion = storedMessageReviewState(latest.current.version).buildToken === buildToken ? latest.current.version : version;
|
||||
|
||||
async function recordProgress(delta: ReviewProgressDelta): Promise<CampaignVersionDetail> {
|
||||
const base = latest.current.version;
|
||||
if (pending.current) throw new Error("i18n:govoplan-campaign.review_save_pending");
|
||||
if (!base || !buildToken || storedMessageReviewState(base).buildToken !== buildToken) {
|
||||
throw new Error("i18n:govoplan-campaign.review_build_changed");
|
||||
}
|
||||
pending.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await updateCampaignReviewState(settings, campaignId, base.id, {
|
||||
...delta,
|
||||
inspection_complete: delta.inspection_complete === true,
|
||||
merge_progress: true,
|
||||
build_token: buildToken,
|
||||
base_revision: base.edit_revision
|
||||
});
|
||||
if (currentKey.current !== key) throw new Error("i18n:govoplan-campaign.review_build_changed");
|
||||
const current = latest.current.version;
|
||||
if (current && storedMessageReviewState(current).buildToken !== buildToken) {
|
||||
throw new Error("i18n:govoplan-campaign.review_build_changed");
|
||||
}
|
||||
// A workspace refresh can observe a newer same-build revision while
|
||||
// this request is still in flight. Never replace it with an older ACK.
|
||||
const acknowledged = current && current.edit_revision > saved.edit_revision ? current : saved;
|
||||
latest.current = { key, version: acknowledged };
|
||||
renderAcknowledgement(value => value + 1);
|
||||
return acknowledged;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// Refresh only authoritative revision/evidence. Never automatically
|
||||
// replay an approval, or reconcile it onto another frozen build.
|
||||
try {
|
||||
const fresh = await getCampaignVersion(settings, campaignId, base.id);
|
||||
if (currentKey.current === key && fresh.edit_revision >= (latest.current.version?.edit_revision ?? -1)) {
|
||||
latest.current = { key, version: fresh };
|
||||
renderAcknowledgement(value => value + 1);
|
||||
}
|
||||
} catch { /* Keep the submitted note and original conflict visible. */ }
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { reviewVersion: effectiveVersion, recordProgress, saving, buildToken };
|
||||
}
|
||||
@@ -33,10 +33,12 @@ export function useCampaignWorkspaceData(
|
||||
} = options;
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectedVersionId = searchParams.get("version");
|
||||
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
||||
const [data, setData] = useState<{ queryKey: string; value: CampaignWorkspaceData } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const dataRef = useRef<CampaignWorkspaceData>(initialData);
|
||||
const dataRef = useRef<{ queryKey: string; value: CampaignWorkspaceData } | null>(null);
|
||||
const requestGeneration = useRef(0);
|
||||
const requestAbort = useRef<AbortController | null>(null);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const queryKey = useMemo(
|
||||
() => JSON.stringify({
|
||||
@@ -51,9 +53,18 @@ export function useCampaignWorkspaceData(
|
||||
}),
|
||||
[campaignId, selectedVersionId, includeCurrentVersion, includeSummary, includeVersions, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||
);
|
||||
const activeQueryKey = useRef(queryKey);
|
||||
activeQueryKey.current = queryKey;
|
||||
|
||||
const reload = useCallback(async (options?: {force?: boolean;}) => {
|
||||
if (!campaignId) return;
|
||||
if (!campaignId || activeQueryKey.current !== queryKey) return;
|
||||
const generation = ++requestGeneration.current;
|
||||
requestAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
requestAbort.current = controller;
|
||||
const isCurrentRequest = () => (
|
||||
!controller.signal.aborted && activeQueryKey.current === queryKey && requestGeneration.current === generation
|
||||
);
|
||||
const force = options?.force === true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -61,7 +72,8 @@ export function useCampaignWorkspaceData(
|
||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||
if (force) resetDeltaWatermark(queryKey);
|
||||
let nextWatermark = force ? null : getDeltaWatermark(queryKey);
|
||||
let merged: CampaignWorkspaceData = force ? initialData : dataRef.current;
|
||||
let merged = !force && dataRef.current?.queryKey === queryKey
|
||||
? dataRef.current.value : initialData;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
||||
@@ -70,35 +82,48 @@ export function useCampaignWorkspaceData(
|
||||
includeSummary,
|
||||
includeVersions: shouldLoadVersions,
|
||||
since: nextWatermark,
|
||||
});
|
||||
}, { cache: "no-store", signal: controller.signal });
|
||||
// Do not apply an old campaign/version/auth response or request another
|
||||
// page after a newer reload or navigation has superseded this request.
|
||||
if (!isCurrentRequest()) return;
|
||||
merged = mergeWorkspaceDelta(merged, response);
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark(queryKey, nextWatermark);
|
||||
dataRef.current = merged;
|
||||
setData(merged);
|
||||
dataRef.current = { queryKey, value: merged };
|
||||
setData(dataRef.current);
|
||||
} catch (err) {
|
||||
dataRef.current = initialData;
|
||||
setData(initialData);
|
||||
if (!isCurrentRequest()) return;
|
||||
// A failed refresh does not undo an authoritative save or erase the last
|
||||
// usable same-query workspace. Retry from a full response next time.
|
||||
resetDeltaWatermark(queryKey);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrentRequest()) {
|
||||
requestAbort.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId, queryKey, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
resetDeltaWatermark(queryKey);
|
||||
dataRef.current = initialData;
|
||||
setData(initialData);
|
||||
dataRef.current = null;
|
||||
setData(null);
|
||||
setError("");
|
||||
}, [queryKey, resetDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
void reload();
|
||||
return () => {
|
||||
requestGeneration.current += 1;
|
||||
requestAbort.current?.abort();
|
||||
requestAbort.current = null;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
return { data, loading, error, reload, setError };
|
||||
return { data: data?.queryKey === queryKey ? data.value : initialData, loading, error, reload, setError };
|
||||
}
|
||||
|
||||
function mergeWorkspaceDelta(current: CampaignWorkspaceData, response: CampaignWorkspaceDeltaResponse): CampaignWorkspaceData {
|
||||
|
||||
@@ -464,7 +464,7 @@ function mergeAddressGroups(
|
||||
nextValues[group.key] = dedupeAddresses([
|
||||
...(nextValues[group.key] ?? []),
|
||||
...group.addresses.map(cloneMailboxAddress)
|
||||
]);
|
||||
], { preserveOrder: true });
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
@@ -489,7 +489,7 @@ function prepareAddressValues(
|
||||
}
|
||||
addresses.push({ name, email });
|
||||
}
|
||||
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses) : addresses.slice(0, 1);
|
||||
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses, { preserveOrder: true }) : addresses.slice(0, 1);
|
||||
}
|
||||
return { values: prepared, error: "" };
|
||||
}
|
||||
@@ -521,6 +521,20 @@ export function entryWithAddressList(entry: Record<string, unknown>, key: Addres
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
export function entryWithAddressValues(
|
||||
entry: Record<string, unknown>, values: HeaderAddressValues, merges: EntryAddressMergeValues
|
||||
): Record<string, unknown> {
|
||||
let next = entry;
|
||||
for (const column of recipientAddressOverlayColumns) {
|
||||
if (!(column.key in values)) continue;
|
||||
next = entryWithAddressList(next, column.key, values[column.key] ?? []);
|
||||
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
|
||||
next = { ...next, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
|
||||
delete next[column.mergeKey.replace("merge_", "combine_")];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function headerAddressValues(columns: EntryAddressColumn[], recipientsSection: Record<string, unknown>): HeaderAddressValues {
|
||||
return Object.fromEntries(columns.map((column) => [
|
||||
column.key,
|
||||
@@ -580,7 +594,7 @@ function parsePastedAddressGroups(targetKey: AddressFieldKey, text: string): Arr
|
||||
const addressText = prefixed?.text ?? token;
|
||||
const address = parseMailboxAddressText(addressText);
|
||||
if (!address?.email) continue;
|
||||
grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address]));
|
||||
grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address], { preserveOrder: true }));
|
||||
}
|
||||
return [...grouped.entries()].map(([key, addresses]) => ({ key, addresses }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { addressesFromValue, i18nMessage } from "@govoplan/core-webui";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
|
||||
const groups = [
|
||||
{ id: "to", label: "i18n:govoplan-campaign.report_recipients_to" },
|
||||
{ id: "cc", label: "i18n:govoplan-campaign.report_recipients_cc" },
|
||||
{ id: "bcc", label: "i18n:govoplan-campaign.report_recipients_bcc" }
|
||||
] as const;
|
||||
|
||||
export function reportRecipientGroups(row: Record<string, unknown>) {
|
||||
const resolved = asRecord(row.resolved_recipients);
|
||||
const result = groups.map(group => ({ ...group, addresses: addressesFromValue(resolved[group.id]) }));
|
||||
if (result.every(group => group.addresses.length === 0) && typeof row.recipient_email === "string" && row.recipient_email.trim()) {
|
||||
result[0].addresses = [{ email: row.recipient_email.trim() }];
|
||||
}
|
||||
return result.filter(group => group.addresses.length > 0);
|
||||
}
|
||||
|
||||
export function reportRecipientSearchText(row: Record<string, unknown>): string {
|
||||
return reportRecipientGroups(row).flatMap(group => group.addresses.map(address => `${address.name ?? ""} ${address.email}`)).join(" ");
|
||||
}
|
||||
|
||||
export default function ReportRecipients({ row }: { row: Record<string, unknown> }) {
|
||||
const values = reportRecipientGroups(row);
|
||||
if (!values.length) return <span>—</span>;
|
||||
return <div className="recipient-outcome-cell campaign-report-recipient-cell">
|
||||
{values.map(group => <div key={group.id}>
|
||||
<strong>{group.label}: </strong>
|
||||
{group.addresses.map((address, index) => <span key={`${address.email}-${index}`}>
|
||||
{index > 0 ? "; " : ""}{address.name ? `${address.name} <${address.email}>` : address.email}
|
||||
</span>)}
|
||||
</div>)}
|
||||
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField } from "@govoplan/core-webui";
|
||||
import type { ReportReconciliation } from "./reportRecovery";
|
||||
|
||||
export default function ReportRecoveryDialog({ request, onConfirm, onClose }: {
|
||||
request: ReportReconciliation;
|
||||
onConfirm: (note: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const claim = request.kind === "claim";
|
||||
const accepted = request.kind === "outcome" && ["smtp_accepted", "imap_appended"].includes(request.decision);
|
||||
const confirmLabel = claim ? "i18n:govoplan-campaign.report_recover_claim_confirm"
|
||||
: request.decision === "imap_appended" ? "i18n:govoplan-campaign.report_record_imap_appended"
|
||||
: request.decision === "imap_not_appended" ? "i18n:govoplan-campaign.report_record_imap_not_appended"
|
||||
: accepted ? "i18n:govoplan-campaign.report_record_accepted" : "i18n:govoplan-campaign.report_record_not_sent";
|
||||
async function confirm() {
|
||||
if (pending.current || !note.trim()) return;
|
||||
pending.current = true; setSaving(true); setError("");
|
||||
try { await onConfirm(note.trim()); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally { pending.current = false; setSaving(false); }
|
||||
}
|
||||
function close() { if (!pending.current) onClose(); }
|
||||
return <Dialog open portal size="small" title={claim ? "i18n:govoplan-campaign.report_recover_claim_title" : "i18n:govoplan-campaign.report_reconcile_title"}
|
||||
closeDisabled={saving} onClose={close}
|
||||
footer={<>
|
||||
<Button onClick={close} disabled={saving}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant={accepted ? "primary" : "danger"} onClick={() => void confirm()} disabled={saving || !note.trim()}>
|
||||
{saving ? "i18n:govoplan-campaign.report_recording_evidence" : confirmLabel}
|
||||
</Button>
|
||||
</>}>
|
||||
<p>{claim ? "i18n:govoplan-campaign.report_recover_claim_help" : accepted ? "i18n:govoplan-campaign.report_accepted_evidence_help" : "i18n:govoplan-campaign.report_not_sent_evidence_help"}</p>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormField label="i18n:govoplan-campaign.report_evidence_note">
|
||||
<textarea value={note} onChange={event => setNote(event.target.value)} maxLength={2000} rows={4} required disabled={saving} />
|
||||
</FormField>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CampaignRecoveryChannel } from "../../../api/deliveryRecovery";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
|
||||
export type ReportReconciliation = {
|
||||
jobId: string;
|
||||
kind: "outcome";
|
||||
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended";
|
||||
} | {
|
||||
jobId: string;
|
||||
kind: "claim";
|
||||
channel: CampaignRecoveryChannel;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export function reportClaimRecovery(row: Record<string, unknown>, channel: CampaignRecoveryChannel): { eligible: boolean; revision: string } {
|
||||
const metadata = asRecord(asRecord(row.recovery)[channel]);
|
||||
const revision = typeof metadata.revision === "string" ? metadata.revision : "";
|
||||
const state = String(channel === "smtp" ? row.send_status ?? "" : row.imap_status ?? "");
|
||||
return { eligible: metadata.eligible === true && Boolean(revision) &&
|
||||
(channel === "smtp" ? ["claimed", "sending"].includes(state) : state === "appending"), revision };
|
||||
}
|
||||
|
||||
export function reportRetryableFailure(row: Record<string, unknown>): boolean {
|
||||
return ["failed_temporary", "failed_permanent", "partially_accepted"].includes(String(row.send_status ?? ""));
|
||||
}
|
||||
|
||||
export function reportUnattempted(row: Record<string, unknown>): boolean {
|
||||
return ["not_queued", "cancelled", "queued"].includes(String(row.send_status ?? "")) &&
|
||||
Number(row.attempt_count ?? 0) === 0 && Number(row.postbox_attempt_count ?? 0) === 0 &&
|
||||
Number(row.print_attempt_count ?? 0) === 0 &&
|
||||
row.build_status === "built" && ["ready", "warning", "needs_review"].includes(String(row.validation_status ?? ""));
|
||||
}
|
||||
|
||||
export function reportRecoveryHints(row: Record<string, unknown>): string[] {
|
||||
const activeChannels = [
|
||||
...(["claimed", "sending"].includes(String(row.send_status ?? "")) ? ["smtp"] : []),
|
||||
...(row.imap_status === "appending" ? ["imap"] : [])
|
||||
];
|
||||
return [...new Set(activeChannels.flatMap(channel => {
|
||||
const metadata = asRecord(asRecord(row.recovery)[channel]);
|
||||
if (metadata.eligible === true) return [];
|
||||
return [metadata.reason === "live_claim" ? "i18n:govoplan-campaign.report_claim_still_active"
|
||||
: "i18n:govoplan-campaign.report_claim_owner_unconfirmed"];
|
||||
}))];
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default function AttachmentLinkingPreview({
|
||||
error,
|
||||
linking,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onRefresh,
|
||||
onLink
|
||||
}: {
|
||||
@@ -25,6 +26,7 @@ export default function AttachmentLinkingPreview({
|
||||
error: string;
|
||||
linking: boolean;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onRefresh: () => void;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
@@ -57,6 +59,7 @@ export default function AttachmentLinkingPreview({
|
||||
variant="primary"
|
||||
onClick={onLink}
|
||||
disabled={disabled || loading || linking || unlinkedCount === 0}
|
||||
title={loading ? "i18n:govoplan-campaign.attachment_link_loading_reason" : disabledReason || (unlinkedCount === 0 ? "i18n:govoplan-campaign.attachment_link_all_done" : undefined)}
|
||||
>
|
||||
{linking
|
||||
? "i18n:govoplan-campaign.linking.a5f54e0f"
|
||||
@@ -69,6 +72,7 @@ export default function AttachmentLinkingPreview({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted small-note">{disabledReason || "i18n:govoplan-campaign.attachment_link_before_lock"}</p>
|
||||
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||
<MetricCard density="compact" surface="subtle"
|
||||
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
i18nMessage
|
||||
} from "@govoplan/core-webui";
|
||||
import { useState } from "react";
|
||||
|
||||
import CampaignMessagePreviewOverlay, {
|
||||
type CampaignMessagePreviewAttachment
|
||||
@@ -34,6 +36,9 @@ export default function BuiltMessagePreview({
|
||||
singleMessageSendBusy,
|
||||
reviewed,
|
||||
reviewReason,
|
||||
reviewSaving = false,
|
||||
reviewDisabled = false,
|
||||
reviewError = "",
|
||||
onReviewReasonChange,
|
||||
onAcceptReview,
|
||||
onSelect,
|
||||
@@ -48,8 +53,11 @@ export default function BuiltMessagePreview({
|
||||
singleMessageSendBusy: boolean;
|
||||
reviewed: boolean;
|
||||
reviewReason: string;
|
||||
reviewSaving?: boolean;
|
||||
reviewDisabled?: boolean;
|
||||
reviewError?: string;
|
||||
onReviewReasonChange: (value: string) => void;
|
||||
onAcceptReview: (reasonRequired: boolean) => void;
|
||||
onAcceptReview: (reasonRequired: boolean, reason: string) => Promise<void>;
|
||||
onSelect: (index: number) => void;
|
||||
onSendSingle: (index: number) => void;
|
||||
onClose: () => void;
|
||||
@@ -128,40 +136,16 @@ export default function BuiltMessagePreview({
|
||||
}}
|
||||
actions={
|
||||
<div className="built-message-review-actions">
|
||||
{reviewError && <DismissibleAlert tone="danger" dismissible={false}>{reviewError}</DismissibleAlert>}
|
||||
{explicitReview && !reviewed ? (
|
||||
<>
|
||||
<FormField
|
||||
label={
|
||||
reasonRequired
|
||||
? "Reason for accepting attachment exception"
|
||||
: "Review note"
|
||||
}
|
||||
help={
|
||||
reasonRequired
|
||||
? "Required. This reason is stored with the frozen build evidence."
|
||||
: "Optional note stored with the review decision."
|
||||
}
|
||||
>
|
||||
<input
|
||||
value={reviewReason}
|
||||
maxLength={4000}
|
||||
onChange={(event) =>
|
||||
onReviewReasonChange(event.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={reasonRequired && !reviewReason.trim()}
|
||||
onClick={() => onAcceptReview(reasonRequired)}
|
||||
>
|
||||
Accept review conditions
|
||||
</Button>
|
||||
</>
|
||||
<ReviewDecisionForm key={String(row.id ?? row.review_key ?? index)} initialReason={reviewReason}
|
||||
reasonRequired={reasonRequired} saving={reviewSaving} disabled={reviewDisabled}
|
||||
onReasonChange={onReviewReasonChange} onAccept={(reason) => onAcceptReview(reasonRequired, reason)} />
|
||||
) : null}
|
||||
{reviewed && <p role="status">i18n:govoplan-campaign.review_decision_saved</p>}
|
||||
<Button
|
||||
variant={explicitReview && !reviewed ? undefined : "primary"}
|
||||
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
||||
disabled={reviewSaving || singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
||||
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
|
||||
onClick={() => onSendSingle(index)}
|
||||
>
|
||||
@@ -169,11 +153,32 @@ export default function BuiltMessagePreview({
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
closeDisabled={reviewSaving}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Keystrokes rerender only this small form, not the campaign table, template
|
||||
// rendering and message body. The parent retains a draft ref for navigation.
|
||||
function ReviewDecisionForm({ initialReason, reasonRequired, saving, disabled, onReasonChange, onAccept }: {
|
||||
initialReason: string; reasonRequired: boolean; saving: boolean; disabled: boolean;
|
||||
onReasonChange: (value: string) => void; onAccept: (reason: string) => Promise<void>;
|
||||
}) {
|
||||
const [reason, setReason] = useState(initialReason);
|
||||
return <>
|
||||
<FormField label={reasonRequired ? "i18n:govoplan-campaign.review_reason_label" : "i18n:govoplan-campaign.review_note_label"}
|
||||
help="i18n:govoplan-campaign.review_reason_help">
|
||||
<input value={reason} maxLength={4000} disabled={saving || disabled} onChange={event => {
|
||||
setReason(event.target.value); onReasonChange(event.target.value);
|
||||
}} />
|
||||
</FormField>
|
||||
<Button variant="primary" disabled={saving || disabled || reasonRequired && !reason.trim()} onClick={() => void onAccept(reason)}>
|
||||
{saving ? "i18n:govoplan-campaign.review_saving_decision" : "i18n:govoplan-campaign.review_accept_next"}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
function singleMessageSendDisabledReason(
|
||||
row: Record<string, unknown>,
|
||||
canStartSingleMessageSend: boolean
|
||||
@@ -247,7 +252,7 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
|
||||
function builtMessageAttachments(
|
||||
row: Record<string, unknown>
|
||||
): CampaignMessagePreviewAttachment[] {
|
||||
return asArray(row.attachments).flatMap((value, index) => {
|
||||
return asArray(row.attachments).flatMap<CampaignMessagePreviewAttachment>((value, index) => {
|
||||
const attachment = asRecord(value);
|
||||
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
||||
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button, Dialog, DismissibleAlert, FormField, ToggleSwitch, i18nMessage, usePlatformLanguage
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
BULK_MESSAGE_REVIEW_LIMIT, bulkMessageReviewGroups, bulkReviewIssueLabel, type BulkMessageReviewRow, type BulkMessageReviewSelection
|
||||
} from "./bulkMessageReview";
|
||||
|
||||
export default function BulkMessageReviewDialog({ rows, buildToken, disabled = false, onAccept, onClose }: {
|
||||
rows: Record<string, unknown>[];
|
||||
buildToken: string;
|
||||
disabled?: boolean;
|
||||
onAccept: (selection: BulkMessageReviewSelection) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const groups = useMemo(() => bulkMessageReviewGroups(rows), [rows]);
|
||||
const [categoryKey, setCategoryKey] = useState(() => groups[0]?.categoryKey ?? "");
|
||||
const [excluded, setExcluded] = useState<Set<string>>(() => new Set());
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const inFlight = useRef(false);
|
||||
const openedBuildToken = useRef(buildToken);
|
||||
const group = groups.find((item) => item.categoryKey === categoryKey);
|
||||
const messages = useMemo(() => group?.messages.slice(0, BULK_MESSAGE_REVIEW_LIMIT) ?? [], [group]);
|
||||
const selected = messages.filter((item) => !excluded.has(item.jobId));
|
||||
const toggleMessage = useCallback((jobId: string, checked: boolean) => setExcluded((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.delete(jobId); else next.add(jobId);
|
||||
return next;
|
||||
}), []);
|
||||
const staleBuild = openedBuildToken.current !== buildToken;
|
||||
const unavailable = disabled || saving || staleBuild || !buildToken;
|
||||
async function accept() {
|
||||
if (inFlight.current || unavailable || !group || selected.length === 0 || (group.reasonRequired && !reason.trim())) return;
|
||||
inFlight.current = true;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await onAccept({ buildToken, categoryKey: group.categoryKey, jobIds: selected.map((item) => item.jobId), reason: reason.trim() });
|
||||
onClose();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : String(failure));
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return <Dialog open title="i18n:govoplan-campaign.bulk_review_title" size="large" closeDisabled={saving}
|
||||
onClose={() => { if (!inFlight.current) onClose(); }}
|
||||
footer={<>
|
||||
<Button disabled={saving} onClick={() => { if (!inFlight.current) onClose(); }}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={unavailable || selected.length === 0 || Boolean(group?.reasonRequired && !reason.trim())} onClick={() => void accept()}>
|
||||
{saving ? "i18n:govoplan-campaign.bulk_review_saving" : i18nMessage("i18n:govoplan-campaign.bulk_review_accept_count", { value0: selected.length })}
|
||||
</Button>
|
||||
</>}>
|
||||
<p>i18n:govoplan-campaign.bulk_review_scope</p>
|
||||
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
|
||||
{staleBuild && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.bulk_review_stale</DismissibleAlert>}
|
||||
{groups.length === 0 ? <p>i18n:govoplan-campaign.bulk_review_empty</p> : <>
|
||||
<FormField label="i18n:govoplan-campaign.bulk_review_category">
|
||||
<select aria-label={translateText("i18n:govoplan-campaign.bulk_review_category")} value={categoryKey} disabled={unavailable}
|
||||
onChange={(event) => { setCategoryKey(event.target.value); setExcluded(new Set()); setReason(""); setError(""); }}>
|
||||
{groups.map((item) => <option key={item.categoryKey} value={item.categoryKey}>
|
||||
{`${[...new Set(item.issueCodes.map((code) => translateText(bulkReviewIssueLabel(code))))].join(" + ") || translateText("i18n:govoplan-campaign.bulk_review_other_category")} (${item.messages.length})`}
|
||||
</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
{Boolean(group?.issueCodes.length) && <details>
|
||||
<summary>i18n:govoplan-campaign.bulk_review_technical_codes</summary>
|
||||
<p>{group?.issueCodes.join(", ")}</p>
|
||||
</details>}
|
||||
<p>{i18nMessage("i18n:govoplan-campaign.bulk_review_subset", { value0: messages.length, value1: group?.messages.length ?? 0, value2: selected.length })}</p>
|
||||
{(group?.messages.length ?? 0) > BULK_MESSAGE_REVIEW_LIMIT && <p className="muted small-note">i18n:govoplan-campaign.bulk_review_limit</p>}
|
||||
<FormField label={group?.reasonRequired ? "i18n:govoplan-campaign.bulk_review_reason_required" : "i18n:govoplan-campaign.bulk_review_reason"}
|
||||
help="i18n:govoplan-campaign.bulk_review_reason_help">
|
||||
<textarea aria-label={translateText(group?.reasonRequired ? "i18n:govoplan-campaign.bulk_review_reason_required" : "i18n:govoplan-campaign.bulk_review_reason")}
|
||||
value={reason} maxLength={4000} rows={3} disabled={unavailable} onChange={(event) => setReason(event.target.value)} />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled={unavailable} onClick={() => setExcluded(new Set())}>i18n:govoplan-campaign.bulk_review_select_all</Button>
|
||||
<Button disabled={unavailable} onClick={() => setExcluded(new Set(messages.map((item) => item.jobId)))}>i18n:govoplan-campaign.bulk_review_select_none</Button>
|
||||
</div>
|
||||
<div className="review-flow-data-stack" role="group" aria-label={translateText("i18n:govoplan-campaign.bulk_review_recipients")}>
|
||||
<BulkReviewRecipients messages={messages} excluded={excluded} disabled={unavailable} onChange={toggleMessage} />
|
||||
</div>
|
||||
</>}
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
// Editing a shared reason must not rebuild a potentially 200-row recipient list.
|
||||
const BulkReviewRecipients = memo(function BulkReviewRecipients({ messages, excluded, disabled, onChange }: {
|
||||
messages: BulkMessageReviewRow[];
|
||||
excluded: Set<string>;
|
||||
disabled: boolean;
|
||||
onChange: (jobId: string, checked: boolean) => void;
|
||||
}) {
|
||||
return <>{messages.map((item) => <ToggleSwitch key={item.jobId} label={item.recipient} help={item.subject || undefined}
|
||||
disabled={disabled} checked={!excluded.has(item.jobId)} onChange={(checked) => onChange(item.jobId, checked)} />)}</>;
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, DescriptionList, Dialog, DismissibleAlert, MetricCard, MetricGrid, i18nMessage } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import { getCampaignDeliveryProgress, type CampaignDeliveryProgress } from "../../../api/campaigns";
|
||||
|
||||
export type DeliveryProgressRequestState = "running" | "finished" | "interrupted";
|
||||
|
||||
/** Only this small dialog polls; the workspace, attachments and report stay still. */
|
||||
export default function CampaignDeliveryProgressDialog({ settings, campaignId, versionId, kind, requestState, requestError, onClose }: {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
versionId: string;
|
||||
kind: "smtp" | "imap";
|
||||
requestState: DeliveryProgressRequestState;
|
||||
requestError?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const scope = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, versionId]);
|
||||
const [snapshot, setSnapshot] = useState<{ scope: string; progress: CampaignDeliveryProgress } | null>(null);
|
||||
const [pollError, setPollError] = useState(false);
|
||||
const progress = snapshot?.scope === scope ? snapshot.progress : null;
|
||||
const running = requestState === "running";
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const controller = new AbortController();
|
||||
setPollError(false);
|
||||
async function poll() {
|
||||
let succeeded = false;
|
||||
try {
|
||||
const result = await getCampaignDeliveryProgress(settings, campaignId, versionId, controller.signal);
|
||||
if (stopped) return;
|
||||
if (result.campaign_id !== campaignId || result.version_id !== versionId) throw new Error("Progress context changed");
|
||||
setSnapshot({ scope, progress: result });
|
||||
setPollError(false);
|
||||
succeeded = true;
|
||||
} catch {
|
||||
if (stopped) return;
|
||||
// A read failure never retries or changes the authoritative send/append.
|
||||
setPollError(true);
|
||||
}
|
||||
if (!stopped && (requestState !== "finished" || !succeeded)) timer = setTimeout(() => void poll(), 2000);
|
||||
}
|
||||
void poll();
|
||||
return () => { stopped = true; controller.abort(); if (timer) clearTimeout(timer); };
|
||||
}, [scope, requestState]);
|
||||
|
||||
const counts = progress?.[kind];
|
||||
const accepted = progress ? kind === "smtp" ? progress.smtp.accepted : progress.imap.appended : null;
|
||||
const label = kind === "smtp" ? "i18n:govoplan-campaign.delivery_progress_smtp" : "i18n:govoplan-campaign.delivery_progress_imap";
|
||||
return <Dialog open title={label} closeDisabled={running} closeOnBackdrop={false} onClose={onClose}
|
||||
footer={<Button variant="primary" disabled={running} onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
<p role="status" aria-live="polite"><strong>{counts
|
||||
? i18nMessage("i18n:govoplan-campaign.delivery_progress_processed", { value0: counts.processed, value1: counts.total })
|
||||
: "i18n:govoplan-campaign.delivery_progress_loading"}</strong></p>
|
||||
<p className="muted small-note">{running ? "i18n:govoplan-campaign.delivery_progress_running"
|
||||
: requestState === "finished" ? "i18n:govoplan-campaign.delivery_progress_finished"
|
||||
: "i18n:govoplan-campaign.delivery_progress_interrupted"}</p>
|
||||
{pollError && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.delivery_progress_stale</DismissibleAlert>}
|
||||
{requestError && <DismissibleAlert tone="warning" dismissible={false}>{requestError}</DismissibleAlert>}
|
||||
<MetricGrid columns={3} density="compact" minimum="compact">
|
||||
<MetricCard label={kind === "smtp" ? "i18n:govoplan-campaign.delivery_progress_accepted" : "i18n:govoplan-campaign.delivery_progress_appended"} value={accepted ?? "—"} density="compact" />
|
||||
<MetricCard label="i18n:govoplan-campaign.delivery_progress_active" value={counts?.active ?? "—"} density="compact" />
|
||||
<MetricCard label="i18n:govoplan-campaign.delivery_progress_pending" value={counts?.pending ?? "—"} density="compact" />
|
||||
<MetricCard label="i18n:govoplan-campaign.delivery_progress_failed" value={counts?.failed ?? "—"} density="compact" />
|
||||
<MetricCard label="i18n:govoplan-campaign.delivery_progress_unknown" value={counts?.outcome_unknown ?? "—"} density="compact" />
|
||||
<MetricCard label="i18n:govoplan-campaign.delivery_progress_excluded" value={counts?.excluded ?? "—"} density="compact" />
|
||||
</MetricGrid>
|
||||
<DescriptionList variant="inline">
|
||||
{kind === "smtp" && <><div><dt>i18n:govoplan-campaign.delivery_progress_paused</dt><dd>{progress?.smtp.paused ?? "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.delivery_progress_cancelled</dt><dd>{progress?.smtp.cancelled ?? "—"}</dd></div></>}
|
||||
<div><dt>i18n:govoplan-campaign.delivery_progress_updated</dt><dd>{progress?.generated_at ? new Date(progress.generated_at).toLocaleTimeString() : "—"}</dd></div>
|
||||
</DescriptionList>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.delivery_progress_scope</p>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { DataGridPaginationBar } from "@govoplan/core-webui";
|
||||
|
||||
/** Shared table pagination also serves review evidence that reads best as prose. */
|
||||
export default function PaginatedReviewDetails<T>({ items, renderPage }: {
|
||||
items: T[];
|
||||
renderPage: (items: T[]) => ReactNode;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const currentPage = Math.min(page, Math.max(1, Math.ceil(items.length / pageSize)));
|
||||
return <>
|
||||
{renderPage(items.slice((currentPage - 1) * pageSize, currentPage * pageSize))}
|
||||
<DataGridPaginationBar page={currentPage} pageSize={pageSize} totalRows={items.length}
|
||||
pageSizeOptions={[10, 25, 50, 100]} onPageChange={setPage}
|
||||
onPageSizeChange={size => { setPageSize(size); setPage(1); }} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { i18nMessage } from "@govoplan/core-webui";
|
||||
import { humanize } from "../utils/campaignView";
|
||||
import PaginatedReviewDetails from "./PaginatedReviewDetails";
|
||||
|
||||
export default function RepeatedFilesDetails({ findings }: { findings: Record<string, unknown>[] }) {
|
||||
return <PaginatedReviewDetails items={findings} renderPage={page => <ul className="small-note">
|
||||
{page.map((finding, index) => <li key={String(finding.file_fingerprint ?? index)}>
|
||||
{String(finding.file_name ?? "Attachment")} · {i18nMessage("i18n:govoplan-campaign.repeated_file_uses", { value0: Number(finding.use_count ?? 0) })} · {humanize(String(finding.disposition ?? "allowed"))}
|
||||
</li>)}
|
||||
</ul>} />;
|
||||
}
|
||||
@@ -44,11 +44,13 @@ function InterventionHint({ tone, summary, requiredAction, destination, document
|
||||
export function ValidationWorkflowGuidance({
|
||||
errors,
|
||||
warnings,
|
||||
stale
|
||||
stale,
|
||||
reviewSatisfied = false
|
||||
}: {
|
||||
errors: number;
|
||||
warnings: number;
|
||||
stale: boolean;
|
||||
reviewSatisfied?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="review-workflow-guidance">
|
||||
@@ -70,7 +72,7 @@ export function ValidationWorkflowGuidance({
|
||||
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
|
||||
/>
|
||||
)}
|
||||
{warnings > 0 && (
|
||||
{warnings > 0 && !reviewSatisfied && (
|
||||
<InterventionHint
|
||||
tone="warning"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1", { value0: warnings })}
|
||||
@@ -124,13 +126,12 @@ export function BuiltMessageReviewProgress({ progress }: { progress: BuildReview
|
||||
}
|
||||
|
||||
export function BuiltMessageWorkflowGuidance({
|
||||
progress,
|
||||
buildWarnings
|
||||
progress
|
||||
}: {
|
||||
progress: BuildReviewProgress;
|
||||
buildWarnings: number;
|
||||
}) {
|
||||
const showUnacknowledgedWarning = progress.remaining === 0 && buildWarnings > 0;
|
||||
// Frozen job review requirements are authoritative. Aggregate build warnings
|
||||
// also contain expected policy outcomes and already acknowledged conditions.
|
||||
return (
|
||||
<div className="review-workflow-guidance">
|
||||
{progress.blocking > 0 && (
|
||||
@@ -155,15 +156,6 @@ export function BuiltMessageWorkflowGuidance({
|
||||
documentationTopicId="campaigns.workflow.complete-review"
|
||||
/>
|
||||
)}
|
||||
{showUnacknowledgedWarning && (
|
||||
<InterventionHint
|
||||
tone="warning"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2", { value0: buildWarnings })}
|
||||
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
|
||||
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
|
||||
documentationTopicId="campaigns.workflow.complete-review"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from "react";
|
||||
import { DescriptionList } from "@govoplan/core-webui";
|
||||
import { humanize } from "../utils/campaignView";
|
||||
import PaginatedReviewDetails from "./PaginatedReviewDetails";
|
||||
import { groupCampaignValidationIssues, type CampaignValidationIssue } from "./validationIssueGroups";
|
||||
|
||||
export default function ValidationDetails({ issues }: { issues: CampaignValidationIssue[] }) {
|
||||
const groups = useMemo(() => groupCampaignValidationIssues(issues), [issues]);
|
||||
return <>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.validation_grouping_help</p>
|
||||
<PaginatedReviewDetails items={groups} renderPage={page => <DescriptionList variant="inline">
|
||||
{page.map(group => <div key={group.key} data-validation-issue-group={group.key}>
|
||||
<dt>{humanize(group.severity)}</dt>
|
||||
<dd>
|
||||
{group.causes.map((issue, index) => <p key={`cause:${index}`}><strong>{String(issue.message ?? issue.code ?? "")}</strong></p>)}
|
||||
{group.outcomes.map((issue, index) => <p key={`outcome:${index}`}>
|
||||
<span>i18n:govoplan-campaign.validation_policy_outcome</span>{" "}<strong>{String(issue.message ?? issue.code ?? "")}</strong>
|
||||
</p>)}
|
||||
<details><summary>i18n:govoplan-campaign.validation_technical_evidence</summary>
|
||||
<ul className="muted small-note">{group.evidence.map((issue, index) => <li key={index}>
|
||||
{humanize(String(issue.severity ?? "info"))} · {String(issue.path ?? "—")} · {String(issue.code ?? "—")}
|
||||
</li>)}</ul>
|
||||
</details>
|
||||
</dd>
|
||||
</div>)}
|
||||
</DescriptionList>} />
|
||||
</>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { DataGridQueryState } from "@govoplan/core-webui";
|
||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import { asArray, asRecord } from "../utils/campaignView";
|
||||
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
||||
import { builtMessageState } from "./builtMessageState";
|
||||
|
||||
type ReviewFilterType = "text" | "integer" | "list";
|
||||
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
||||
@@ -10,7 +11,8 @@ type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
||||
export function filterAndSortBuiltMessageRows(
|
||||
rows: Record<string, unknown>[],
|
||||
query: DataGridQueryState,
|
||||
reviewedKeys: Set<string>
|
||||
reviewedKeys: Set<string>,
|
||||
inspectionComplete = false
|
||||
): Record<string, unknown>[] {
|
||||
const filters = query.filters ?? {};
|
||||
const filtered = rows.filter((row, rowIndex) =>
|
||||
@@ -18,7 +20,7 @@ export function filterAndSortBuiltMessageRows(
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return true;
|
||||
if (!filterValue.trim()) return true;
|
||||
return matchesReviewFilter(
|
||||
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
|
||||
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys, inspectionComplete),
|
||||
filterValue,
|
||||
builtMessageFilterType(columnId)
|
||||
);
|
||||
@@ -31,8 +33,8 @@ export function filterAndSortBuiltMessageRows(
|
||||
const leftIndex = rows.indexOf(left);
|
||||
const rightIndex = rows.indexOf(right);
|
||||
const result = compareReviewValues(
|
||||
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
|
||||
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
|
||||
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys, inspectionComplete),
|
||||
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys, inspectionComplete)
|
||||
);
|
||||
return direction === "desc" ? -result : result;
|
||||
});
|
||||
@@ -54,7 +56,15 @@ export function reviewQueryEquals(
|
||||
}
|
||||
|
||||
export function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
||||
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
|
||||
const eligibility = asRecord(row.review_decision).eligible;
|
||||
if (typeof eligibility === "boolean") return eligibility;
|
||||
return String(row.validation_status ?? "").toLowerCase() === "needs_review"
|
||||
&& String(row.build_status ?? "built") === "built"
|
||||
&& !asArray(row.issues).some((value) => {
|
||||
const issue = asRecord(value);
|
||||
return ["block", "blocked", "error"].includes(String(issue.behavior ?? "").toLowerCase())
|
||||
|| ["error", "critical", "fatal"].includes(String(issue.severity ?? "").toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
export function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
||||
@@ -64,9 +74,9 @@ export function storedMessageReviewState(version: CampaignVersionDetail | null):
|
||||
issueDecisions: Array<Record<string, unknown>>;
|
||||
} {
|
||||
const build = asRecord(version?.build_summary);
|
||||
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
||||
const buildToken = String(version?.review_build_token ?? build.build_token ?? build.built_at ?? "");
|
||||
const review = asRecord(asRecord(version?.editor_state).review_send);
|
||||
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
||||
if (!buildToken || String(review.review_build_token ?? review.build_token ?? "") !== buildToken) {
|
||||
return {
|
||||
buildToken,
|
||||
inspectionComplete: false,
|
||||
@@ -87,6 +97,8 @@ export function storedMessageReviewState(version: CampaignVersionDetail | null):
|
||||
export function messageRequiresAttachmentOverrideReason(
|
||||
row: Record<string, unknown>
|
||||
): boolean {
|
||||
const decision = asRecord(row.review_decision);
|
||||
if (typeof decision.reason_required === "boolean") return decision.eligible === true && decision.reason_required;
|
||||
return asArray(row.issues).some((value) => {
|
||||
const issue = asRecord(value);
|
||||
return String(issue.behavior ?? "").toLowerCase() === "ask"
|
||||
@@ -95,7 +107,7 @@ export function messageRequiresAttachmentOverrideReason(
|
||||
}
|
||||
|
||||
export function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
||||
return String(row.entry_id ?? row.entry_index ?? index);
|
||||
return String(row.review_key ?? row.entry_id ?? row.entry_index ?? index);
|
||||
}
|
||||
|
||||
export function findBuiltMessageIndex(
|
||||
@@ -144,14 +156,15 @@ export function sameBuiltMessage(
|
||||
}
|
||||
|
||||
function isBuiltMessageQueryColumn(columnId: string): boolean {
|
||||
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
|
||||
return ["number", "recipient", "subject", "messageState", "validation", "attachments", "reviewed"].includes(columnId);
|
||||
}
|
||||
|
||||
function builtMessageColumnValue(
|
||||
columnId: string,
|
||||
row: Record<string, unknown>,
|
||||
index: number,
|
||||
reviewedKeys: Set<string>
|
||||
reviewedKeys: Set<string>,
|
||||
inspectionComplete = false
|
||||
): unknown {
|
||||
switch (columnId) {
|
||||
case "number":
|
||||
@@ -163,6 +176,8 @@ function builtMessageColumnValue(
|
||||
);
|
||||
case "subject":
|
||||
return String(row.subject ?? "—");
|
||||
case "messageState":
|
||||
return builtMessageState(row, index, reviewedKeys, inspectionComplete).state;
|
||||
case "validation":
|
||||
return String(row.validation_status ?? "unknown");
|
||||
case "attachments":
|
||||
@@ -179,7 +194,7 @@ function builtMessageColumnValue(
|
||||
|
||||
function builtMessageFilterType(columnId: string): ReviewFilterType {
|
||||
if (["number", "attachments"].includes(columnId)) return "integer";
|
||||
if (["validation", "reviewed"].includes(columnId)) return "list";
|
||||
if (["messageState", "validation", "reviewed"].includes(columnId)) return "list";
|
||||
return "text";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export type BuiltMessageState = "ready" | "needs_review" | "blocked" | "excluded";
|
||||
|
||||
export type BuiltMessageStatePresentation = {
|
||||
state: BuiltMessageState;
|
||||
explanationLabel: string;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** One display/filter model; it never authorizes an exception or a delivery. */
|
||||
export function builtMessageState(
|
||||
row: Record<string, unknown>,
|
||||
index: number,
|
||||
reviewedKeys: ReadonlySet<string>,
|
||||
inspectionComplete = false
|
||||
): BuiltMessageStatePresentation {
|
||||
const validation = String(row.validation_status ?? "").toLowerCase();
|
||||
if (validation === "excluded" || validation === "inactive") {
|
||||
return { state: "excluded", explanationLabel: validation === "inactive"
|
||||
? "i18n:govoplan-campaign.message_state_inactive" : "i18n:govoplan-campaign.message_state_excluded" };
|
||||
}
|
||||
const build = String(row.build_status ?? "built").toLowerCase();
|
||||
if (build !== "built") {
|
||||
return { state: "blocked", explanationLabel: ["failed", "error"].includes(build)
|
||||
? "i18n:govoplan-campaign.message_state_build_failed" : "i18n:govoplan-campaign.message_state_not_built" };
|
||||
}
|
||||
const issues = Array.isArray(row.issues) ? row.issues.map(record) : [];
|
||||
if (validation === "blocked" || issues.some((issue) => ["block", "blocked"].includes(String(issue.behavior ?? "").toLowerCase())
|
||||
|| ["error", "critical", "fatal"].includes(String(issue.severity ?? "").toLowerCase()))) {
|
||||
return { state: "blocked", explanationLabel: "i18n:govoplan-campaign.message_state_blocked" };
|
||||
}
|
||||
if (validation === "warning") {
|
||||
// An earlier view/open marker does not replace the final warning gate.
|
||||
return inspectionComplete
|
||||
? { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_warning_accepted" }
|
||||
: { state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_warning_pending" };
|
||||
}
|
||||
if (validation === "needs_review") {
|
||||
const key = String(row.review_key ?? row.entry_id ?? row.entry_index ?? index);
|
||||
const accepted = row.reviewed === true || reviewedKeys.has(key);
|
||||
return accepted
|
||||
? { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_individually_accepted" }
|
||||
: { state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_individual_pending" };
|
||||
}
|
||||
if (validation === "ready") {
|
||||
return { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_automatic" };
|
||||
}
|
||||
return { state: "blocked", explanationLabel: "i18n:govoplan-campaign.message_state_unknown" };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export const BULK_MESSAGE_REVIEW_LIMIT = 200;
|
||||
|
||||
export type BulkMessageReviewRow = {
|
||||
jobId: string;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
};
|
||||
|
||||
export type BulkMessageReviewGroup = {
|
||||
categoryKey: string;
|
||||
issueCodes: string[];
|
||||
reasonRequired: boolean;
|
||||
messages: BulkMessageReviewRow[];
|
||||
};
|
||||
|
||||
export type BulkMessageReviewSelection = {
|
||||
buildToken: string;
|
||||
categoryKey: string;
|
||||
jobIds: string[];
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const issueLabels: Record<string, string> = {
|
||||
missing_required_attachment: "i18n:govoplan-campaign.bulk_review_missing_required",
|
||||
missing_optional_attachment: "i18n:govoplan-campaign.bulk_review_missing_optional",
|
||||
missing_attachment_coverage: "i18n:govoplan-campaign.bulk_review_no_attachments",
|
||||
ambiguous_attachment_match: "i18n:govoplan-campaign.bulk_review_multiple_matches",
|
||||
duplicate_attachment_reuse: "i18n:govoplan-campaign.bulk_review_shared_attachment",
|
||||
attachment_override_required: "i18n:govoplan-campaign.bulk_review_attachment_exception",
|
||||
attachment_warning: "i18n:govoplan-campaign.bulk_review_attachment_exception",
|
||||
attachment_match_empty: "i18n:govoplan-campaign.bulk_review_missing_optional",
|
||||
unsent_attachment_files: "i18n:govoplan-campaign.bulk_review_unused_files",
|
||||
residual_attachment_disposition: "i18n:govoplan-campaign.bulk_review_unused_files",
|
||||
missing_email: "i18n:govoplan-campaign.bulk_review_missing_address",
|
||||
};
|
||||
|
||||
export function bulkReviewIssueLabel(code: string): string {
|
||||
return issueLabels[code] ?? "i18n:govoplan-campaign.bulk_review_other_category";
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Only the backend may offer a category for accepting frozen review evidence. */
|
||||
export function bulkMessageReviewGroups(rows: Record<string, unknown>[]): BulkMessageReviewGroup[] {
|
||||
const groups = new Map<string, BulkMessageReviewGroup>();
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const decision = record(row.review_decision);
|
||||
const jobId = String(row.id ?? "").trim();
|
||||
const categoryKey = String(decision.category_key ?? "").trim();
|
||||
if (decision.eligible !== true || !jobId || !categoryKey || seen.has(jobId)
|
||||
|| row.reviewed === true || row.build_status !== "built"
|
||||
|| row.validation_status !== "needs_review") continue;
|
||||
const issues = Array.isArray(row.issues) ? row.issues.map(record) : [];
|
||||
if (issues.some((issue) => ["block", "blocked", "error"].includes(String(issue.behavior ?? "").toLowerCase())
|
||||
|| ["error", "critical", "fatal"].includes(String(issue.severity ?? "").toLowerCase()))) continue;
|
||||
seen.add(jobId);
|
||||
const issueCodes = Array.isArray(decision.issue_codes)
|
||||
? decision.issue_codes.filter((code): code is string => typeof code === "string" && Boolean(code.trim())) : [];
|
||||
const group = groups.get(categoryKey) ?? {
|
||||
categoryKey, issueCodes: [...new Set(issueCodes)].sort(), reasonRequired: false, messages: []
|
||||
};
|
||||
group.reasonRequired ||= decision.reason_required === true;
|
||||
const recipients = record(row.resolved_recipients).to;
|
||||
const recipient = Array.isArray(recipients)
|
||||
? recipients.map((value) => typeof value === "string" ? value
|
||||
: String(record(value).email ?? record(value).address ?? "")).filter(Boolean).join(", ") : "";
|
||||
group.messages.push({ jobId, recipient: recipient || String(row.recipient_email ?? row.entry_id ?? jobId), subject: String(row.subject ?? "") });
|
||||
groups.set(categoryKey, group);
|
||||
}
|
||||
return [...groups.values()].map((group) => ({ ...group, messages: group.messages.sort((left, right) => left.jobId.localeCompare(right.jobId)) }))
|
||||
.sort((left, right) => left.categoryKey.localeCompare(right.categoryKey));
|
||||
}
|
||||
@@ -1,41 +1,43 @@
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import {
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
i18nMessage,
|
||||
type DataGridColumn,
|
||||
type DataGridListOption
|
||||
} from "@govoplan/core-webui";
|
||||
import type { MockMailboxMessage } from "../../../api/mail";
|
||||
import type { CampaignMessagePreviewAttachment } from "../components/MessagePreviewOverlay";
|
||||
import { asArray, asRecord, stringifyPreview } from "../utils/campaignView";
|
||||
import { builtMessageKey } from "./builtMessageQuery";
|
||||
import { SEND_STATUS_OPTIONS, IMAP_STATUS_OPTIONS, deliveryStatusLabel } from "../utils/deliveryStatusOptions";
|
||||
import { builtMessageState } from "./builtMessageState";
|
||||
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
||||
|
||||
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
|
||||
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" },
|
||||
{ value: "warning", label: "i18n:govoplan-campaign.warning.e9c45563" },
|
||||
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
|
||||
{ value: "blocked", label: "i18n:govoplan-campaign.blocked.99613c74" },
|
||||
{ value: "excluded", label: "i18n:govoplan-campaign.excluded.9804952b" }];
|
||||
const MESSAGE_STATE_OPTIONS: DataGridListOption[] = [
|
||||
{ value: "ready", label: "i18n:govoplan-campaign.message_state_ready" },
|
||||
{ value: "needs_review", label: "i18n:govoplan-campaign.message_state_needs_review" },
|
||||
{ value: "blocked", label: "i18n:govoplan-campaign.message_state_blocked_label" },
|
||||
{ value: "excluded", label: "i18n:govoplan-campaign.message_state_excluded_label" }];
|
||||
|
||||
|
||||
export function builtMessageColumns(
|
||||
openMessage: (row: Record<string, unknown>) => void,
|
||||
reviewedKeys: Set<string>)
|
||||
reviewedKeys: Set<string>,
|
||||
inspectionComplete = false)
|
||||
: DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
|
||||
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
|
||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{
|
||||
id: "validation",
|
||||
header: "i18n:govoplan-campaign.validation.dd74d182",
|
||||
id: "messageState",
|
||||
header: "i18n:govoplan-campaign.message_state_heading",
|
||||
width: 145,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
|
||||
value: (row) => String(row.validation_status ?? "unknown")
|
||||
list: { options: MESSAGE_STATE_OPTIONS, display: "pill" },
|
||||
value: (row, index) => builtMessageState(row, index, reviewedKeys, inspectionComplete).state
|
||||
},
|
||||
{
|
||||
id: "postboxTargets",
|
||||
@@ -55,7 +57,8 @@ reviewedKeys: Set<string>)
|
||||
}
|
||||
},
|
||||
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
|
||||
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted">—</span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
|
||||
{ id: "stateExplanation", header: "i18n:govoplan-campaign.message_state_explanation_heading", width: "minmax(220px, 1fr)", maxWidth: 480, resizable: true,
|
||||
value: (row, index) => builtMessageState(row, index, reviewedKeys, inspectionComplete).explanationLabel },
|
||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
|
||||
|
||||
}
|
||||
@@ -107,8 +110,8 @@ export function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<voi
|
||||
return [
|
||||
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
|
||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
|
||||
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
|
||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
|
||||
{ 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: (row) => <StatusBadge status={String(row.send_status ?? "info")} label={deliveryStatusLabel(String(row.send_status ?? "info"))} />, value: (row) => String(row.send_status ?? "-") },
|
||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} label={deliveryStatusLabel(String(row.imap_status ?? "info"))} />, value: (row) => String(row.imap_status ?? "-") },
|
||||
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
|
||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
|
||||
|
||||
@@ -153,7 +156,10 @@ export function synchronousSendReason(option: Record<string, unknown>): string {
|
||||
const configuredMessage = String(option.message ?? "").trim();
|
||||
if (configuredMessage) return configuredMessage;
|
||||
switch (String(option.reason ?? "")) {
|
||||
case "recipient_limit_exceeded":return "i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a";
|
||||
case "recipient_limit_exceeded":return i18nMessage("i18n:govoplan-campaign.synchronous_limit_explanation", {
|
||||
value0: Number(option.eligible_recipient_job_count ?? 0),
|
||||
value1: Number(asRecord(option.policy).max_recipient_jobs ?? 0)
|
||||
});
|
||||
case "no_eligible_recipient_jobs":return "i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c";
|
||||
case "version_not_ready":return "i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc";
|
||||
case "policy_configuration_invalid":return "i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8";
|
||||
@@ -168,6 +174,7 @@ export function deliveryControlProgressMessage(action: "pause" | "resume" | "can
|
||||
}
|
||||
|
||||
export function deliveryPolicySourceLabel(source: string): string {
|
||||
if (source === "system" || source === "system_ceiling") return "i18n:govoplan-campaign.system_delivery_policy_source";
|
||||
if (source === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
|
||||
if (source === "deployment") return "i18n:govoplan-campaign.deployment.327a55f8";
|
||||
if (source === "deployment_default") return "i18n:govoplan-campaign.deployment_default.aa39f7b4";
|
||||
|
||||
@@ -47,3 +47,27 @@ export function calculateBuildReviewProgress(input: BuildReviewProgressInput): B
|
||||
remaining: individualRemaining + groupRemaining
|
||||
};
|
||||
}
|
||||
|
||||
/** Summary match counts describe inputs; they are not fresh review gates. */
|
||||
export function attachmentDeliveryPreflight(input: {
|
||||
migrationRequired: boolean;
|
||||
hasBuild: boolean;
|
||||
reviewLoaded: boolean;
|
||||
blocking: number;
|
||||
remaining: number;
|
||||
inspectionComplete: boolean;
|
||||
missing: number;
|
||||
ambiguous: number;
|
||||
}): { state: "ready" | "warning" | "blocked" | "info"; detail: string } {
|
||||
if (input.migrationRequired) return { state: "blocked", detail: "i18n:govoplan-campaign.attachment_checks_await_mail_migration" };
|
||||
if (!input.hasBuild) return { state: "info", detail: "i18n:govoplan-campaign.attachment_preflight_build_first" };
|
||||
if (!input.reviewLoaded) return { state: "info", detail: "i18n:govoplan-campaign.attachment_preflight_loading" };
|
||||
if (input.blocking > 0) return { state: "blocked", detail: "i18n:govoplan-campaign.attachment_preflight_blocked" };
|
||||
if (input.inspectionComplete || input.remaining === 0) {
|
||||
return { state: "ready", detail: "i18n:govoplan-campaign.attachment_preflight_satisfied" };
|
||||
}
|
||||
if (input.missing + input.ambiguous > 0) {
|
||||
return { state: "warning", detail: "i18n:govoplan-campaign.attachment_preflight_review" };
|
||||
}
|
||||
return { state: "info", detail: "i18n:govoplan-campaign.attachment_preflight_pending" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export type CampaignValidationIssue = Record<string, unknown>;
|
||||
export type CampaignValidationIssueGroup = {
|
||||
key: string;
|
||||
severity: string;
|
||||
causes: CampaignValidationIssue[];
|
||||
outcomes: CampaignValidationIssue[];
|
||||
evidence: CampaignValidationIssue[];
|
||||
};
|
||||
|
||||
const MISSING_RULE_CODES = new Set(["missing_required_attachment", "missing_optional_attachment", "attachment_match_empty"]);
|
||||
function entryPath(issue: CampaignValidationIssue): string | null {
|
||||
return String(issue.path ?? "").match(/^(\/entries\/[^/]+)(?:\/attachments\/\d+)?$/)?.[1] ?? null;
|
||||
}
|
||||
function severityRank(issue: CampaignValidationIssue): number {
|
||||
return ({ error: 3, warning: 2, info: 1 } as Record<string, number>)[String(issue.severity ?? "")] ?? 0;
|
||||
}
|
||||
|
||||
/** Join a recipient's missing-rule cause with its no-attachment policy outcome.
|
||||
* Keep every original record; unrelated validation problems stay independent.
|
||||
*/
|
||||
export function groupCampaignValidationIssues(issues: CampaignValidationIssue[]): CampaignValidationIssueGroup[] {
|
||||
const coveragePaths = new Set(issues.filter(issue => issue.code === "missing_attachment_coverage")
|
||||
.map(entryPath).filter((path): path is string => Boolean(path)));
|
||||
const groups = new Map<string, CampaignValidationIssueGroup>();
|
||||
issues.forEach((issue, index) => {
|
||||
const path = entryPath(issue);
|
||||
const coverage = issue.code === "missing_attachment_coverage";
|
||||
const related = path && coveragePaths.has(path) && (coverage || MISSING_RULE_CODES.has(String(issue.code ?? "")));
|
||||
const key = related ? `attachment-coverage:${path}` : `issue:${index}`;
|
||||
const group = groups.get(key) ?? { key, severity: String(issue.severity ?? "info"), causes: [], outcomes: [], evidence: [] };
|
||||
if (severityRank(issue) > severityRank({ severity: group.severity })) group.severity = String(issue.severity);
|
||||
group.evidence.push(issue);
|
||||
(coverage && related ? group.outcomes : group.causes).push(issue);
|
||||
groups.set(key, group);
|
||||
});
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DataGridListOption } from "@govoplan/core-webui";
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
not_queued: "Not queued", skipped: "Excluded", queued: "Queued", claimed: "Claimed", sending: "Sending",
|
||||
smtp_accepted: "SMTP accepted", postbox_accepted: "Postbox accepted", print_accepted: "Print accepted",
|
||||
delivered: "Delivered", partially_accepted: "Partially accepted", sent: "Sent", outcome_unknown: "Outcome uncertain",
|
||||
failed_temporary: "Temporary failure", failed_permanent: "Permanent failure", cancelled: "Cancelled",
|
||||
not_requested: "Not requested", pending: "Pending", appending: "Copying to Sent", appended: "Copied to Sent",
|
||||
failed: "Failed", ready: "Ready", accepted: "Accepted", delivering: "Delivering", accepted_vacant: "Accepted (vacant)",
|
||||
rejected_temporary: "Temporarily rejected", rejected_permanent: "Permanently rejected"
|
||||
};
|
||||
|
||||
export function deliveryStatusLabel(status: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(labels, status) ? `i18n:govoplan-campaign.delivery_status_${status}` : undefined;
|
||||
}
|
||||
|
||||
function statusOptions(values: string[]): DataGridListOption[] {
|
||||
return values.map((value) => ({ value, label: deliveryStatusLabel(value) ?? value }));
|
||||
}
|
||||
|
||||
export const SEND_STATUS_OPTIONS = statusOptions([
|
||||
"not_queued", "skipped", "queued", "claimed", "sending", "smtp_accepted", "postbox_accepted", "print_accepted",
|
||||
"delivered", "partially_accepted", "sent", "outcome_unknown", "failed_temporary", "failed_permanent", "cancelled"
|
||||
]);
|
||||
export const IMAP_STATUS_OPTIONS = statusOptions([
|
||||
"not_requested", "pending", "appending", "appended", "outcome_unknown", "failed", "skipped"
|
||||
]);
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Review and approval evidence is readable, but is never a client mutation. */
|
||||
export function clientCampaignEditorState(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of ["created_from", "field_overrides", "opt_ins"] as const) {
|
||||
if (value && Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
result[key] = value[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function campaignVersionUpdateForRequest<T extends {
|
||||
editor_state?: Record<string, unknown> | null;
|
||||
}>(payload: T): T {
|
||||
// Preserve omission/null: they mean “leave metadata unchanged” on the server.
|
||||
if (payload.editor_state == null) return payload;
|
||||
return {
|
||||
...payload,
|
||||
editor_state: clientCampaignEditorState(payload.editor_state)
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,23 @@
|
||||
export function campaignMailProfileListOptions(view: "settings" | "policy", campaignId: string) {
|
||||
// Policy management needs its administrative catalogue; delivery selection
|
||||
// needs only profiles authorized for this particular campaign. Do not couple
|
||||
// these independent reads or let a policy catalogue failure empty the picker.
|
||||
return view === "policy"
|
||||
? { includeInactive: true, campaignId: undefined }
|
||||
: { includeInactive: false, campaignId };
|
||||
}
|
||||
|
||||
export function campaignMailReferencesUnchanged(
|
||||
current: Record<string, unknown>, candidate: Record<string, unknown>
|
||||
): boolean {
|
||||
const before = isRecord(current.server) ? current.server : {};
|
||||
const after = isRecord(candidate.server) ? candidate.server : {};
|
||||
const keys = Object.keys(before);
|
||||
return keys.length === Object.keys(after).length && keys.every((key) => (
|
||||
Object.prototype.hasOwnProperty.call(after, key) && after[key] === before[key]
|
||||
));
|
||||
}
|
||||
|
||||
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const server = isRecord(value.server) ? value.server : {};
|
||||
const profileId = textId(server.mail_profile_id);
|
||||
|
||||
@@ -8,6 +8,8 @@ export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" a
|
||||
export type ReportGridShortcutId =
|
||||
| "all"
|
||||
| "smtp_accepted"
|
||||
| "smtp_active"
|
||||
| "smtp_queued"
|
||||
| "postbox_accepted"
|
||||
| "print_accepted"
|
||||
| "failed"
|
||||
@@ -17,11 +19,16 @@ export type ReportGridShortcutId =
|
||||
| "cancelled"
|
||||
| "imap_appended"
|
||||
| "imap_failed"
|
||||
| "imap_pending"
|
||||
| "imap_active"
|
||||
| "imap_unknown"
|
||||
| "imap_skipped";
|
||||
|
||||
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
|
||||
all: {},
|
||||
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
|
||||
smtp_active: { send: listFilter(["claimed", "sending"]) },
|
||||
smtp_queued: { send: listFilter(["queued"]) },
|
||||
postbox_accepted: { send: listFilter(["postbox_accepted"]) },
|
||||
print_accepted: { send: listFilter(["print_accepted"]) },
|
||||
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
|
||||
@@ -31,6 +38,9 @@ const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string,
|
||||
cancelled: { send: listFilter(["cancelled"]) },
|
||||
imap_appended: { imap: listFilter(["appended"]) },
|
||||
imap_failed: { imap: listFilter(["failed"]) },
|
||||
imap_pending: { imap: listFilter(["pending"]) },
|
||||
imap_active: { imap: listFilter(["appending"]) },
|
||||
imap_unknown: { imap: listFilter(["outcome_unknown"]) },
|
||||
imap_skipped: { imap: listFilter(["skipped"]) }
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function CreateWizard({ settings, campaignId }: {settings: ApiSet
|
||||
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, dirty, saving, saveState, patch, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -132,7 +132,7 @@ export default function CreateWizard({ settings, campaignId }: {settings: ApiSet
|
||||
</Card>
|
||||
<div className="wizard-footer">
|
||||
<Button onClick={previousStep}>i18n:govoplan-campaign.back.b52b36b7</Button>
|
||||
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||
<Button onClick={() => saveDraft("manual")} disabled={!dirty || saving}>{saving ? "i18n:govoplan-campaign.saving.56a2285c" : dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||
<Button onClick={validateCurrentStep}>i18n:govoplan-campaign.validate_step.6a8b527c</Button>
|
||||
<Button variant="primary" onClick={nextStep}>i18n:govoplan-campaign.continue.2e026239</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,176 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-campaign.report_claim_still_active": "Processing is still active or its lease has not expired. Recovery is unavailable; wait and reload the saved status.",
|
||||
"i18n:govoplan-campaign.report_claim_owner_unconfirmed": "The server cannot yet confirm that the previous process stopped. Recovery stays blocked to prevent duplicate delivery; ask an administrator to check the process and its lease.",
|
||||
"i18n:govoplan-campaign.report_recipients_to": "To",
|
||||
"i18n:govoplan-campaign.report_recipients_cc": "Cc",
|
||||
"i18n:govoplan-campaign.report_recipients_bcc": "Bcc",
|
||||
"i18n:govoplan-campaign.report_retry_now": "Retry now",
|
||||
"i18n:govoplan-campaign.report_send_unattempted_now": "Send unattempted message now",
|
||||
"i18n:govoplan-campaign.report_retry_page_now": "Retry failed messages on this page now ({value0})",
|
||||
"i18n:govoplan-campaign.report_send_page_now": "Send unattempted messages on this page now ({value0})",
|
||||
"i18n:govoplan-campaign.report_queue_retry_workers": "Queue temporary failures for workers",
|
||||
"i18n:govoplan-campaign.report_queue_unattempted_workers": "Queue unattempted messages for workers",
|
||||
"i18n:govoplan-campaign.report_workers_unavailable": "Background workers are disabled. Use the explicit “now” actions for synchronous recovery.",
|
||||
"i18n:govoplan-campaign.report_inline_scope_help": "“Now” actions affect only the indicated jobs on the current filtered page, within the configured synchronous limit. Previously accepted or uncertain deliveries are never automatically resent.",
|
||||
"i18n:govoplan-campaign.report_inline_result": "Delivery request finished: {value0} attempted, {value1} accepted, {value2} failed, {value3} unknown, {value4} remaining. Inspect the refreshed delivery states before continuing.",
|
||||
"i18n:govoplan-campaign.report_acknowledged_refresh_failed": "The action was acknowledged, but refreshing its display failed. Reload the report; do not repeat the action blindly.",
|
||||
"i18n:govoplan-campaign.report_reconcile_title": "Record verified delivery outcome",
|
||||
"i18n:govoplan-campaign.report_recover_claim_title": "Recover interrupted processing",
|
||||
"i18n:govoplan-campaign.report_recover_claim_confirm": "Mark outcome for investigation",
|
||||
"i18n:govoplan-campaign.report_recover_claim_help": "The server has identified a stopped owner and an expired processing claim. Record your evidence to mark this attempt as uncertain. This does not send anything or decide whether delivery succeeded; verify that separately before recording an outcome.",
|
||||
"i18n:govoplan-campaign.report_evidence_note": "Evidence note",
|
||||
"i18n:govoplan-campaign.report_recording_evidence": "Recording evidence…",
|
||||
"i18n:govoplan-campaign.report_record_accepted": "Record SMTP acceptance",
|
||||
"i18n:govoplan-campaign.report_record_not_sent": "Record message as not sent",
|
||||
"i18n:govoplan-campaign.report_accepted_evidence_help": "Confirm only after verifying acceptance or the saved IMAP copy in the mail server logs or mailbox. Explain what you checked. Recording evidence does not send the message again.",
|
||||
"i18n:govoplan-campaign.report_not_sent_evidence_help": "Confirm only when you have evidence that the message was not accepted, or the IMAP copy was not saved. An incorrect decision could permit a duplicate. Retrying remains a separate explicit action.",
|
||||
"i18n:govoplan-campaign.report_claim_recovered": "Interrupted processing is now marked as uncertain. Inspect provider evidence and record the verified outcome separately.",
|
||||
"i18n:govoplan-campaign.report_evidence_recorded": "The verified outcome was recorded. Any retry remains a separate explicit action.",
|
||||
"i18n:govoplan-campaign.report_recover_smtp_claim": "Recover interrupted SMTP processing",
|
||||
"i18n:govoplan-campaign.report_recover_imap_claim": "Recover interrupted IMAP processing",
|
||||
"i18n:govoplan-campaign.report_record_imap_appended": "Record IMAP copy as saved",
|
||||
"i18n:govoplan-campaign.report_record_imap_not_appended": "Record IMAP copy as not saved",
|
||||
"i18n:govoplan-campaign.delivery_policy_admin": "Campaign delivery",
|
||||
"i18n:govoplan-campaign.configure_sending_limit": "Configure sending limit",
|
||||
"i18n:govoplan-campaign.delivery_progress_smtp": "Sending messages",
|
||||
"i18n:govoplan-campaign.delivery_status_not_queued": "Not queued",
|
||||
"i18n:govoplan-campaign.delivery_status_skipped": "Excluded",
|
||||
"i18n:govoplan-campaign.delivery_status_queued": "Queued",
|
||||
"i18n:govoplan-campaign.delivery_status_claimed": "Claimed",
|
||||
"i18n:govoplan-campaign.delivery_status_sending": "Sending",
|
||||
"i18n:govoplan-campaign.delivery_status_smtp_accepted": "SMTP accepted",
|
||||
"i18n:govoplan-campaign.delivery_status_postbox_accepted": "Postbox accepted",
|
||||
"i18n:govoplan-campaign.delivery_status_print_accepted": "Print accepted",
|
||||
"i18n:govoplan-campaign.delivery_status_delivered": "Delivered",
|
||||
"i18n:govoplan-campaign.delivery_status_partially_accepted": "Partially accepted",
|
||||
"i18n:govoplan-campaign.delivery_status_sent": "Sent",
|
||||
"i18n:govoplan-campaign.delivery_status_outcome_unknown": "Outcome uncertain",
|
||||
"i18n:govoplan-campaign.delivery_status_failed_temporary": "Temporary failure",
|
||||
"i18n:govoplan-campaign.delivery_status_failed_permanent": "Permanent failure",
|
||||
"i18n:govoplan-campaign.delivery_status_cancelled": "Cancelled",
|
||||
"i18n:govoplan-campaign.delivery_status_not_requested": "Not requested",
|
||||
"i18n:govoplan-campaign.delivery_status_pending": "Pending",
|
||||
"i18n:govoplan-campaign.delivery_status_appending": "Copying to Sent",
|
||||
"i18n:govoplan-campaign.delivery_status_appended": "Copied to Sent",
|
||||
"i18n:govoplan-campaign.delivery_status_failed": "Failed",
|
||||
"i18n:govoplan-campaign.delivery_status_ready": "Ready",
|
||||
"i18n:govoplan-campaign.delivery_status_accepted": "Accepted",
|
||||
"i18n:govoplan-campaign.delivery_status_delivering": "Delivering",
|
||||
"i18n:govoplan-campaign.delivery_status_accepted_vacant": "Accepted (vacant)",
|
||||
"i18n:govoplan-campaign.delivery_status_rejected_temporary": "Temporarily rejected",
|
||||
"i18n:govoplan-campaign.delivery_status_rejected_permanent": "Permanently rejected",
|
||||
"i18n:govoplan-campaign.delivery_progress_imap": "Copying messages to Sent",
|
||||
"i18n:govoplan-campaign.delivery_progress_processed": "{value0}/{value1} processed",
|
||||
"i18n:govoplan-campaign.delivery_progress_loading": "Loading saved progress…",
|
||||
"i18n:govoplan-campaign.delivery_progress_running": "Processing is running. Only these saved counters refresh; the campaign page stays unchanged.",
|
||||
"i18n:govoplan-campaign.delivery_progress_finished": "The request finished. Check the totals below for remaining work or problems.",
|
||||
"i18n:govoplan-campaign.delivery_progress_interrupted": "The connection ended, but processing may still be running. These saved counters continue refreshing. Do not resend or append again until the outcome is clear.",
|
||||
"i18n:govoplan-campaign.delivery_progress_stale": "Progress could not be refreshed. The last available counters are retained; checking again shortly. This does not mean processing failed.",
|
||||
"i18n:govoplan-campaign.delivery_progress_accepted": "SMTP accepted",
|
||||
"i18n:govoplan-campaign.delivery_progress_appended": "Copied to Sent",
|
||||
"i18n:govoplan-campaign.delivery_progress_active": "In progress",
|
||||
"i18n:govoplan-campaign.delivery_progress_pending": "Pending",
|
||||
"i18n:govoplan-campaign.delivery_progress_failed": "Failed",
|
||||
"i18n:govoplan-campaign.delivery_progress_unknown": "Outcome uncertain",
|
||||
"i18n:govoplan-campaign.delivery_progress_excluded": "Excluded",
|
||||
"i18n:govoplan-campaign.delivery_progress_paused": "Paused",
|
||||
"i18n:govoplan-campaign.delivery_progress_cancelled": "Cancelled",
|
||||
"i18n:govoplan-campaign.delivery_progress_updated": "Last checked",
|
||||
"i18n:govoplan-campaign.delivery_progress_scope": "Counts cover this campaign version. Processed includes successful, failed, uncertain and cancelled results; excluded messages are outside the total. In-progress messages are counted separately.",
|
||||
"i18n:govoplan-campaign.delivery_progress_refresh_failed": "Delivery details could not be refreshed. Saved results are retained; this does not mean processing failed. Use Reload to refresh the display, not to repeat the operation.",
|
||||
"i18n:govoplan-campaign.delivery_smtp_finished_summary": "Send finished. SMTP accepted {value0} message(s), failed {value1}, outcome unknown {value2}, paused {value3}.",
|
||||
"i18n:govoplan-campaign.delivery_imap_finished_summary": "IMAP append processed {value0} job(s): appended {value1}, failed {value2}.",
|
||||
"i18n:govoplan-campaign.delivery_imap_queued_summary": "Queued {value0} pending IMAP append job(s).",
|
||||
"i18n:govoplan-campaign.attachment_link_before_lock": "Link all required files before locking the campaign. Once locked, the file selection cannot be changed.",
|
||||
"i18n:govoplan-campaign.attachment_link_locked_warning": "This version is locked: file linking is unavailable. Unlinked files are not part of its frozen attachment snapshot. Use an editable version to link files, then validate, build and review it before sending.",
|
||||
"i18n:govoplan-campaign.attachment_link_loading_reason": "File matches are still loading. Wait before linking or locking.",
|
||||
"i18n:govoplan-campaign.attachment_link_failed_reason": "File matches could not be checked. Refresh the attachment preview before locking.",
|
||||
"i18n:govoplan-campaign.attachment_link_lock_failed": "Locking was stopped because the current file-link preview could not be checked. Refresh it and try again.",
|
||||
"i18n:govoplan-campaign.attachment_link_readonly_reason": "This version is read-only; link files in an editable version.",
|
||||
"i18n:govoplan-campaign.attachment_link_all_done": "All matched files are already linked.",
|
||||
"i18n:govoplan-campaign.system_delivery_policy_source": "System delivery settings",
|
||||
"i18n:govoplan-campaign.message_state_filter_help": "State uses the saved decisions for this build: Ready, Needs review, Blocked or Excluded. The review-candidates button sets this same State filter; the explanation column tells you why a message has that state.",
|
||||
"i18n:govoplan-campaign.validation_grouping_help": "Related attachment-rule causes and their policy outcome appear together. Technical details retain each original validation record; the page count refers to grouped conditions.",
|
||||
"i18n:govoplan-campaign.validation_policy_outcome": "Policy outcome:",
|
||||
"i18n:govoplan-campaign.validation_technical_evidence": "Technical details",
|
||||
"i18n:govoplan-campaign.repeated_file_uses": "{value0} uses",
|
||||
"i18n:govoplan-campaign.message_state_heading": "Message state",
|
||||
"i18n:govoplan-campaign.message_state_ready": "Ready",
|
||||
"i18n:govoplan-campaign.message_state_needs_review": "Needs review",
|
||||
"i18n:govoplan-campaign.message_state_blocked_label": "Blocked",
|
||||
"i18n:govoplan-campaign.message_state_excluded_label": "Excluded",
|
||||
"i18n:govoplan-campaign.message_state_explanation_heading": "Why this state?",
|
||||
"i18n:govoplan-campaign.message_state_automatic": "Build passed; no manual decision is required.",
|
||||
"i18n:govoplan-campaign.message_state_individual_pending": "The build requires an explicit review decision for this message.",
|
||||
"i18n:govoplan-campaign.message_state_individually_accepted": "Build exception accepted with a saved review decision.",
|
||||
"i18n:govoplan-campaign.message_state_warning_pending": "Non-blocking build warning; acknowledge it with Complete review.",
|
||||
"i18n:govoplan-campaign.message_state_warning_accepted": "Build warning acknowledged when review was completed.",
|
||||
"i18n:govoplan-campaign.message_state_excluded": "Excluded by configured policy; this message will not be sent.",
|
||||
"i18n:govoplan-campaign.message_state_inactive": "The recipient is inactive; this message will not be sent.",
|
||||
"i18n:govoplan-campaign.message_state_build_failed": "The message could not be built. Correct the source and rebuild.",
|
||||
"i18n:govoplan-campaign.message_state_not_built": "No completed message build is available.",
|
||||
"i18n:govoplan-campaign.message_state_blocked": "A blocking condition must be corrected and rebuilt; it cannot be accepted.",
|
||||
"i18n:govoplan-campaign.message_state_unknown": "The build state is unavailable. Reload or rebuild before continuing.",
|
||||
"i18n:govoplan-campaign.synchronous_limit_explanation": "This campaign has {value0} eligible messages; ‘Send now’ allows at most {value1} in one request. Larger campaigns use background delivery.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_build_first": "Build the messages to evaluate their exact attachment requirements.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_loading": "Loading review evidence for the current message build.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_blocked": "The current build still has blocking conditions. Correct them before delivery; review decisions cannot override blockers.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_satisfied": "Attachment conditions are satisfied or already accepted for this build. No further attachment approval is required.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_review": "Attachment exceptions still need a decision in Build and Review. Saved decisions carry forward here automatically.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_pending": "No missing or ambiguous matches are reported. Complete the remaining build review before delivery.",
|
||||
"i18n:govoplan-campaign.review_save_pending": "A review decision is being saved. Please wait.",
|
||||
"i18n:govoplan-campaign.review_build_changed": "The message build has changed. Reload the review before making another decision; previous approvals cannot apply to new messages.",
|
||||
"i18n:govoplan-campaign.review_decision_saved": "Review decision saved. You can leave and continue later.",
|
||||
"i18n:govoplan-campaign.review_reason_required": "Explain why it is acceptable to continue without these attachments.",
|
||||
"i18n:govoplan-campaign.review_reason_label": "Why is this acceptable?",
|
||||
"i18n:govoplan-campaign.review_note_label": "Review note (optional)",
|
||||
"i18n:govoplan-campaign.review_reason_help": "Saved with this message when you accept. The next message opens automatically; you do not need to finish the whole review to keep your progress.",
|
||||
"i18n:govoplan-campaign.review_saving_decision": "Saving decision…",
|
||||
"i18n:govoplan-campaign.review_accept_next": "Accept and continue",
|
||||
"i18n:govoplan-campaign.bulk_review_title": "Accept similar review conditions",
|
||||
"i18n:govoplan-campaign.bulk_review_missing_required": "Required attachment was not found",
|
||||
"i18n:govoplan-campaign.bulk_review_missing_optional": "Optional attachment was not found",
|
||||
"i18n:govoplan-campaign.bulk_review_no_attachments": "Message has no attachments",
|
||||
"i18n:govoplan-campaign.bulk_review_multiple_matches": "More than one file matched a single-file rule",
|
||||
"i18n:govoplan-campaign.bulk_review_shared_attachment": "The same attachment is used for several recipients",
|
||||
"i18n:govoplan-campaign.bulk_review_attachment_exception": "Attachment rule requires an exception",
|
||||
"i18n:govoplan-campaign.bulk_review_unused_files": "Some attachment files have no recipient",
|
||||
"i18n:govoplan-campaign.bulk_review_missing_address": "Recipient email address is missing",
|
||||
"i18n:govoplan-campaign.bulk_review_technical_codes": "Technical issue codes",
|
||||
"i18n:govoplan-campaign.bulk_review_scope": "Choose one server-verified issue category among currently loaded matching messages. Only the recipients selected here will be accepted. Blockers, already reviewed messages, intentional policy exclusions, and allowed zero-match attachment rules are not included. This records review evidence; it does not send messages or complete the review.",
|
||||
"i18n:govoplan-campaign.bulk_review_category": "Review condition category",
|
||||
"i18n:govoplan-campaign.bulk_review_other_category": "Other review conditions",
|
||||
"i18n:govoplan-campaign.bulk_review_empty": "No matching messages are eligible for a grouped review decision.",
|
||||
"i18n:govoplan-campaign.bulk_review_subset": "Showing {value0} of {value1} matching unreviewed messages in this category; {value2} selected.",
|
||||
"i18n:govoplan-campaign.bulk_review_limit": "A decision contains at most 200 messages. After saving this selection, reopen the dialog to review the remaining messages.",
|
||||
"i18n:govoplan-campaign.bulk_review_select_all": "Select all shown",
|
||||
"i18n:govoplan-campaign.bulk_review_select_none": "Clear selection",
|
||||
"i18n:govoplan-campaign.bulk_review_recipients": "Messages included in this decision",
|
||||
"i18n:govoplan-campaign.bulk_review_reason_required": "Common reason for accepting attachment exceptions (required)",
|
||||
"i18n:govoplan-campaign.bulk_review_reason": "Common review note",
|
||||
"i18n:govoplan-campaign.bulk_review_reason_help": "This note applies to every selected message and is stored with each message's frozen build evidence. Review the recipients and conditions before accepting.",
|
||||
"i18n:govoplan-campaign.bulk_review_accept_count": "Accept {value0} selected messages",
|
||||
"i18n:govoplan-campaign.bulk_review_saving": "Saving review decisions…",
|
||||
"i18n:govoplan-campaign.bulk_review_stale": "The build changed while this dialog was open. Close it and inspect the current build before accepting any conditions.",
|
||||
"i18n:govoplan-campaign.file_chooser_unavailable": "The Files browser is currently unavailable. Your attachment settings are unchanged. If it does not return after the module finishes loading, reload the page after saving any pending changes.",
|
||||
"i18n:govoplan-campaign.choose_available_attachment_source": "The attachment's source is no longer available. Select an available base path in this row before choosing a file or pattern.",
|
||||
"i18n:govoplan-campaign.inherit_mail_server": "Use profile server",
|
||||
"i18n:govoplan-campaign.unavailable_selected_server": "Selected server is unavailable",
|
||||
"i18n:govoplan-campaign.unavailable_selected_profile": "Selected profile is unavailable",
|
||||
"i18n:govoplan-campaign.discard_superseded": "Discard cancelled because the draft changed while reloading. Your newer changes are still unsaved.",
|
||||
"i18n:govoplan-campaign.inherit_mail_credential_when_allowed": "Use profile credentials (if allowed by policy)",
|
||||
"i18n:govoplan-campaign.unavailable_selected_credential": "Selected credential is unavailable",
|
||||
"i18n:govoplan-campaign.explicit_mail_credential_help": "Credential inheritance is configured under Mail policy → SMTP/IMAP credential inheritance. When it is disabled, choose a credential explicitly here and save. A profile default is not an explicit campaign selection; inherited policy locks remain in force.",
|
||||
"i18n:govoplan-campaign.save_cancelled": "Save cancelled; your changes are still unsaved.",
|
||||
"i18n:govoplan-campaign.saved_newer_changes_pending": "Submitted changes saved; newer changes are still unsaved.",
|
||||
"i18n:govoplan-campaign.saved_refresh_failed": "The campaign was saved, but the follow-up refresh failed. Reload when available; do not repeat the save to recover the refresh.",
|
||||
"i18n:govoplan-campaign.attachment_checks_await_mail_migration": "Current attachment checks are awaiting Mail migration and have not been evaluated. Migrate to an authorized Mail profile, then refresh the attachment preview and validate again.",
|
||||
"i18n:govoplan-campaign.legacy_mail_migration_required": "This older version still contains campaign-local mail settings. Content and archive corrections can be saved while the selected Mail references remain unchanged; legacy transport stays stored and blocked from delivery. Before validating, building, or sending, open Mail settings, select an authorized Mail profile, and explicitly save the migration. Unchanged ZIP settings do not block this migration. Existing review records remain available; credentials never reach the browser. Locked versions must first be unlocked or copied using the available version action.",
|
||||
"i18n:govoplan-campaign.open_mail_settings": "Open Mail settings",
|
||||
"i18n:govoplan-campaign.migrate_selected_mail_profile": "Migrate to selected Mail profile",
|
||||
"i18n:govoplan-campaign.migrating_mail_profile": "Migrating Mail settings…",
|
||||
"i18n:govoplan-campaign.collaboration": "Collaboration",
|
||||
"i18n:govoplan-campaign.collaboration_description": "Bounded human discussion linked to stable Campaign evidence.",
|
||||
"i18n:govoplan-campaign.loading_collaboration": "Loading campaign collaboration…",
|
||||
@@ -66,7 +236,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Where to go",
|
||||
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Critical blockers",
|
||||
"i18n:govoplan-campaign.remaining.cc632b5e": "Remaining",
|
||||
"i18n:govoplan-campaign.individual_review.402783bf": "Individual review",
|
||||
"i18n:govoplan-campaign.individual_review.402783bf": "Message decisions",
|
||||
"i18n:govoplan-campaign.group_review.a809a9d9": "Group review",
|
||||
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blocking validation issue(s) prevent building.",
|
||||
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Correct the affected campaign data, then validate again.",
|
||||
@@ -81,11 +251,11 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Correct the affected recipient, template, mail, or attachment data and rebuild.",
|
||||
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Sender & Recipients, Template, Mail settings, or Files",
|
||||
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} review decision(s) remain ({value1} individual, {value2} group).",
|
||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Open every critical message and record a decision; then explicitly accept any non-critical group.",
|
||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Review the affected messages individually, or select matching conditions with ‘Accept similar review conditions’. Then complete the review to acknowledge any remaining non-critical warnings.",
|
||||
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Review & Send - Built messages",
|
||||
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} built message warning(s) need acknowledgement.",
|
||||
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Must be corrected before delivery.",
|
||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Requires an individual decision.",
|
||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "These messages still need a recorded decision.",
|
||||
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "May be accepted together after critical review is complete.",
|
||||
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "Already acknowledged in the completed review.",
|
||||
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "All required review decisions are complete.",
|
||||
@@ -172,7 +342,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Keep delivery work",
|
||||
"i18n:govoplan-campaign.limit_source.bd933adb": "Limit source",
|
||||
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Loading the effective delivery policy…",
|
||||
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "No real delivery mode is currently available ({value0}).",
|
||||
"i18n:govoplan-campaign.value0_background_delivery_is_not_enabled_on_thi.b53162c5": "{value0} Background delivery is not enabled on this instance. An administrator can adjust the ‘Send now’ limit under Administration → SYSTEM → Campaign delivery (within the deployment ceiling), or configure background workers for longer-running campaigns.",
|
||||
"i18n:govoplan-campaign.not_available.d1a17af1": "Not available",
|
||||
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Pause queued work",
|
||||
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "Paused {value0} queued message(s).",
|
||||
@@ -192,11 +362,10 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "Send now is available for {value0} eligible message(s), within the effective limit of {value1}. {value2}",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "Send now is unavailable: {value0}.",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "Send now is unavailable ({value0}). Queue for workers remains available.",
|
||||
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Synchronous limit",
|
||||
"i18n:govoplan-campaign.send_now_message_limit.7521d73d": "‘Send now’ message limit",
|
||||
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "the built run has no eligible message",
|
||||
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "the configured maximum",
|
||||
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "the delivery policy configuration is invalid",
|
||||
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "the exact built run exceeds the effective limit for recipient jobs",
|
||||
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "The worker queue is also available.",
|
||||
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "This cancels queued or paused messages that have not crossed the SMTP boundary. Accepted, active, or outcome-unknown messages remain protected for audit and reconciliation.",
|
||||
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "This commits {value0} eligible message(s) from version {value1} to durable worker jobs. You may leave this page and return to the same progress and recovery state.",
|
||||
@@ -1388,10 +1557,186 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.zip_archive_for_value.3dfaf812": "ZIP archive for {value0}",
|
||||
"i18n:govoplan-campaign.zip_archive.5a2430dd": "ZIP archive",
|
||||
"i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d": "ZIP attachments are disabled.",
|
||||
"i18n:govoplan-campaign.archive_policy_enable_guidance": "To permit Legacy ZipCrypto, a policy administrator must enable it under Administration → SYSTEM → Campaign archive encryption and save. Tenant and owner policies may still restrict it. Then select Legacy ZipCrypto under Attachments → ZIP attachments and record the weak-encryption acknowledgement and an operational reason of at least 10 characters. No policy or campaign change sends mail.",
|
||||
"i18n:govoplan-campaign.archive_policy_permission_missing": "Your account also needs the dedicated campaigns:archive:use_legacy_zipcrypto permission. Ask an administrator to grant it if this compatibility exception is authorized.",
|
||||
"i18n:govoplan-campaign.archive_policy_module_missing": "The Policy module is not available. AES remains the safe default; Legacy ZipCrypto cannot be enabled here.",
|
||||
"i18n:govoplan-campaign.archive_policy_open_system": "Open system archive policy",
|
||||
"i18n:govoplan-campaign.archive_policy_open_tenant": "Open tenant archive policy",
|
||||
"i18n:govoplan-campaign.archive_policy_reload": "Reload archive policy",
|
||||
"i18n:govoplan-campaign.zip_attachments.6b58ed68": "ZIP attachments",
|
||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-campaign.report_claim_still_active": "Die Verarbeitung ist noch aktiv oder ihre Reservierung noch nicht abgelaufen. Eine Wiederherstellung ist nicht verfügbar; warten Sie und laden Sie den gespeicherten Status neu.",
|
||||
"i18n:govoplan-campaign.report_claim_owner_unconfirmed": "Der Server kann noch nicht bestätigen, dass der vorherige Prozess beendet wurde. Zum Schutz vor doppeltem Versand bleibt die Wiederherstellung gesperrt; lassen Sie Prozess und Reservierung administrativ prüfen.",
|
||||
"i18n:govoplan-campaign.report_recipients_to": "An",
|
||||
"i18n:govoplan-campaign.report_recipients_cc": "Cc",
|
||||
"i18n:govoplan-campaign.report_recipients_bcc": "Bcc",
|
||||
"i18n:govoplan-campaign.report_retry_now": "Jetzt erneut versuchen",
|
||||
"i18n:govoplan-campaign.report_send_unattempted_now": "Noch nicht versuchte Nachricht jetzt senden",
|
||||
"i18n:govoplan-campaign.report_retry_page_now": "Fehlgeschlagene Nachrichten dieser Seite jetzt erneut versuchen ({value0})",
|
||||
"i18n:govoplan-campaign.report_send_page_now": "Noch nicht versuchte Nachrichten dieser Seite jetzt senden ({value0})",
|
||||
"i18n:govoplan-campaign.report_queue_retry_workers": "Vorübergehende Fehler für Hintergrundverarbeitung einreihen",
|
||||
"i18n:govoplan-campaign.report_queue_unattempted_workers": "Noch nicht versuchte Nachrichten für Hintergrundverarbeitung einreihen",
|
||||
"i18n:govoplan-campaign.report_workers_unavailable": "Hintergrundverarbeitung ist deaktiviert. Verwenden Sie die ausdrücklichen „jetzt“-Aktionen zur synchronen Fortsetzung.",
|
||||
"i18n:govoplan-campaign.report_inline_scope_help": "„Jetzt“-Aktionen betreffen nur die angegebenen Aufträge der aktuell gefilterten Seite und beachten die konfigurierte synchrone Grenze. Bereits angenommene oder ungewisse Zustellungen werden niemals automatisch erneut gesendet.",
|
||||
"i18n:govoplan-campaign.report_inline_result": "Versandanfrage abgeschlossen: {value0} versucht, {value1} angenommen, {value2} fehlgeschlagen, {value3} ungewiss, {value4} verbleibend. Prüfen Sie vor dem Fortsetzen die aktualisierten Versandzustände.",
|
||||
"i18n:govoplan-campaign.report_acknowledged_refresh_failed": "Die Aktion wurde bestätigt, aber die Anzeige konnte nicht aktualisiert werden. Laden Sie den Bericht neu; wiederholen Sie die Aktion nicht ungeprüft.",
|
||||
"i18n:govoplan-campaign.report_reconcile_title": "Nachgewiesenes Versandergebnis erfassen",
|
||||
"i18n:govoplan-campaign.report_recover_claim_title": "Unterbrochene Verarbeitung auflösen",
|
||||
"i18n:govoplan-campaign.report_recover_claim_confirm": "Ergebnis zur Klärung markieren",
|
||||
"i18n:govoplan-campaign.report_recover_claim_help": "Der Server hat einen beendeten Verarbeitungsprozess und eine abgelaufene Reservierung festgestellt. Dokumentieren Sie den Nachweis, um diesen Versuch als ungewiss zu markieren. Dadurch wird nichts gesendet und kein Versanderfolg angenommen; klären Sie diesen gesondert, bevor Sie ein Ergebnis erfassen.",
|
||||
"i18n:govoplan-campaign.report_evidence_note": "Nachweis / Begründung",
|
||||
"i18n:govoplan-campaign.report_recording_evidence": "Nachweis wird gespeichert…",
|
||||
"i18n:govoplan-campaign.report_record_accepted": "SMTP-Annahme bestätigen",
|
||||
"i18n:govoplan-campaign.report_record_not_sent": "Nachricht als nicht gesendet erfassen",
|
||||
"i18n:govoplan-campaign.report_accepted_evidence_help": "Bestätigen Sie erst nach Prüfung der Annahme oder der gespeicherten IMAP-Kopie anhand der Mailserver-Protokolle oder des Postfachs. Beschreiben Sie den Nachweis. Das Erfassen sendet die Nachricht nicht erneut.",
|
||||
"i18n:govoplan-campaign.report_not_sent_evidence_help": "Bestätigen Sie nur mit einem Nachweis, dass die Nachricht nicht angenommen oder die IMAP-Kopie nicht gespeichert wurde. Eine falsche Entscheidung kann ein Duplikat ermöglichen. Ein erneuter Versuch bleibt eine gesonderte ausdrückliche Aktion.",
|
||||
"i18n:govoplan-campaign.report_claim_recovered": "Die unterbrochene Verarbeitung ist jetzt als ungewiss markiert. Prüfen Sie die Nachweise des Anbieters und erfassen Sie das bestätigte Ergebnis gesondert.",
|
||||
"i18n:govoplan-campaign.report_evidence_recorded": "Das nachgewiesene Ergebnis wurde erfasst. Ein erneuter Versuch bleibt eine gesonderte ausdrückliche Aktion.",
|
||||
"i18n:govoplan-campaign.report_recover_smtp_claim": "Unterbrochene SMTP-Verarbeitung auflösen",
|
||||
"i18n:govoplan-campaign.report_recover_imap_claim": "Unterbrochene IMAP-Verarbeitung auflösen",
|
||||
"i18n:govoplan-campaign.report_record_imap_appended": "IMAP-Kopie als gespeichert erfassen",
|
||||
"i18n:govoplan-campaign.report_record_imap_not_appended": "IMAP-Kopie als nicht gespeichert erfassen",
|
||||
"i18n:govoplan-campaign.delivery_policy_admin": "Campaign-Versand",
|
||||
"i18n:govoplan-campaign.configure_sending_limit": "Versandlimit konfigurieren",
|
||||
"i18n:govoplan-campaign.delivery_progress_smtp": "Nachrichten werden gesendet",
|
||||
"i18n:govoplan-campaign.delivery_status_not_queued": "Nicht eingereiht",
|
||||
"i18n:govoplan-campaign.delivery_status_skipped": "Ausgeschlossen",
|
||||
"i18n:govoplan-campaign.delivery_status_queued": "Eingereiht",
|
||||
"i18n:govoplan-campaign.delivery_status_claimed": "Übernommen",
|
||||
"i18n:govoplan-campaign.delivery_status_sending": "Wird gesendet",
|
||||
"i18n:govoplan-campaign.delivery_status_smtp_accepted": "Von SMTP angenommen",
|
||||
"i18n:govoplan-campaign.delivery_status_postbox_accepted": "Vom Postfach angenommen",
|
||||
"i18n:govoplan-campaign.delivery_status_print_accepted": "Vom Druck angenommen",
|
||||
"i18n:govoplan-campaign.delivery_status_delivered": "Zugestellt",
|
||||
"i18n:govoplan-campaign.delivery_status_partially_accepted": "Teilweise angenommen",
|
||||
"i18n:govoplan-campaign.delivery_status_sent": "Gesendet",
|
||||
"i18n:govoplan-campaign.delivery_status_outcome_unknown": "Ergebnis ungewiss",
|
||||
"i18n:govoplan-campaign.delivery_status_failed_temporary": "Vorübergehend fehlgeschlagen",
|
||||
"i18n:govoplan-campaign.delivery_status_failed_permanent": "Dauerhaft fehlgeschlagen",
|
||||
"i18n:govoplan-campaign.delivery_status_cancelled": "Abgebrochen",
|
||||
"i18n:govoplan-campaign.delivery_status_not_requested": "Nicht angefordert",
|
||||
"i18n:govoplan-campaign.delivery_status_pending": "Ausstehend",
|
||||
"i18n:govoplan-campaign.delivery_status_appending": "Wird nach Gesendet kopiert",
|
||||
"i18n:govoplan-campaign.delivery_status_appended": "Nach Gesendet kopiert",
|
||||
"i18n:govoplan-campaign.delivery_status_failed": "Fehlgeschlagen",
|
||||
"i18n:govoplan-campaign.delivery_status_ready": "Bereit",
|
||||
"i18n:govoplan-campaign.delivery_status_accepted": "Angenommen",
|
||||
"i18n:govoplan-campaign.delivery_status_delivering": "Wird zugestellt",
|
||||
"i18n:govoplan-campaign.delivery_status_accepted_vacant": "Angenommen (unbesetzt)",
|
||||
"i18n:govoplan-campaign.delivery_status_rejected_temporary": "Vorübergehend abgewiesen",
|
||||
"i18n:govoplan-campaign.delivery_status_rejected_permanent": "Dauerhaft abgewiesen",
|
||||
"i18n:govoplan-campaign.delivery_progress_imap": "Nachrichten werden nach Gesendet kopiert",
|
||||
"i18n:govoplan-campaign.delivery_progress_processed": "{value0}/{value1} verarbeitet",
|
||||
"i18n:govoplan-campaign.delivery_progress_loading": "Gespeicherten Fortschritt laden…",
|
||||
"i18n:govoplan-campaign.delivery_progress_running": "Die Verarbeitung läuft. Nur diese gespeicherten Zähler werden aktualisiert; die Kampagnenseite bleibt unverändert.",
|
||||
"i18n:govoplan-campaign.delivery_progress_finished": "Die Anfrage ist abgeschlossen. Prüfen Sie unten, ob Arbeit offen ist oder Probleme aufgetreten sind.",
|
||||
"i18n:govoplan-campaign.delivery_progress_interrupted": "Die Verbindung wurde beendet; die Verarbeitung kann noch laufen. Die gespeicherten Zähler werden weiter aktualisiert. Senden oder kopieren Sie nicht erneut, bevor das Ergebnis geklärt ist.",
|
||||
"i18n:govoplan-campaign.delivery_progress_stale": "Der Fortschritt konnte nicht aktualisiert werden. Die letzten Zähler bleiben erhalten; die Abfrage wird wiederholt. Das bedeutet nicht, dass die Verarbeitung fehlgeschlagen ist.",
|
||||
"i18n:govoplan-campaign.delivery_progress_accepted": "Von SMTP angenommen",
|
||||
"i18n:govoplan-campaign.delivery_progress_appended": "Nach Gesendet kopiert",
|
||||
"i18n:govoplan-campaign.delivery_progress_active": "In Bearbeitung",
|
||||
"i18n:govoplan-campaign.delivery_progress_pending": "Ausstehend",
|
||||
"i18n:govoplan-campaign.delivery_progress_failed": "Fehlgeschlagen",
|
||||
"i18n:govoplan-campaign.delivery_progress_unknown": "Ergebnis ungewiss",
|
||||
"i18n:govoplan-campaign.delivery_progress_excluded": "Ausgeschlossen",
|
||||
"i18n:govoplan-campaign.delivery_progress_paused": "Pausiert",
|
||||
"i18n:govoplan-campaign.delivery_progress_cancelled": "Abgebrochen",
|
||||
"i18n:govoplan-campaign.delivery_progress_updated": "Zuletzt geprüft",
|
||||
"i18n:govoplan-campaign.delivery_progress_scope": "Die Zähler gelten für diese Kampagnenversion. Verarbeitet umfasst erfolgreiche, fehlgeschlagene, ungewisse und abgebrochene Ergebnisse; ausgeschlossene Nachrichten gehören nicht zur Gesamtzahl. Laufende Nachrichten werden getrennt gezählt.",
|
||||
"i18n:govoplan-campaign.delivery_progress_refresh_failed": "Versanddetails konnten nicht aktualisiert werden. Gespeicherte Ergebnisse bleiben erhalten; dies bedeutet keine fehlgeschlagene Verarbeitung. Aktualisieren Sie die Anzeige mit Neu laden, statt die Aktion zu wiederholen.",
|
||||
"i18n:govoplan-campaign.delivery_smtp_finished_summary": "Versand abgeschlossen. Von SMTP angenommen: {value0} Nachrichten, fehlgeschlagen: {value1}, Ergebnis ungewiss: {value2}, pausiert: {value3}.",
|
||||
"i18n:govoplan-campaign.delivery_imap_finished_summary": "IMAP-Kopie hat {value0} Aufträge verarbeitet: kopiert {value1}, fehlgeschlagen {value2}.",
|
||||
"i18n:govoplan-campaign.delivery_imap_queued_summary": "{value0} ausstehende IMAP-Kopien eingereiht.",
|
||||
"i18n:govoplan-campaign.attachment_link_before_lock": "Verknüpfen Sie alle benötigten Dateien, bevor Sie die Kampagne sperren. Nach dem Sperren kann die Dateiauswahl nicht mehr geändert werden.",
|
||||
"i18n:govoplan-campaign.attachment_link_locked_warning": "Diese Version ist gesperrt: Dateien können nicht verknüpft werden. Nicht verknüpfte Dateien gehören nicht zum eingefrorenen Anhangsbestand. Verknüpfen Sie die Dateien in einer bearbeitbaren Version und validieren, bauen und prüfen Sie diese vor dem Senden.",
|
||||
"i18n:govoplan-campaign.attachment_link_loading_reason": "Dateitreffer werden noch geladen. Warten Sie vor dem Verknüpfen oder Sperren.",
|
||||
"i18n:govoplan-campaign.attachment_link_failed_reason": "Dateitreffer konnten nicht geprüft werden. Aktualisieren Sie die Anhangsvorschau vor dem Sperren.",
|
||||
"i18n:govoplan-campaign.attachment_link_lock_failed": "Das Sperren wurde gestoppt, weil die aktuelle Dateiverknüpfungsvorschau nicht geprüft werden konnte. Aktualisieren Sie diese und versuchen Sie es erneut.",
|
||||
"i18n:govoplan-campaign.attachment_link_readonly_reason": "Diese Version ist schreibgeschützt; verknüpfen Sie Dateien in einer bearbeitbaren Version.",
|
||||
"i18n:govoplan-campaign.attachment_link_all_done": "Alle gefundenen Dateien sind bereits verknüpft.",
|
||||
"i18n:govoplan-campaign.system_delivery_policy_source": "Systemweite Versandeinstellungen",
|
||||
"i18n:govoplan-campaign.message_state_filter_help": "Der Status berücksichtigt gespeicherte Entscheidungen für diesen Nachrichtenstand: Bereit, Prüfung erforderlich, Blockiert oder Ausgeschlossen. Die Schaltfläche für Prüfkandidaten setzt denselben Statusfilter; die Erklärungsspalte zeigt den jeweiligen Grund.",
|
||||
"i18n:govoplan-campaign.validation_grouping_help": "Zusammengehörige Ursachen aus Anhangsregeln und ihre Richtlinienfolge werden gemeinsam angezeigt. Die technischen Details enthalten jeden ursprünglichen Prüfeintrag; die Seitenzählung bezieht sich auf zusammengefasste Bedingungen.",
|
||||
"i18n:govoplan-campaign.validation_policy_outcome": "Richtlinienfolge:",
|
||||
"i18n:govoplan-campaign.validation_technical_evidence": "Technische Details",
|
||||
"i18n:govoplan-campaign.repeated_file_uses": "{value0} Verwendungen",
|
||||
"i18n:govoplan-campaign.message_state_heading": "Nachrichtenstatus",
|
||||
"i18n:govoplan-campaign.message_state_ready": "Bereit",
|
||||
"i18n:govoplan-campaign.message_state_needs_review": "Prüfung erforderlich",
|
||||
"i18n:govoplan-campaign.message_state_blocked_label": "Blockiert",
|
||||
"i18n:govoplan-campaign.message_state_excluded_label": "Ausgeschlossen",
|
||||
"i18n:govoplan-campaign.message_state_explanation_heading": "Begründung",
|
||||
"i18n:govoplan-campaign.message_state_automatic": "Build erfolgreich; keine manuelle Entscheidung erforderlich.",
|
||||
"i18n:govoplan-campaign.message_state_individual_pending": "Der Build erfordert eine ausdrückliche Prüfentscheidung für diese Nachricht.",
|
||||
"i18n:govoplan-campaign.message_state_individually_accepted": "Build-Ausnahme mit gespeicherter Prüfentscheidung bestätigt.",
|
||||
"i18n:govoplan-campaign.message_state_warning_pending": "Nicht blockierende Build-Warnung; mit Prüfung abschließen bestätigen.",
|
||||
"i18n:govoplan-campaign.message_state_warning_accepted": "Build-Warnung beim Abschluss der Prüfung bestätigt.",
|
||||
"i18n:govoplan-campaign.message_state_excluded": "Durch konfigurierte Richtlinie ausgeschlossen; diese Nachricht wird nicht gesendet.",
|
||||
"i18n:govoplan-campaign.message_state_inactive": "Der Empfänger ist inaktiv; diese Nachricht wird nicht gesendet.",
|
||||
"i18n:govoplan-campaign.message_state_build_failed": "Die Nachricht konnte nicht gebaut werden. Quelle korrigieren und erneut bauen.",
|
||||
"i18n:govoplan-campaign.message_state_not_built": "Es liegt kein abgeschlossener Nachrichten-Build vor.",
|
||||
"i18n:govoplan-campaign.message_state_blocked": "Eine blockierende Bedingung muss korrigiert und neu gebaut werden; sie kann nicht bestätigt werden.",
|
||||
"i18n:govoplan-campaign.message_state_unknown": "Der Build-Status ist nicht verfügbar. Vor dem Fortfahren neu laden oder erneut bauen.",
|
||||
"i18n:govoplan-campaign.synchronous_limit_explanation": "Diese Kampagne hat {value0} versandfähige Nachrichten; „Jetzt senden“ erlaubt höchstens {value1} pro Anfrage. Größere Kampagnen verwenden den Hintergrundversand.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_build_first": "Erstellen Sie die Nachrichten, um ihre genauen Anhangsanforderungen zu prüfen.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_loading": "Prüfnachweise für den aktuellen Nachrichtenstand werden geladen.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_blocked": "Der aktuelle Nachrichtenstand enthält noch Blocker. Beheben Sie diese vor dem Versand; Prüfentscheidungen können Blocker nicht übergehen.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_satisfied": "Die Anhangsbedingungen sind erfüllt oder für diesen Nachrichtenstand bereits bestätigt. Eine weitere Anhangsfreigabe ist nicht erforderlich.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_review": "Anhangsausnahmen benötigen noch eine Entscheidung unter „Erstellen und prüfen“. Gespeicherte Entscheidungen gelten hier automatisch weiter.",
|
||||
"i18n:govoplan-campaign.attachment_preflight_pending": "Es werden keine fehlenden oder mehrdeutigen Treffer gemeldet. Schließen Sie vor dem Versand die verbleibende Nachrichtenprüfung ab.",
|
||||
"i18n:govoplan-campaign.review_save_pending": "Eine Prüfentscheidung wird gespeichert. Bitte warten Sie.",
|
||||
"i18n:govoplan-campaign.review_build_changed": "Die erstellten Nachrichten wurden geändert. Laden Sie die Prüfung vor einer weiteren Entscheidung neu; bisherige Freigaben gelten nicht für neue Nachrichten.",
|
||||
"i18n:govoplan-campaign.review_decision_saved": "Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.",
|
||||
"i18n:govoplan-campaign.review_reason_required": "Begründen Sie, warum ohne diese Anlagen fortgefahren werden darf.",
|
||||
"i18n:govoplan-campaign.review_reason_label": "Warum ist das in Ordnung?",
|
||||
"i18n:govoplan-campaign.review_note_label": "Prüfvermerk (optional)",
|
||||
"i18n:govoplan-campaign.review_reason_help": "Wird beim Akzeptieren mit dieser Nachricht gespeichert. Danach öffnet sich automatisch die nächste Nachricht; der Fortschritt bleibt auch vor Abschluss der gesamten Prüfung erhalten.",
|
||||
"i18n:govoplan-campaign.review_saving_decision": "Entscheidung wird gespeichert…",
|
||||
"i18n:govoplan-campaign.review_accept_next": "Akzeptieren und weiter",
|
||||
"i18n:govoplan-campaign.bulk_review_title": "Gleichartige Prüfbedingungen bestätigen",
|
||||
"i18n:govoplan-campaign.bulk_review_missing_required": "Erforderlicher Anhang wurde nicht gefunden",
|
||||
"i18n:govoplan-campaign.bulk_review_missing_optional": "Optionaler Anhang wurde nicht gefunden",
|
||||
"i18n:govoplan-campaign.bulk_review_no_attachments": "Nachricht enthält keine Anhänge",
|
||||
"i18n:govoplan-campaign.bulk_review_multiple_matches": "Mehrere Dateien passen zu einer Einzeldatei-Regel",
|
||||
"i18n:govoplan-campaign.bulk_review_shared_attachment": "Derselbe Anhang wird für mehrere Empfänger verwendet",
|
||||
"i18n:govoplan-campaign.bulk_review_attachment_exception": "Anhangsregel erfordert eine Ausnahme",
|
||||
"i18n:govoplan-campaign.bulk_review_unused_files": "Einigen Anhangsdateien ist kein Empfänger zugeordnet",
|
||||
"i18n:govoplan-campaign.bulk_review_missing_address": "E-Mail-Adresse des Empfängers fehlt",
|
||||
"i18n:govoplan-campaign.bulk_review_technical_codes": "Technische Problemcodes",
|
||||
"i18n:govoplan-campaign.bulk_review_scope": "Wählen Sie eine vom Server geprüfte Problemkategorie unter den aktuell geladenen passenden Nachrichten. Bestätigt werden ausschließlich die hier ausgewählten Empfänger. Blocker, bereits geprüfte Nachrichten, beabsichtigte richtlinienbedingte Ausschlüsse und erlaubte Anhangsregeln ohne Treffer sind nicht enthalten. Dies speichert Prüfnachweise; es sendet keine Nachrichten und schließt die Prüfung nicht ab.",
|
||||
"i18n:govoplan-campaign.bulk_review_category": "Kategorie der Prüfbedingungen",
|
||||
"i18n:govoplan-campaign.bulk_review_other_category": "Andere Prüfbedingungen",
|
||||
"i18n:govoplan-campaign.bulk_review_empty": "Keine passenden Nachrichten sind für eine gemeinsame Prüfentscheidung geeignet.",
|
||||
"i18n:govoplan-campaign.bulk_review_subset": "Angezeigt werden {value0} von {value1} passenden ungeprüften Nachrichten dieser Kategorie; {value2} ausgewählt.",
|
||||
"i18n:govoplan-campaign.bulk_review_limit": "Eine Entscheidung umfasst höchstens 200 Nachrichten. Öffnen Sie den Dialog nach dem Speichern dieser Auswahl erneut, um die verbleibenden Nachrichten zu prüfen.",
|
||||
"i18n:govoplan-campaign.bulk_review_select_all": "Alle angezeigten auswählen",
|
||||
"i18n:govoplan-campaign.bulk_review_select_none": "Auswahl aufheben",
|
||||
"i18n:govoplan-campaign.bulk_review_recipients": "Nachrichten dieser Entscheidung",
|
||||
"i18n:govoplan-campaign.bulk_review_reason_required": "Gemeinsame Begründung für Anhangsausnahmen (erforderlich)",
|
||||
"i18n:govoplan-campaign.bulk_review_reason": "Gemeinsamer Prüfvermerk",
|
||||
"i18n:govoplan-campaign.bulk_review_reason_help": "Dieser Vermerk gilt für jede ausgewählte Nachricht und wird mit deren eingefrorenem Build-Nachweis gespeichert. Prüfen Sie Empfänger und Bedingungen vor dem Bestätigen.",
|
||||
"i18n:govoplan-campaign.bulk_review_accept_count": "{value0} ausgewählte Nachrichten bestätigen",
|
||||
"i18n:govoplan-campaign.bulk_review_saving": "Prüfentscheidungen werden gespeichert…",
|
||||
"i18n:govoplan-campaign.bulk_review_stale": "Der Build hat sich bei geöffnetem Dialog geändert. Schließen Sie ihn und prüfen Sie den aktuellen Build, bevor Sie Bedingungen bestätigen.",
|
||||
"i18n:govoplan-campaign.file_chooser_unavailable": "Die Dateiauswahl ist derzeit nicht verfügbar. Ihre Anlageneinstellungen sind unverändert. Falls sie nach dem Laden des Moduls nicht wieder erscheint, speichern Sie ausstehende Änderungen und laden Sie die Seite neu.",
|
||||
"i18n:govoplan-campaign.choose_available_attachment_source": "Die Quelle der Anlage ist nicht mehr verfügbar. Wählen Sie in dieser Zeile einen verfügbaren Basispfad, bevor Sie eine Datei oder ein Muster auswählen.",
|
||||
"i18n:govoplan-campaign.inherit_mail_server": "Server aus dem Profil verwenden",
|
||||
"i18n:govoplan-campaign.unavailable_selected_server": "Ausgewählter Server ist nicht verfügbar",
|
||||
"i18n:govoplan-campaign.unavailable_selected_profile": "Ausgewähltes Profil ist nicht verfügbar",
|
||||
"i18n:govoplan-campaign.discard_superseded": "Verwerfen abgebrochen, weil der Entwurf während des Neuladens geändert wurde. Ihre neueren Änderungen sind weiterhin ungespeichert.",
|
||||
"i18n:govoplan-campaign.inherit_mail_credential_when_allowed": "Profil-Zugangsdaten verwenden (wenn die Richtlinie dies erlaubt)",
|
||||
"i18n:govoplan-campaign.unavailable_selected_credential": "Ausgewählte Zugangsdaten sind nicht verfügbar",
|
||||
"i18n:govoplan-campaign.explicit_mail_credential_help": "Die Vererbung der Zugangsdaten wird unter Mail-Richtlinie → SMTP-/IMAP-Zugangsdatenvererbung festgelegt. Ist sie ausgeschaltet, wählen Sie hier ausdrücklich Zugangsdaten aus und speichern Sie. Ein Profilstandard ist keine ausdrückliche Kampagnenauswahl; geerbte Richtliniensperren bleiben wirksam.",
|
||||
"i18n:govoplan-campaign.save_cancelled": "Speichern abgebrochen; Ihre Änderungen sind weiterhin ungespeichert.",
|
||||
"i18n:govoplan-campaign.saved_newer_changes_pending": "Übermittelte Änderungen gespeichert; neuere Änderungen sind weiterhin ungespeichert.",
|
||||
"i18n:govoplan-campaign.saved_refresh_failed": "Die Kampagne wurde gespeichert, aber das anschließende Neuladen ist fehlgeschlagen. Laden Sie erneut, sobald dies möglich ist; wiederholen Sie dafür nicht den Speichervorgang.",
|
||||
"i18n:govoplan-campaign.attachment_checks_await_mail_migration": "Die aktuellen Anhangprüfungen warten auf die Mail-Migration und wurden noch nicht ausgewertet. Stellen Sie auf ein berechtigtes Mail-Profil um, aktualisieren Sie anschließend die Anhangvorschau und validieren Sie erneut.",
|
||||
"i18n:govoplan-campaign.legacy_mail_migration_required": "Diese ältere Version enthält noch kampagneneigene E-Mail-Einstellungen. Inhalts- und Archivkorrekturen können bei unveränderten Mail-Referenzen gespeichert werden; der alte Transport bleibt gespeichert und für den Versand gesperrt. Öffnen Sie vor Validierung, Erstellung oder Versand die Mail-Einstellungen, wählen Sie ein berechtigtes Profil und speichern Sie die Migration ausdrücklich. Unveränderte ZIP-Einstellungen blockieren diese Migration nicht. Prüfprotokolle bleiben einsehbar; Zugangsdaten gelangen nie in den Browser. Gesperrte Versionen müssen zuerst über die verfügbare Versionsaktion entsperrt oder kopiert werden.",
|
||||
"i18n:govoplan-campaign.open_mail_settings": "E-Mail-Einstellungen öffnen",
|
||||
"i18n:govoplan-campaign.migrate_selected_mail_profile": "Auf ausgewähltes Mail-Profil umstellen",
|
||||
"i18n:govoplan-campaign.migrating_mail_profile": "E-Mail-Einstellungen werden umgestellt…",
|
||||
"i18n:govoplan-campaign.collaboration": "Zusammenarbeit",
|
||||
"i18n:govoplan-campaign.collaboration_description": "Begrenzte menschliche Diskussion mit Bezug auf stabile Kampagnennachweise.",
|
||||
"i18n:govoplan-campaign.loading_collaboration": "Kampagnenzusammenarbeit wird geladen…",
|
||||
@@ -1456,7 +1801,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Ziel",
|
||||
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Kritische Blockaden",
|
||||
"i18n:govoplan-campaign.remaining.cc632b5e": "Verbleibend",
|
||||
"i18n:govoplan-campaign.individual_review.402783bf": "Einzelprüfung",
|
||||
"i18n:govoplan-campaign.individual_review.402783bf": "Nachrichtenentscheidungen",
|
||||
"i18n:govoplan-campaign.group_review.a809a9d9": "Gruppenprüfung",
|
||||
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blockierende Validierungsprobleme verhindern die Erstellung.",
|
||||
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Korrigieren Sie die betroffenen Kampagnendaten und validieren Sie erneut.",
|
||||
@@ -1471,11 +1816,11 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Korrigieren Sie die betroffenen Empfänger-, Vorlagen-, Mail- oder Anhangsdaten und erstellen Sie erneut.",
|
||||
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Absender & Empfänger, Vorlage, Mail-Einstellungen oder Dateien",
|
||||
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} Prüfentscheidungen verbleiben ({value1} einzeln, {value2} als Gruppe).",
|
||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Öffnen Sie jede kritische Nachricht und dokumentieren Sie eine Entscheidung; akzeptieren Sie anschließend ausdrücklich die nichtkritische Gruppe.",
|
||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Prüfen Sie die betroffenen Nachrichten einzeln oder wählen Sie passende Bedingungen unter „Gleichartige Prüfbedingungen bestätigen“. Schließen Sie anschließend die Prüfung ab, um verbleibende nichtkritische Warnungen zu bestätigen.",
|
||||
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Prüfen & Senden - Erstellte Nachrichten",
|
||||
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} Warnungen zu erstellten Nachrichten müssen bestätigt werden.",
|
||||
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Muss vor dem Versand korrigiert werden.",
|
||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Erfordert eine Einzelentscheidung.",
|
||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Für diese Nachrichten fehlt noch eine dokumentierte Entscheidung.",
|
||||
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "Kann nach Abschluss der kritischen Prüfung gemeinsam akzeptiert werden.",
|
||||
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "In der abgeschlossenen Prüfung bereits bestätigt.",
|
||||
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "Alle erforderlichen Prüfentscheidungen sind abgeschlossen.",
|
||||
@@ -1562,7 +1907,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Versandaufträge beibehalten",
|
||||
"i18n:govoplan-campaign.limit_source.bd933adb": "Quelle des Grenzwerts",
|
||||
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Wirksame Versandrichtlinie wird geladen…",
|
||||
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "Derzeit ist kein echter Versandmodus verfügbar ({value0}).",
|
||||
"i18n:govoplan-campaign.value0_background_delivery_is_not_enabled_on_thi.b53162c5": "{value0} Der Hintergrundversand ist auf dieser Instanz nicht aktiviert. Die Administration kann unter Administration → SYSTEM → Kampagnenversand das Limit für „Jetzt senden“ innerhalb der Deployment-Obergrenze anpassen oder Hintergrund-Worker für länger laufende Kampagnen einrichten.",
|
||||
"i18n:govoplan-campaign.not_available.d1a17af1": "Nicht verfügbar",
|
||||
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Wartende Aufträge pausieren",
|
||||
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "{value0} wartende Nachricht(en) pausiert.",
|
||||
@@ -1582,11 +1927,10 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "„Jetzt senden“ ist für {value0} geeignete Nachricht(en) innerhalb des wirksamen Grenzwerts von {value1} verfügbar. {value2}",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "„Jetzt senden“ ist nicht verfügbar: {value0}.",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "„Jetzt senden“ ist nicht verfügbar ({value0}). Die Worker-Warteschlange bleibt verfügbar.",
|
||||
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Grenzwert für synchronen Versand",
|
||||
"i18n:govoplan-campaign.send_now_message_limit.7521d73d": "Nachrichtenlimit für „Jetzt senden“",
|
||||
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "der erstellte Lauf enthält keine geeignete Nachricht",
|
||||
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "das konfigurierte Maximum",
|
||||
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "die Konfiguration der Versandrichtlinie ist ungültig",
|
||||
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "der exakt erstellte Lauf überschreitet den wirksamen Grenzwert für Empfängeraufträge",
|
||||
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "Die Worker-Warteschlange ist ebenfalls verfügbar.",
|
||||
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "Dadurch werden wartende oder pausierte Nachrichten abgebrochen, die die SMTP-Grenze noch nicht überschritten haben. Angenommene, aktive oder ungeklärte Nachrichten bleiben für Audit und Abstimmung geschützt.",
|
||||
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "Dadurch werden {value0} geeignete Nachricht(en) aus Version {value1} als dauerhafte Worker-Aufträge eingereiht. Sie können diese Seite verlassen und später zum selben Fortschritts- und Wiederherstellungszustand zurückkehren.",
|
||||
@@ -1953,7 +2297,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.evidence.7ea014de": "Nachweise",
|
||||
"i18n:govoplan-campaign.exact": "exact",
|
||||
"i18n:govoplan-campaign.exclude_from_zip.a6422da1": "Exclude from ZIP",
|
||||
"i18n:govoplan-campaign.excluded.9804952b": "Excluded",
|
||||
"i18n:govoplan-campaign.excluded.9804952b": "Ausgeschlossen",
|
||||
"i18n:govoplan-campaign.excludes_the_affected_message.73be963f": "Excludes the affected message.",
|
||||
"i18n:govoplan-campaign.execution_snapshot.5a67f098": "Execution snapshot",
|
||||
"i18n:govoplan-campaign.exercise_the_delivery_path_and_verify_recipient_.12ed1928": "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers. This dev workflow can be optional before real sending.",
|
||||
@@ -2187,7 +2531,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.need_review.201a4493": "Need review",
|
||||
"i18n:govoplan-campaign.needs_attention.a126722e": "Benötigt Aufmerksamkeit",
|
||||
"i18n:govoplan-campaign.needs_linking.a0fc8341": "Needs linking",
|
||||
"i18n:govoplan-campaign.needs_review.33a506cf": "Needs review",
|
||||
"i18n:govoplan-campaign.needs_review.33a506cf": "Prüfung erforderlich",
|
||||
"i18n:govoplan-campaign.needs_setup.522ebae4": "Needs setup",
|
||||
"i18n:govoplan-campaign.new_attachment_source.563145ba": "New attachment source",
|
||||
"i18n:govoplan-campaign.new_campaign.1f0d021c": "New Campaign",
|
||||
@@ -2537,12 +2881,12 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
||||
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
||||
"i18n:govoplan-campaign.short_reminder_template_with_one_deadline_field.5f15d110": "Short reminder template with one deadline field.",
|
||||
"i18n:govoplan-campaign.show_all_messages.1c2107a1": "Show all messages",
|
||||
"i18n:govoplan-campaign.show_all_messages.1c2107a1": "Alle Nachrichten anzeigen",
|
||||
"i18n:govoplan-campaign.show_captured_mock_mailbox.019e1e79": "Show captured mock mailbox",
|
||||
"i18n:govoplan-campaign.show_content.0528d8d2": "Inhalt anzeigen",
|
||||
"i18n:govoplan-campaign.show_guided_warnings_while_editing.bc5dba85": "Show guided warnings while editing",
|
||||
"i18n:govoplan-campaign.show_header_only.24afefca": "Nur Überschrift anzeigen",
|
||||
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Show review candidates only",
|
||||
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Nur Prüfkandidaten anzeigen",
|
||||
"i18n:govoplan-campaign.showing.163d8174": "Showing",
|
||||
"i18n:govoplan-campaign.size.b7152342": "Größe",
|
||||
"i18n:govoplan-campaign.skipped.5a000ad7": "Übersprungen",
|
||||
@@ -2778,6 +3122,12 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.zip_archive_for_value.3dfaf812": "ZIP archive for {value0}",
|
||||
"i18n:govoplan-campaign.zip_archive.5a2430dd": "ZIP archive",
|
||||
"i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d": "ZIP attachments are disabled.",
|
||||
"i18n:govoplan-campaign.archive_policy_enable_guidance": "Um Legacy ZipCrypto zuzulassen, muss die Richtlinienadministration es unter Administration → SYSTEM → Campaign archive encryption aktivieren und speichern. Mandanten- und Eigentümerrichtlinien können es weiterhin einschränken. Wählen Sie anschließend Legacy ZipCrypto unter Anhänge → ZIP-Anhänge und bestätigen Sie die schwache Verschlüsselung mit einer betrieblichen Begründung von mindestens 10 Zeichen. Richtlinien- und Kampagnenänderungen versenden keine E-Mails.",
|
||||
"i18n:govoplan-campaign.archive_policy_permission_missing": "Ihr Konto benötigt außerdem die gesonderte Berechtigung campaigns:archive:use_legacy_zipcrypto. Bitten Sie die Administration um die Vergabe, wenn diese Kompatibilitätsausnahme genehmigt ist.",
|
||||
"i18n:govoplan-campaign.archive_policy_module_missing": "Das Policy-Modul ist nicht verfügbar. AES bleibt der sichere Standard; Legacy ZipCrypto kann hier nicht aktiviert werden.",
|
||||
"i18n:govoplan-campaign.archive_policy_open_system": "System-Archivrichtlinie öffnen",
|
||||
"i18n:govoplan-campaign.archive_policy_open_tenant": "Mandanten-Archivrichtlinie öffnen",
|
||||
"i18n:govoplan-campaign.archive_policy_reload": "Archivrichtlinie neu laden",
|
||||
"i18n:govoplan-campaign.zip_attachments.6b58ed68": "ZIP attachments",
|
||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Navigate, useParams } from "react-router";
|
||||
import {
|
||||
ResourceAccessBoundary,
|
||||
hasAnyScope,
|
||||
hasScope,
|
||||
type AdminSectionsUiCapability,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DashboardWidgetsUiCapability,
|
||||
@@ -19,6 +21,18 @@ import "./styles/campaign-workspace.css";
|
||||
|
||||
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
|
||||
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
||||
const CampaignDeliveryPolicyPanel = lazy(() => import("./features/admin/CampaignDeliveryPolicyPanel"));
|
||||
const campaignAdminSections: AdminSectionsUiCapability = {
|
||||
sections: (["system", "tenant"] as const).map((scope) => ({
|
||||
id: `${scope}-campaign-delivery`, moduleId: "campaigns", kind: "settings" as const,
|
||||
surfaceId: `campaigns.admin.${scope}-delivery`, label: "i18n:govoplan-campaign.delivery_policy_admin", group: scope === "system" ? "SYSTEM" : "TENANT", order: 76,
|
||||
allOf: [scope === "system" ? "system:settings:read" : "admin:policies:read"],
|
||||
render: ({ settings, auth }) => createElement(CampaignDeliveryPolicyPanel, {
|
||||
key: `${scope}:${(auth.active_tenant ?? auth.tenant).id}`, settings, scope,
|
||||
canWrite: hasScope(auth, scope === "system" ? "system:settings:write" : "admin:policies:write")
|
||||
})
|
||||
}))
|
||||
};
|
||||
|
||||
const campaignRead = ["campaigns:campaign:read"];
|
||||
const reportRead = ["campaigns:report:read"];
|
||||
@@ -91,6 +105,8 @@ export const campaignModule: PlatformWebModule = {
|
||||
optionalDependencies: ["files", "mail", "notifications", "organizations", "idm", "tasks"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{ id: "campaigns.admin.system-delivery", moduleId: "campaigns", kind: "section", label: "System Campaign delivery", order: 76 },
|
||||
{ id: "campaigns.admin.tenant-delivery", moduleId: "campaigns", kind: "section", label: "Tenant Campaign delivery", order: 76 },
|
||||
{
|
||||
id: "campaigns.page.work",
|
||||
moduleId: "campaigns",
|
||||
@@ -123,6 +139,7 @@ export const campaignModule: PlatformWebModule = {
|
||||
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
||||
uiCapabilities: {
|
||||
"admin.sections": campaignAdminSections,
|
||||
"dashboard.widgets": campaignDashboardWidgets,
|
||||
"quickAccess.tools": campaignQuickAccessTools,
|
||||
"wizard.directories": campaignWizardDirectories
|
||||
|
||||
@@ -776,9 +776,6 @@
|
||||
.attachment-rules-table td:nth-child(5) { width: 160px; }
|
||||
.attachment-rules-table th:last-child,
|
||||
.attachment-rules-table td:last-child { width: 150px; }
|
||||
.attachment-rules-table .data-grid-body-cell:last-child .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Attachment file chooser panel embedded in the shared rules editor. */
|
||||
.attachment-rules-editor {
|
||||
@@ -2244,6 +2241,20 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.campaign-report-recipient-cell,
|
||||
.campaign-report-recipient-cell > div {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recipient-outcome-cell.campaign-report-recipient-cell span {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recipient-outcome-cell strong,
|
||||
.recipient-outcome-cell span {
|
||||
min-width: 0;
|
||||
|
||||
@@ -69,6 +69,20 @@ const attachmentPreview = fs.readFileSync(
|
||||
path.join(sourceRoot, "features/campaigns/review/AttachmentLinkingPreview.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const archivePolicyNotice = fs.readFileSync(
|
||||
path.join(sourceRoot, "features/campaigns/components/CampaignArchiveEncryptionPolicyNotice.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
for (const page of ["AttachmentsDataPage.tsx", "GlobalSettingsPage.tsx"]) {
|
||||
const value = fs.readFileSync(path.join(sourceRoot, "features/campaigns", page), "utf8");
|
||||
assert.match(value, /<CampaignArchiveEncryptionPolicyNotice/, `${page} must make archive policy configuration discoverable`);
|
||||
}
|
||||
assert.match(archivePolicyNotice, /\/admin\?section=system-campaign-archive-encryption/, "Campaign must link to the actual system Legacy ZipCrypto setting");
|
||||
assert.match(archivePolicyNotice, /\/admin\?section=tenant-campaign-archive-encryption/, "Campaign must expose the tenant ceiling as well as the system switch");
|
||||
assert.match(archivePolicyNotice, /policyInstalled && hasScope\(auth, "admin:policies:read"\)/, "Policy admin actions stay conditional on module and permission availability");
|
||||
assert.match(archivePolicyNotice, /campaigns:archive:use_legacy_zipcrypto/, "The dedicated weak-encryption permission stays explicit");
|
||||
assert.match(archivePolicyNotice, /setPolicy\(UNAVAILABLE_ARCHIVE_POLICY\)/, "Reloading policy must fail closed while its current state is unknown");
|
||||
assert.match(archivePolicyNotice, /setRefresh\(\(value\) => value \+ 1\)/, "Operators must be able to reload policy after an administrator changes it");
|
||||
assert.ok(
|
||||
jsonView.includes('DismissibleAlert tone="warning" dismissible={false}'),
|
||||
"The full Campaign JSON projection must retain an explicit privacy warning",
|
||||
|
||||
@@ -56,7 +56,7 @@ assert(overviewPage.includes("drilldown={{"), "campaign overview metrics expose
|
||||
assert(overviewPage.includes("openSection(\"recipients\")"), "the recipient count opens its owning collection");
|
||||
assert(attachmentsPage.includes('revealAttachmentSection("campaign-global-attachments")'), "attachment counts reveal their underlying editor section");
|
||||
assert(attachmentsPage.includes("openRecipients"), "per-recipient attachment counts open recipient details");
|
||||
assert(reviewPage.includes("showBuiltDetails({ validation:"), "review counts apply a relevant table filter");
|
||||
assert(reviewPage.includes("messageState: 'list:[\"needs_review\",\"blocked\"]'"), "review counts apply the unified message-state table filter");
|
||||
assert(reviewPage.includes('revealReviewElement("campaign-attachment-preview")'), "attachment-result counts reveal the evidence preview");
|
||||
assert(!page.includes("{report.population.denominator_definition}"), "the known denominator contract uses a localized UI explanation");
|
||||
assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses a localized UI explanation");
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { builtMessageState } from "../src/features/campaigns/review/builtMessageState.ts";
|
||||
|
||||
const noReviews = new Set<string>();
|
||||
const row = (validation_status: string, fields: Record<string, unknown> = {}) => ({
|
||||
id: "job-1", review_key: "recipient-key", build_status: "built", validation_status, ...fields
|
||||
});
|
||||
|
||||
test("single display state distinguishes automatic and explicitly accepted readiness", () => {
|
||||
assert.deepEqual(builtMessageState(row("ready"), 0, noReviews), { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_automatic" });
|
||||
assert.deepEqual(builtMessageState(row("needs_review"), 0, noReviews), { state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_individual_pending" });
|
||||
assert.deepEqual(builtMessageState(row("needs_review"), 0, new Set(["recipient-key"])), { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_individually_accepted" });
|
||||
assert.equal(builtMessageState(row("needs_review", { reviewed: true }), 0, noReviews).state, "ready");
|
||||
});
|
||||
|
||||
test("warnings need the final acknowledgement, not a legacy view marker", () => {
|
||||
assert.deepEqual(builtMessageState(row("warning", { reviewed: true }), 0, new Set(["recipient-key"])), {
|
||||
state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_warning_pending"
|
||||
});
|
||||
assert.deepEqual(builtMessageState(row("warning"), 0, noReviews, true), {
|
||||
state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_warning_accepted"
|
||||
});
|
||||
});
|
||||
|
||||
test("hard blockers and failed builds never become ready through a review marker", () => {
|
||||
for (const message of [row("blocked", { reviewed: true }), row("needs_review", { reviewed: true, issues: [{ behavior: "block" }] }),
|
||||
row("ready", { build_status: "failed" }), row("ready", { build_status: "pending" }), row("unknown")]) {
|
||||
assert.equal(builtMessageState(message, 0, new Set(["recipient-key"]), true).state, "blocked");
|
||||
}
|
||||
});
|
||||
|
||||
test("intentionally excluded or inactive messages remain excluded, including unbuilt ones", () => {
|
||||
for (const status of ["excluded", "inactive"]) {
|
||||
assert.equal(builtMessageState(row(status, { build_status: "failed", reviewed: true }), 0, noReviews, true).state, "excluded");
|
||||
}
|
||||
});
|
||||
|
||||
test("complete review does not invent a missing individual decision", () => {
|
||||
assert.equal(builtMessageState(row("needs_review"), 0, noReviews, true).state, "needs_review");
|
||||
assert.equal(builtMessageState(row("needs_review", { entry_id: "legacy-entry", review_key: undefined }), 0, new Set(["legacy-entry"])).state, "ready");
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { BULK_MESSAGE_REVIEW_LIMIT, bulkMessageReviewGroups, bulkReviewIssueLabel } from "../src/features/campaigns/review/bulkMessageReview.ts";
|
||||
|
||||
function job(id: string, patch: Record<string, unknown> = {}) {
|
||||
return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`,
|
||||
review_decision: { eligible: true, category_key: "attachments-empty", reason_required: true, issue_codes: ["attachment_missing"] }, ...patch };
|
||||
}
|
||||
|
||||
test("bulk review trusts explicit backend eligibility, never warning status or expected outcomes", () => {
|
||||
const groups = bulkMessageReviewGroups([
|
||||
job("eligible"), job("missing", { review_decision: undefined }),
|
||||
job("denied", { review_decision: { eligible: false, category_key: "attachments-empty" } }),
|
||||
job("excluded", { validation_status: "excluded" }), job("warning", { validation_status: "warning" }),
|
||||
job("blocked", { validation_status: "blocked" }), job("unbuilt", { build_status: "failed" }),
|
||||
job("reviewed", { reviewed: true }), job("hard-issue", { issues: [{ behavior: "block" }] }),
|
||||
job("empty-category", { review_decision: { eligible: true, category_key: "" } }), job(""),
|
||||
]);
|
||||
assert.deepEqual(groups.map((group) => group.messages.map((message) => message.jobId)), [["eligible"]]);
|
||||
});
|
||||
|
||||
test("bulk grouping preserves full server category and exact stable deduplicated IDs", () => {
|
||||
const groups = bulkMessageReviewGroups([
|
||||
job("second"), job("first"), job("first"),
|
||||
job("other", { review_decision: { eligible: true, category_key: "attachments-count", reason_required: false, issue_codes: ["attachment_count", "attachment_empty", "attachment_count"] } }),
|
||||
]);
|
||||
assert.equal(groups.length, 2);
|
||||
assert.deepEqual(groups.find((group) => group.categoryKey === "attachments-empty")?.messages.map((message) => message.jobId), ["first", "second"]);
|
||||
assert.equal(groups.find((group) => group.categoryKey === "attachments-empty")?.reasonRequired, true);
|
||||
assert.deepEqual(groups.find((group) => group.categoryKey === "attachments-count")?.issueCodes, ["attachment_count", "attachment_empty"]);
|
||||
});
|
||||
|
||||
test("group totals remain truthful beyond the bounded selection size", () => {
|
||||
const groups = bulkMessageReviewGroups(Array.from({ length: 205 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)));
|
||||
assert.equal(BULK_MESSAGE_REVIEW_LIMIT, 200);
|
||||
assert.equal(groups[0].messages.length, 205);
|
||||
assert.equal(groups[0].messages.slice(0, BULK_MESSAGE_REVIEW_LIMIT).length, 200);
|
||||
});
|
||||
|
||||
test("category labels are translated descriptions rather than technical codes", () => {
|
||||
assert.equal(bulkReviewIssueLabel("missing_optional_attachment"), "i18n:govoplan-campaign.bulk_review_missing_optional");
|
||||
assert.equal(bulkReviewIssueLabel("missing_attachment_coverage"), "i18n:govoplan-campaign.bulk_review_no_attachments");
|
||||
assert.equal(bulkReviewIssueLabel("duplicate_attachment_reuse"), "i18n:govoplan-campaign.bulk_review_shared_attachment");
|
||||
assert.equal(bulkReviewIssueLabel("future_condition"), "i18n:govoplan-campaign.bulk_review_other_category");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||
const ts = require("typescript");
|
||||
|
||||
function evaluate(relativePath, imports = () => ({})) {
|
||||
const filename = fileURLToPath(new URL(relativePath, import.meta.url));
|
||||
const source = fs.readFileSync(filename, "utf8");
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const exports = {};
|
||||
vm.runInNewContext(compiled, { exports, require: imports }, { filename });
|
||||
return exports;
|
||||
}
|
||||
|
||||
const catalogue = evaluate("../src/i18n/generatedTranslations.ts");
|
||||
// Evaluate the actual module descriptor; unrelated page rendering and optional
|
||||
// capability imports are inert. The real translation catalogue is not mocked.
|
||||
const { campaignModule } = evaluate("../src/module.ts", (name) => {
|
||||
if (name === "react") return { lazy: () => () => null };
|
||||
if (name === "./i18n/generatedTranslations") return catalogue;
|
||||
return {};
|
||||
});
|
||||
const sections = campaignModule.uiCapabilities["admin.sections"].sections;
|
||||
assert.equal(sections.length, 2);
|
||||
for (const section of sections) {
|
||||
assert.equal(campaignModule.translations.en[section.label], "Campaign delivery");
|
||||
assert.equal(campaignModule.translations.de[section.label], "Campaign-Versand");
|
||||
assert.ok(campaignModule.viewSurfaces.some((surface) => surface.id === section.surfaceId));
|
||||
}
|
||||
assert.equal(sections[0].id, "system-campaign-delivery");
|
||||
assert.deepEqual(Array.from(sections[0].allOf), ["system:settings:read"]);
|
||||
assert.equal(sections[1].id, "tenant-campaign-delivery");
|
||||
assert.deepEqual(Array.from(sections[1].allOf), ["admin:policies:read"]);
|
||||
console.log("Campaign delivery administration labels resolve in the real EN/DE module catalogue.");
|
||||
@@ -1,4 +1,4 @@
|
||||
import { campaignMailProfileReferenceOnly } from "../src/features/campaigns/utils/mailProfileReference";
|
||||
import { campaignMailProfileListOptions, campaignMailProfileReferenceOnly, campaignMailReferencesUnchanged } from "../src/features/campaigns/utils/mailProfileReference";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -24,3 +24,34 @@ assert((source.server.smtp as Record<string, unknown>).password === "smtp-secret
|
||||
|
||||
const withoutProfile = campaignMailProfileReferenceOnly({ server: { smtp: { host: "legacy" } } });
|
||||
assert(Object.keys(withoutProfile.server as Record<string, unknown>).length === 0, "Legacy settings without a profile must normalize to an empty reference object.");
|
||||
|
||||
const campaignChoices = campaignMailProfileListOptions("settings", "campaign-1");
|
||||
assert(campaignChoices.campaignId === "campaign-1", "Mail settings must load profiles authorized for this campaign.");
|
||||
assert(!campaignChoices.includeInactive, "Inactive profiles cannot become campaign delivery choices.");
|
||||
const policyChoices = campaignMailProfileListOptions("policy", "campaign-1");
|
||||
assert(policyChoices.campaignId === undefined, "Policy management must not restrict its catalogue to the current campaign allowance.");
|
||||
assert(policyChoices.includeInactive, "Policy management may inspect its authorized inactive catalogue independently of delivery selection.");
|
||||
|
||||
const mixedLegacy = campaignMailProfileReferenceOnly({ server: {
|
||||
mail_profile_id: "profile-1",
|
||||
smtp_server_id: "server-smtp",
|
||||
smtp_credential_id: "credential-smtp",
|
||||
imap_server_id: "server-imap",
|
||||
imap_credential_id: "credential-imap",
|
||||
smtp: { password: "never-resubmit" },
|
||||
imap: { password: "never-resubmit" },
|
||||
credentials: { password: "never-resubmit" },
|
||||
inherit_smtp_credentials: true,
|
||||
inherit_imap_credentials: true
|
||||
} });
|
||||
assert(Object.keys(mixedLegacy.server as Record<string, unknown>).length === 5, "Migration must preserve all five stable Mail references and no legacy transport fields.");
|
||||
assert(!JSON.stringify(mixedLegacy).includes("never-resubmit"), "Migration must not resubmit transport secrets.");
|
||||
|
||||
assert(campaignMailReferencesUnchanged(normalized, {
|
||||
...normalized, attachments: { zip: { archives: [{ method: "aes" }] } }
|
||||
}), "Unchanged public Mail references allow an independent archive policy repair.");
|
||||
assert(campaignMailReferencesUnchanged({ server: {} }, { server: {}, template: { text: "Repair" } }), "A legacy version without a selected profile can still save unrelated repairs.");
|
||||
assert(!campaignMailReferencesUnchanged(normalized, { server: { mail_profile_id: "profile-2" } }), "Selecting another Mail profile requires explicit migration.");
|
||||
assert(!campaignMailReferencesUnchanged(normalized, { server: {} }), "Removing a Mail reference is not an unchanged save.");
|
||||
assert(!campaignMailReferencesUnchanged(normalized, { server: { ...server, smtp: {} } }), "Inline transport cannot be submitted as an unrelated repair.");
|
||||
assert(!campaignMailReferencesUnchanged(mixedLegacy, { server: { ...(mixedLegacy.server as object), smtp_credential_id: "other" } }), "Changing a Mail credential requires explicit migration.");
|
||||
|
||||
@@ -41,6 +41,8 @@ assert(!params.toString().includes("unsupported") && !params.toString().includes
|
||||
|
||||
const shortcutFilters = {
|
||||
smtp_accepted: { send: 'list:["smtp_accepted","sent"]' },
|
||||
smtp_active: { send: 'list:["claimed","sending"]' },
|
||||
smtp_queued: { send: 'list:["queued"]' },
|
||||
failed: { send: 'list:["failed_temporary","failed_permanent"]' },
|
||||
outcome_unknown: { send: 'list:["outcome_unknown"]' },
|
||||
not_attempted: { send: 'list:["not_queued"]' },
|
||||
@@ -48,6 +50,9 @@ const shortcutFilters = {
|
||||
cancelled: { send: 'list:["cancelled"]' },
|
||||
imap_appended: { imap: 'list:["appended"]' },
|
||||
imap_failed: { imap: 'list:["failed"]' },
|
||||
imap_pending: { imap: 'list:["pending"]' },
|
||||
imap_active: { imap: 'list:["appending"]' },
|
||||
imap_unknown: { imap: 'list:["outcome_unknown"]' },
|
||||
imap_skipped: { imap: 'list:["skipped"]' }
|
||||
} as const;
|
||||
|
||||
@@ -80,9 +85,10 @@ assert(reportSource.includes('setQuery("");') && reportSource.includes('setAppli
|
||||
assert(reportSource.includes("deliveryOutcomeShortcuts.map") && reportSource.includes("imapOutcomeShortcuts.map"), "top-level delivery counts use one coherent shortcut model");
|
||||
assert(reportSource.includes('<Button type="button" variant={active ? "primary" : "ghost"}'), "count shortcuts render the central Button directly");
|
||||
assert(reportSource.includes('aria-pressed={active}'), "count shortcuts expose their selected state accessibly");
|
||||
assert(reportSource.includes('"skipped",\n"queued"'), "SMTP skipped is a first-class report filter option");
|
||||
const deliveryStatusSource = readFileSync("src/features/campaigns/utils/deliveryStatusOptions.ts", "utf8");
|
||||
assert(deliveryStatusSource.includes('"not_queued", "skipped", "queued"'), "SMTP skipped is a first-class shared report filter option");
|
||||
assert(reportSource.includes("i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00"), "the report explains excluded transport semantics through the bilingual catalog");
|
||||
assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7"'), "the new skipped filter and status badges use the localized label");
|
||||
assert(reportSource.includes('import { SEND_STATUS_OPTIONS, IMAP_STATUS_OPTIONS, deliveryStatusLabel }'), "Report reuses shared localized delivery states");
|
||||
assert(reportSource.includes("cards?.skipped ?? jobs.counts.send?.skipped"), "SMTP skipped has a separate report count");
|
||||
assert(reportSource.includes("cards?.imap_skipped ?? jobs.counts.imap?.skipped"), "IMAP skipped has a separate report count");
|
||||
assert(!reportSource.includes("setPage((value) => Math.max(1, value - 1))"), "the one-off report pager is removed in favor of the central DataGrid pager");
|
||||
|
||||
@@ -7,6 +7,9 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
|
||||
const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
|
||||
const attachmentsPage = readFileSync(resolve(here, "../src/features/campaigns/AttachmentsDataPage.tsx"), "utf8");
|
||||
const mailPage = readFileSync(resolve(here, "../src/features/campaigns/MailSettingsPage.tsx"), "utf8");
|
||||
const migrationNotice = readFileSync(resolve(here, "../src/features/campaigns/components/LegacyMailMigrationNotice.tsx"), "utf8");
|
||||
const draftEditor = readFileSync(resolve(here, "../src/features/campaigns/hooks/useCampaignDraftEditor.ts"), "utf8");
|
||||
|
||||
assert.match(guidance, /<ActionBlockerHint/);
|
||||
assert.match(guidance, /<GuidedReviewList/);
|
||||
@@ -17,9 +20,14 @@ assert.match(guidance, /target: destinationLabel/);
|
||||
assert.match(guidance, /documentation=\{\{ topicId: documentationTopicId \}\}/);
|
||||
assert.match(guidance, /campaigns\.workflow\.prepare-validate-and-build/);
|
||||
assert.match(guidance, /campaigns\.workflow\.complete-review/);
|
||||
assert.doesNotMatch(guidance, /showUnacknowledgedWarning|buildWarnings/,
|
||||
"aggregate warnings must not create extra review requirements for expected or already accepted outcomes");
|
||||
assert.match(page, /calculateBuildReviewProgress/);
|
||||
assert.match(page, /<MetricCard[^>]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
|
||||
assert.match(page, /showBuiltDetails\(\{ reviewed: 'list:\["no"\]' \}\)/);
|
||||
assert.match(page, /showBuiltDetails\(\{ messageState: 'list:\["needs_review"\]' \}\)/);
|
||||
assert.doesNotMatch(page, /showAllReviewJobs|visibleValidationIssues|attachmentReuseFindings\.slice\(0, 10\)/);
|
||||
assert.match(page, /<ValidationDetails/);
|
||||
assert.match(page, /<RepeatedFilesDetails/);
|
||||
assert.match(page, /data-residual-file-policy/);
|
||||
assert.match(page, /build\.residual_file_disposition/);
|
||||
assert.match(page, /residual_file_count/);
|
||||
@@ -36,3 +44,31 @@ for (const allowance of ["none", "same_recipient", "same_message"]) {
|
||||
}
|
||||
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
|
||||
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);
|
||||
assert.match(page, /readOnlyVersion = [^;]*mailProfileMigrationRequired/);
|
||||
assert.match(page, /reloadAttachmentPreview = useCallback\(async[^]*?if \(!version\?\.id \|\| mailProfileMigrationRequired\)/);
|
||||
assert.match(page, /<LegacyMailMigrationNotice campaignId=\{campaignId\} versionId=\{version\?\.id\}/);
|
||||
assert.match(page, /state: mailProfileSelected && !mailProfileMigrationRequired \? "ready" : "blocked"/);
|
||||
assert.match(page, /label: "Attachments",\s*\.\.\.attachmentDeliveryPreflight/);
|
||||
assert.match(page, /inspectionComplete: inspectionSatisfied/);
|
||||
assert.match(page, /use_reviewed_build: true/);
|
||||
assert.match(page, /mailProfileMigrationRequired \? <DismissibleAlert[^>]*>\s*i18n:govoplan-campaign\.attachment_checks_await_mail_migration\s*<\/DismissibleAlert> : <AttachmentLinkingPreview/);
|
||||
assert.match(mailPage, /campaignMailProfileListOptions\(view, campaignId\)/);
|
||||
assert.doesNotMatch(mailPage, /Promise\.all/);
|
||||
assert.match(mailPage, /const generation = \+\+profileLoadGeneration\.current/);
|
||||
assert.match(mailPage, /if \(generation !== profileLoadGeneration\.current\) return;/);
|
||||
assert.match(mailPage, /if \(generation === profileLoadGeneration\.current\) setProfilesLoading\(false\)/);
|
||||
assert.match(mailPage, /if \(isPolicyView\) setPolicyProfiles\(\[\]\);\s*else setMailProfiles\(\[\]\)/);
|
||||
assert.match(mailPage, /const canMigrate = migrationRequired && !locked && !saving && Boolean\(draft\) && Boolean\(selectedProfile\) && !profilesLoading/);
|
||||
assert.doesNotMatch(mailPage, /const canMigrate = [^;]*dirty/);
|
||||
assert.match(mailPage, /async function migrateMailProfile\(\)[^]*?await saveDraft\("manual"\)/);
|
||||
assert.match(mailPage, /onMigrate=\{!isPolicyView && !locked/);
|
||||
assert.match(migrationNotice, /useGuardedNavigate/);
|
||||
assert.match(migrationNotice, /\?version=\$\{encodeURIComponent\(versionId\)\}/);
|
||||
assert.match(migrationNotice, /disabled=\{!canMigrate \|\| busy\}/);
|
||||
assert.match(draftEditor, /version\.mail_profile_migration_required && !additionalPayload\.migrate_legacy_mail_settings/);
|
||||
assert.match(draftEditor, /!campaignMailReferencesUnchanged\(baseDraftRef\.current \?\? getCampaignJson\(version\), draftToSave\)/);
|
||||
assert.match(draftEditor, /throw new Error\("i18n:govoplan-campaign\.legacy_mail_migration_required"\)/);
|
||||
for (const editor of ["TemplateDataPage.tsx", "CampaignFieldsPage.tsx", "AttachmentsDataPage.tsx", "GlobalSettingsPage.tsx", "components/CampaignDraftPageScaffold.tsx"]) {
|
||||
const source = readFileSync(resolve(here, "../src/features/campaigns", editor), "utf8");
|
||||
assert.match(source, /version\?\.mail_profile_migration_required && <LegacyMailMigrationNotice/);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { calculateBuildReviewProgress } from "../src/features/campaigns/review/reviewProgress.ts";
|
||||
import { attachmentDeliveryPreflight, calculateBuildReviewProgress } from "../src/features/campaigns/review/reviewProgress.ts";
|
||||
|
||||
test("separates blocking, individual, and group review work", () => {
|
||||
assert.deepEqual(calculateBuildReviewProgress({
|
||||
@@ -52,3 +52,23 @@ test("normalizes malformed counters without overstating progress", () => {
|
||||
assert.equal(progress.required, 2);
|
||||
assert.equal(progress.remaining, 0);
|
||||
});
|
||||
|
||||
const acceptedAttachments = { migrationRequired: false, hasBuild: true, reviewLoaded: true,
|
||||
blocking: 0, remaining: 0, inspectionComplete: true, missing: 14, ambiguous: 0 };
|
||||
|
||||
test("fourteen accepted missing matches do not ask again at final delivery preflight", () => {
|
||||
assert.equal(attachmentDeliveryPreflight(acceptedAttachments).state, "ready");
|
||||
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, inspectionComplete: false }).state, "ready");
|
||||
});
|
||||
|
||||
test("expected policy omissions need no acceptance and pending review is not a hard attachment blocker", () => {
|
||||
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, inspectionComplete: false, remaining: 0 }).state, "ready");
|
||||
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, inspectionComplete: false, remaining: 14 }).state, "warning");
|
||||
});
|
||||
|
||||
test("saved review cannot override real blockers, missing build evidence or legacy Mail migration", () => {
|
||||
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, blocking: 1 }).state, "blocked");
|
||||
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, migrationRequired: true }).state, "blocked");
|
||||
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, reviewLoaded: false }).state, "info");
|
||||
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, hasBuild: false }).state, "info");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { campaignJsonForAttachmentPreview } from "../src/features/campaigns/utils/templatePreviewDraft";
|
||||
import { checkContentLibraryCompatibility } from "../src/features/campaigns/utils/contentLibraryCompatibility";
|
||||
import {
|
||||
campaignVersionUpdateForRequest,
|
||||
clientCampaignEditorState
|
||||
} from "../src/features/campaigns/utils/editorState";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -68,3 +72,44 @@ const compatibility = checkContentLibraryCompatibility([
|
||||
assert(!compatibility.compatible, "missing or wrong-typed fields are incompatible");
|
||||
assert(compatibility.missing.length === 1 && compatibility.missing[0].path === "global:case_reference", "namespaced missing fields are normalized");
|
||||
assert(compatibility.incompatible.length === 1 && compatibility.incompatible[0].actual === "boolean", "type mismatches are reported");
|
||||
|
||||
// Ordinary saves must not echo the server-owned evidence returned by reads.
|
||||
// This is the state that previously broke saves when leaving the Template page.
|
||||
const loadedEditorState = {
|
||||
created_from: "minimal_campaign",
|
||||
field_overrides: { department: false },
|
||||
opt_ins: { inline_guidance: true },
|
||||
review_send: {
|
||||
inspection_complete: true,
|
||||
reviewed_message_keys: ["gabriele.fode", "fruzsina.molnar-gabor"],
|
||||
issue_decisions: [],
|
||||
updated_at: "2026-07-15T11:23:31.569257+00:00",
|
||||
updated_by_user_id: "064fa105-9e69-4f29-8558-a7665eb219df"
|
||||
},
|
||||
approval_gate: { request_id: "approval-1", requested_by_user_id: "approver-1" },
|
||||
smtp: { password: "legacy-secret" }
|
||||
};
|
||||
const clientState = clientCampaignEditorState(loadedEditorState);
|
||||
assert(JSON.stringify(clientState) === JSON.stringify({
|
||||
created_from: "minimal_campaign",
|
||||
field_overrides: { department: false },
|
||||
opt_ins: { inline_guidance: true }
|
||||
}), "draft and merge metadata contains only client-owned fields");
|
||||
const savePayload = {
|
||||
editor_state: loadedEditorState,
|
||||
base_revision: 7,
|
||||
campaign_json: { template: { subject: "Updated template" } },
|
||||
migrate_legacy_mail_settings: true
|
||||
};
|
||||
const requestPayload = campaignVersionUpdateForRequest(savePayload);
|
||||
assert(JSON.stringify(requestPayload.editor_state) === JSON.stringify(clientState), "save, autosave, and fork requests omit review and approval evidence");
|
||||
assert(!JSON.stringify(requestPayload).includes("legacy-secret"), "unknown legacy metadata is not resubmitted");
|
||||
assert(requestPayload.base_revision === 7 && requestPayload.migrate_legacy_mail_settings, "concurrency and explicit migration controls are preserved");
|
||||
assert(requestPayload.campaign_json === savePayload.campaign_json, "campaign content is not rewritten by metadata serialization");
|
||||
assert(savePayload.editor_state.review_send.inspection_complete, "read response evidence is not mutated");
|
||||
assert("approval_gate" in savePayload.editor_state, "read response approval evidence is preserved");
|
||||
assert(Object.keys(clientCampaignEditorState(undefined)).length === 0, "new drafts need no metadata");
|
||||
const omittedEditorState = { base_revision: 2, editor_state: undefined };
|
||||
assert(campaignVersionUpdateForRequest(omittedEditorState) === omittedEditorState, "omitted metadata remains omitted");
|
||||
const nullEditorState = { editor_state: null };
|
||||
assert(campaignVersionUpdateForRequest(nullEditorState) === nullEditorState, "null metadata retains its unchanged-state semantics");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { groupCampaignValidationIssues } from "../src/features/campaigns/review/validationIssueGroups.ts";
|
||||
|
||||
const cause = { code: "missing_required_attachment", severity: "warning", path: "/entries/recipient-1/attachments/0", message: "No file matched filter" };
|
||||
const outcome = { code: "missing_attachment_coverage", severity: "info", path: "/entries/recipient-1", message: "Policy excludes this message without attachments" };
|
||||
|
||||
test("one grouped condition preserves the missing-rule cause and policy outcome regardless of order", () => {
|
||||
for (const records of [[cause, outcome], [outcome, cause]]) {
|
||||
const groups = groupCampaignValidationIssues(records);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.deepEqual(groups[0].causes, [cause]);
|
||||
assert.deepEqual(groups[0].outcomes, [outcome]);
|
||||
assert.deepEqual(groups[0].evidence, records);
|
||||
assert.equal(groups[0].severity, "warning");
|
||||
}
|
||||
});
|
||||
|
||||
test("different recipients, unmatched rule without coverage, and unrelated errors are never collapsed", () => {
|
||||
const email = { code: "missing_email", severity: "error", path: "/entries/recipient-1", message: "No address" };
|
||||
const other = { ...cause, path: "/entries/recipient-2/attachments/0" };
|
||||
const groups = groupCampaignValidationIssues([cause, outcome, email, other]);
|
||||
assert.equal(groups.length, 3);
|
||||
assert.deepEqual(groups[1].evidence, [email]);
|
||||
assert.deepEqual(groups[2].evidence, [other]);
|
||||
});
|
||||
|
||||
test("multiple rule causes remain visible and a real block is not downgraded by an informational outcome", () => {
|
||||
const blocked = { ...cause, severity: "error", path: "/entries/recipient-1/attachments/1" };
|
||||
const [group] = groupCampaignValidationIssues([cause, blocked, outcome]);
|
||||
assert.equal(group.severity, "error");
|
||||
assert.equal(group.causes.length, 2);
|
||||
assert.equal(group.evidence.length, 3);
|
||||
});
|
||||
Reference in New Issue
Block a user