feat: complete campaign wizards and retention reporting

This commit is contained in:
2026-07-31 04:21:34 +02:00
parent e689fdf495
commit fa4eb39e0b
18 changed files with 785 additions and 55 deletions
+9
View File
@@ -153,6 +153,15 @@ every action control that the actor lacks. The server authorizes each action,
but permission-aware action visibility on that detailed surface remains open but permission-aware action visibility on that detailed surface remains open
work; do not confuse it with the aggregate reader experience. work; do not confuse it with the aggregate reader experience.
The detailed Campaign Report includes the selected campaign's effective
retention policy, its system/tenant/owner/campaign provenance, and the current
evidence state. It distinguishes retained, redacted, expired, partially
minimized, unavailable, and not-applicable source JSON, stored report detail,
generated EML, and Postbox-copy evidence. When Policy is absent, the report
shows the platform defaults and explicitly warns that automated retention
enforcement is unavailable. Retention removes or minimizes detail; aggregate
counters and audit references may remain so outcomes can still be explained.
### Deliver and resolve outcomes ### Deliver and resolve outcomes
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
@@ -12,6 +12,11 @@ from typing import Any
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.policy import (
CAPABILITY_POLICY_PRIVACY_RETENTION,
PrivacyRetentionService,
)
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem
from govoplan_core.settings import settings as core_settings from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
@@ -26,6 +31,7 @@ from govoplan_campaign.backend.db.models import (
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.integrations import postbox_integration from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.runtime import capability
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import ( from govoplan_campaign.backend.response_security import (
public_campaign_payload, public_campaign_payload,
@@ -301,6 +307,9 @@ class _JobReportAggregate:
attachment_ambiguous: int = 0 attachment_ambiguous: int = 0
attachment_status: Counter[str] = field(default_factory=Counter) attachment_status: Counter[str] = field(default_factory=Counter)
attachment_behavior: Counter[str] = field(default_factory=Counter) attachment_behavior: Counter[str] = field(default_factory=Counter)
eml_retained: int = 0
eml_expired: int = 0
eml_not_generated: int = 0
recent_failures: list[CampaignJob] = field(default_factory=list) recent_failures: list[CampaignJob] = field(default_factory=list)
def add( def add(
@@ -315,6 +324,7 @@ class _JobReportAggregate:
self._add_delivery_counts(job, retry_max_attempts=retry_max_attempts) self._add_delivery_counts(job, retry_max_attempts=retry_max_attempts)
self._add_issues(job) self._add_issues(job)
self._add_attachments(job) self._add_attachments(job)
self._add_eml_evidence(job)
self._add_recent_failure(job, include_recent_failures=include_recent_failures) self._add_recent_failure(job, include_recent_failures=include_recent_failures)
def _add_statuses(self, job: CampaignJob) -> None: def _add_statuses(self, job: CampaignJob) -> None:
@@ -375,6 +385,14 @@ class _JobReportAggregate:
if status_value == "ambiguous": if status_value == "ambiguous":
self.attachment_ambiguous += 1 self.attachment_ambiguous += 1
def _add_eml_evidence(self, job: CampaignJob) -> None:
if not job.eml_sha256:
self.eml_not_generated += 1
elif job.eml_local_path or job.eml_storage_key:
self.eml_retained += 1
else:
self.eml_expired += 1
NON_CANCELLABLE_SEND_STATUSES = { NON_CANCELLABLE_SEND_STATUSES = {
"skipped", "skipped",
@@ -865,6 +883,12 @@ def _campaign_report_payload(
include_diagnostics: bool, include_diagnostics: bool,
review_decisions: dict[str, dict[str, Any]] | None = None, review_decisions: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
postbox_receipts = _campaign_postbox_receipt_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
)
report = { report = {
"generated_at": _utcnow_iso(), "generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign), "campaign": _campaign_report_campaign_payload(campaign),
@@ -903,12 +927,7 @@ def _campaign_report_payload(
campaign_id=campaign.id, campaign_id=campaign.id,
version=version, version=version,
), ),
"postbox_receipts": _campaign_postbox_receipt_summary( "postbox_receipts": postbox_receipts,
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"message_actions": _campaign_message_action_summary( "message_actions": _campaign_message_action_summary(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -920,6 +939,13 @@ def _campaign_report_payload(
pending_job_count=aggregate.pending, pending_job_count=aggregate.pending,
include_diagnostics=include_diagnostics, include_diagnostics=include_diagnostics,
), ),
"retention": _campaign_retention_projection(
session,
campaign=campaign,
version=version,
aggregate=aggregate,
postbox_receipts=postbox_receipts,
),
} }
if include_recent_failures: if include_recent_failures:
report["recent_failures"] = _recent_failures( report["recent_failures"] = _recent_failures(
@@ -928,6 +954,221 @@ def _campaign_report_payload(
return report return report
def _campaign_retention_projection(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion | None,
aggregate: _JobReportAggregate,
postbox_receipts: dict[str, object],
) -> dict[str, Any]:
service = capability(CAPABILITY_POLICY_PRIVACY_RETENTION)
policy_status = "configured"
policy_reason: str | None = None
sources: list[dict[str, Any]] = []
if service is None:
policy_status = "defaults"
policy_reason = (
"The Policy module is not active. Platform defaults apply, but "
"automated retention enforcement is unavailable."
)
policy = PrivacyRetentionPolicyItem().model_dump(mode="json")
sources = [{
"scope_type": "system",
"path": "platform-defaults",
"label": "Platform defaults",
"applied_fields": list(policy),
}]
elif not isinstance(service, PrivacyRetentionService):
policy_status = "unavailable"
policy_reason = "The configured retention provider does not satisfy the platform contract."
policy = PrivacyRetentionPolicyItem().model_dump(mode="json")
else:
try:
policy = _model_payload(
service.effective_privacy_policy(
session,
campaign_id=campaign.id,
)
)
sources = _public_retention_sources(
service.effective_privacy_policy_sources(
session,
campaign_id=campaign.id,
)
)
except Exception: # pragma: no cover - report remains readable on provider failure.
logger.exception("Effective Campaign retention policy could not be resolved")
policy_status = "unavailable"
policy_reason = "The effective retention policy could not be resolved."
policy = PrivacyRetentionPolicyItem().model_dump(mode="json")
evidence = _retention_evidence_state(
version=version,
aggregate=aggregate,
postbox_receipts=postbox_receipts,
)
minimized = [
name
for name, item in evidence.items()
if item.get("state") in {"redacted", "partially_redacted", "expired", "partially_expired"}
]
retained = [
name
for name, item in evidence.items()
if item.get("state") in {"retained", "partially_expired"}
]
impact_state = (
"policy_unavailable"
if policy_status == "unavailable"
else "partially_minimized"
if minimized
else "retained"
)
return {
"policy_status": policy_status,
"policy_reason": policy_reason,
"effective_policy": policy,
"sources": sources,
"evidence": evidence,
"privacy_impact": {
"state": impact_state,
"summary": (
"Campaign reports expose delivery and recipient evidence only "
"to authorized report readers. The effective retention policy "
"controls when source JSON, generated message files, and stored "
"report detail are removed or redacted; aggregate counters and "
"audit references may remain."
),
"retained_categories": retained,
"minimized_categories": minimized,
},
}
def _model_payload(value: object) -> dict[str, Any]:
if hasattr(value, "model_dump"):
payload = value.model_dump(mode="json")
return dict(payload) if isinstance(payload, dict) else {}
return dict(value) if isinstance(value, dict) else {}
def _public_retention_sources(value: object) -> list[dict[str, Any]]:
if not isinstance(value, (list, tuple)):
return []
sources: list[dict[str, Any]] = []
for item in value:
if not isinstance(item, dict):
continue
sources.append({
"scope_type": item.get("scope_type"),
"path": item.get("path"),
"label": item.get("label"),
"applied_fields": [
str(field)
for field in item.get("applied_fields") or []
if field
],
})
return sources
def _retention_evidence_state(
*,
version: CampaignVersion | None,
aggregate: _JobReportAggregate,
postbox_receipts: dict[str, object],
) -> dict[str, dict[str, Any]]:
if version is None:
return {
key: {"state": "not_applicable"}
for key in (
"raw_campaign_json",
"stored_report_detail",
"generated_eml",
"postbox_copies",
)
}
raw_marker = _retention_marker(version.raw_json, "raw_json_redacted")
validation_marker = _retention_marker(
version.validation_summary,
"report_detail_redacted",
)
build_marker = _retention_marker(
version.build_summary,
"report_detail_redacted",
)
redacted_summaries = sum(bool(marker) for marker in (validation_marker, build_marker))
generated_total = aggregate.eml_retained + aggregate.eml_expired
generated_state = (
"not_generated"
if generated_total == 0
else "expired"
if aggregate.eml_expired == generated_total
else "partially_expired"
if aggregate.eml_expired
else "retained"
)
expired_postbox = int(postbox_receipts.get("expired_message_count") or 0)
withdrawn_postbox = int(postbox_receipts.get("withdrawn_message_count") or 0)
readable_postbox = int(postbox_receipts.get("currently_readable_delivery_count") or 0)
postbox_state = (
"not_applicable"
if postbox_receipts.get("status") == "not_applicable"
else "unavailable"
if postbox_receipts.get("status") == "unavailable"
else "partially_expired"
if (expired_postbox or withdrawn_postbox) and readable_postbox
else "expired"
if expired_postbox or withdrawn_postbox
else "retained"
)
return {
"raw_campaign_json": {
"state": "redacted" if raw_marker else "retained",
"redacted_at": raw_marker.get("redacted_at") if raw_marker else None,
},
"stored_report_detail": {
"state": (
"redacted"
if redacted_summaries == 2
else "partially_redacted"
if redacted_summaries
else "retained"
),
"redacted_summary_count": redacted_summaries,
"summary_count": 2,
"redacted_at": next(
(
marker.get("redacted_at")
for marker in (validation_marker, build_marker)
if marker and marker.get("redacted_at")
),
None,
),
},
"generated_eml": {
"state": generated_state,
"retained_count": aggregate.eml_retained,
"expired_count": aggregate.eml_expired,
"not_generated_count": aggregate.eml_not_generated,
},
"postbox_copies": {
"state": postbox_state,
"currently_readable_count": readable_postbox,
"expired_count": expired_postbox,
"withdrawn_count": withdrawn_postbox,
},
}
def _retention_marker(value: object, key: str) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
marker = value.get("_retention")
return dict(marker) if isinstance(marker, dict) and marker.get(key) else None
def _campaign_postbox_receipt_summary( def _campaign_postbox_receipt_summary(
session: Session, session: Session,
*, *,
+110
View File
@@ -6,7 +6,10 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem
from govoplan_campaign.backend.reports.campaigns import ( from govoplan_campaign.backend.reports.campaigns import (
_JobReportAggregate,
_campaign_retention_projection,
_campaign_postbox_receipt_summary, _campaign_postbox_receipt_summary,
_job_evidence_row, _job_evidence_row,
_latest_by_job_id, _latest_by_job_id,
@@ -269,6 +272,113 @@ def test_postbox_receipt_report_aggregates_provider_state_without_identities() -
assert "identity_id" not in repr(report) assert "identity_id" not in repr(report)
def test_retention_projection_explains_defaults_and_redacted_evidence() -> None:
aggregate = _JobReportAggregate(
eml_retained=2,
eml_expired=1,
eml_not_generated=3,
)
version = SimpleNamespace(
raw_json={
"_retention": {
"raw_json_redacted": True,
"redacted_at": "2026-07-08T12:30:00+00:00",
}
},
validation_summary={
"_retention": {
"report_detail_redacted": True,
"redacted_at": "2026-07-08T12:30:00+00:00",
}
},
build_summary={"ok": True},
)
with patch(
"govoplan_campaign.backend.reports.campaigns.capability",
return_value=None,
):
projection = _campaign_retention_projection(
object(), # type: ignore[arg-type]
campaign=SimpleNamespace(id="campaign-1"),
version=version, # type: ignore[arg-type]
aggregate=aggregate,
postbox_receipts={
"status": "available",
"currently_readable_delivery_count": 2,
"expired_message_count": 1,
},
)
assert projection["policy_status"] == "defaults"
assert projection["effective_policy"]["store_raw_campaign_json"] is True
assert projection["evidence"]["raw_campaign_json"]["state"] == "redacted"
assert projection["evidence"]["stored_report_detail"]["state"] == "partially_redacted"
assert projection["evidence"]["generated_eml"] == {
"state": "partially_expired",
"retained_count": 2,
"expired_count": 1,
"not_generated_count": 3,
}
assert projection["evidence"]["postbox_copies"]["state"] == "partially_expired"
assert "generated_eml" in projection["privacy_impact"]["minimized_categories"]
def test_retention_projection_uses_only_public_policy_contract_metadata() -> None:
class _RetentionService:
privacy_policy_from_settings = lambda self, *args, **kwargs: None
privacy_policy_from_session = lambda self, *args, **kwargs: None
set_privacy_policy = lambda self, *args, **kwargs: None
parent_privacy_policy = lambda self, *args, **kwargs: None
parent_privacy_policy_sources = lambda self, *args, **kwargs: []
get_privacy_policy_for_scope = lambda self, *args, **kwargs: {}
set_privacy_policy_for_scope = lambda self, *args, **kwargs: {}
sanitize_audit_details_for_policy = lambda self, *args, **kwargs: {}
apply_retention_policy = lambda self, *args, **kwargs: {}
def effective_privacy_policy(self, *args, **kwargs):
return PrivacyRetentionPolicyItem(
generated_eml_retention_days=30,
stored_report_detail_retention_days=90,
)
def effective_privacy_policy_sources(self, *args, **kwargs):
return [{
"scope_type": "tenant",
"path": "tenant:tenant-1",
"label": "Tenant",
"applied_fields": ["generated_eml_retention_days"],
"policy": {"generated_eml_retention_days": 30},
"provider_secret": "must-not-leak",
}]
with patch(
"govoplan_campaign.backend.reports.campaigns.capability",
return_value=_RetentionService(),
):
projection = _campaign_retention_projection(
object(), # type: ignore[arg-type]
campaign=SimpleNamespace(id="campaign-1"),
version=SimpleNamespace(
raw_json={},
validation_summary={},
build_summary={},
), # type: ignore[arg-type]
aggregate=_JobReportAggregate(),
postbox_receipts={"status": "not_applicable"},
)
assert projection["policy_status"] == "configured"
assert projection["effective_policy"]["generated_eml_retention_days"] == 30
assert projection["sources"] == [{
"scope_type": "tenant",
"path": "tenant:tenant-1",
"label": "Tenant",
"applied_fields": ["generated_eml_retention_days"],
}]
assert "must-not-leak" not in repr(projection)
class CampaignReportProjectionTests(unittest.TestCase): class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None: def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence() test_job_evidence_row_contains_transport_and_message_evidence()
+2 -1
View File
@@ -31,7 +31,8 @@
"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:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs", "test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs", "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: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"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
+38
View File
@@ -295,6 +295,43 @@ export type CampaignPartialValidationResponse = {
issues: Record<string, unknown>[]; issues: Record<string, unknown>[];
}; };
export type CampaignRetentionReport = {
policy_status: "configured" | "defaults" | "unavailable" | string;
policy_reason?: string | null;
effective_policy: {
store_raw_campaign_json?: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level?: string;
};
sources: Array<{
scope_type?: string | null;
path?: string | null;
label?: string | null;
applied_fields?: string[];
}>;
evidence: Record<string, {
state?: string;
redacted_at?: string | null;
retained_count?: number;
expired_count?: number;
not_generated_count?: number;
currently_readable_count?: number;
withdrawn_count?: number;
redacted_summary_count?: number;
summary_count?: number;
}>;
privacy_impact: {
state: string;
summary: string;
retained_categories: string[];
minimized_categories: string[];
};
};
export type CampaignSummary = { export type CampaignSummary = {
generated_at?: string; generated_at?: string;
selected_version_id?: string | null; selected_version_id?: string | null;
@@ -336,6 +373,7 @@ export type CampaignSummary = {
attachments?: Record<string, unknown>; attachments?: Record<string, unknown>;
attempts?: Record<string, unknown>; attempts?: Record<string, unknown>;
postbox_receipts?: Record<string, unknown>; postbox_receipts?: Record<string, unknown>;
retention?: CampaignRetentionReport;
delivery?: Record<string, unknown>; delivery?: Record<string, unknown>;
recent_failures?: Record<string, unknown>[]; recent_failures?: Record<string, unknown>[];
}; };
@@ -129,7 +129,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 70, width: 120,
sticky: "end", sticky: "end",
align: "right", align: "right",
render: (campaign) => <TableActionGroup actions={[{ render: (campaign) => <TableActionGroup actions={[{
@@ -172,7 +172,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<Card <Card
title="i18n:govoplan-campaign.campaign_identity.a00ca574" title="i18n:govoplan-campaign.campaign_identity.a00ca574"
collapsible collapsible
actions={<Link className="btn btn-secondary" to="wizard/create">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}> actions={<Link className="btn btn-secondary" to="wizard">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}>
<div className="form-grid campaign-identity-grid"> <div className="form-grid campaign-identity-grid">
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e"> <FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} /> <input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
@@ -308,8 +308,9 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 150, width: 180,
sticky: "end", sticky: "end",
align: "right",
render: (version) => { render: (version) => {
const isCurrent = version.id === currentVersionId; const isCurrent = version.id === currentVersionId;
const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version); const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
@@ -11,7 +11,8 @@ import {
sendCampaignJob, sendCampaignJob,
sendUnattemptedCampaignJobs, sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse, type CampaignJobDetailResponse,
type CampaignJobsResponse } from type CampaignJobsResponse,
type CampaignRetentionReport } from
"../../api/campaigns"; "../../api/campaigns";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
@@ -103,6 +104,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const cards = data.summary?.cards; const cards = data.summary?.cards;
const delivery = asRecord(data.summary?.delivery); const delivery = asRecord(data.summary?.delivery);
const postboxReceipts = asRecord(data.summary?.postbox_receipts); const postboxReceipts = asRecord(data.summary?.postbox_receipts);
const retention = data.summary?.retention;
const rateLimit = asRecord(delivery.rate_limit); const rateLimit = asRecord(delivery.rate_limit);
const imapPolicy = asRecord(delivery.imap_append_sent); const imapPolicy = asRecord(delivery.imap_append_sent);
@@ -500,6 +502,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div> <div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
</dl> </dl>
</Card> </Card>
<RetentionPrivacyCard retention={retention} />
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4"> <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> <p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
@@ -623,6 +626,102 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
} }
function RetentionPrivacyCard({ retention }: { retention?: CampaignRetentionReport }) {
if (!retention) {
return (
<Card title="i18n:govoplan-campaign.retention_and_privacy.24418676">
<p className="muted">i18n:govoplan-campaign.retention_information_is_unavailable.f68cc4d1</p>
</Card>
);
}
const policy = retention.effective_policy;
const evidenceRows = [
["i18n:govoplan-campaign.raw_campaign_json.53d8522d", retention.evidence.raw_campaign_json],
["i18n:govoplan-campaign.stored_report_detail.13a437d7", retention.evidence.stored_report_detail],
["i18n:govoplan-campaign.generated_message_files.2d86ef64", retention.evidence.generated_eml],
["i18n:govoplan-campaign.postbox_copies.080b404f", retention.evidence.postbox_copies]
] as const;
return (
<Card title="i18n:govoplan-campaign.retention_and_privacy.24418676">
<p>{retention.privacy_impact.summary}</p>
{retention.policy_reason && <p className="muted">{retention.policy_reason}</p>}
<dl className="detail-list">
<div>
<dt>i18n:govoplan-campaign.policy_state.7e955a0d</dt>
<dd>
<StatusBadge
status={retention.policy_status === "configured" ? "success" : retention.policy_status === "defaults" ? "warning" : "error"}
label={humanize(retention.policy_status)}
/>
</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.policy_sources.a592802f</dt>
<dd>{retention.sources.map((source) => source.label || source.path).filter(Boolean).join(" → ") || "—"}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.raw_json_retention.19ea35f7</dt>
<dd>{policy.store_raw_campaign_json === false ? "i18n:govoplan-campaign.do_not_retain.9fd25c4d" : retentionDuration(policy.raw_campaign_json_retention_days)}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.generated_message_retention.ce6366ca</dt>
<dd>{retentionDuration(policy.generated_eml_retention_days)}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.report_detail_retention.68e30587</dt>
<dd>{retentionDuration(policy.stored_report_detail_retention_days)}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.audit_detail.2a85f905</dt>
<dd>{humanize(policy.audit_detail_level ?? "full")} · {retentionDuration(policy.audit_detail_retention_days)}</dd>
</div>
{evidenceRows.map(([label, evidence]) =>
<div key={label}>
<dt>{label}</dt>
<dd>
<StatusBadge
status={retentionEvidenceTone(evidence?.state)}
label={humanize(evidence?.state ?? "unavailable")}
/>
<span className="muted"> · {retentionEvidenceDetail(evidence)}</span>
</dd>
</div>
)}
</dl>
</Card>
);
}
function retentionDuration(days?: number | null): string {
if (days === null || days === undefined) return "i18n:govoplan-campaign.no_automatic_expiry.52cb62f8";
if (days === 0) return "i18n:govoplan-campaign.remove_when_eligible.385ff0eb";
return i18nMessage("i18n:govoplan-campaign.value_days.2bf9b447", { value0: String(days) });
}
function retentionEvidenceTone(state?: string): string {
if (state === "retained") return "success";
if (state === "redacted" || state === "expired") return "inactive";
if (state === "partially_redacted" || state === "partially_expired") return "warning";
if (state === "unavailable") return "error";
return "info";
}
function retentionEvidenceDetail(
evidence?: CampaignRetentionReport["evidence"][string]
): string {
if (!evidence) return "—";
const counts = [
evidence.retained_count !== undefined ? `${evidence.retained_count} retained` : "",
evidence.expired_count !== undefined ? `${evidence.expired_count} expired` : "",
evidence.redacted_summary_count !== undefined
? `${evidence.redacted_summary_count}/${evidence.summary_count ?? 0} redacted`
: "",
evidence.currently_readable_count !== undefined ? `${evidence.currently_readable_count} readable` : "",
evidence.withdrawn_count !== undefined ? `${evidence.withdrawn_count} withdrawn` : ""
].filter(Boolean);
return counts.join(", ") || (evidence.redacted_at ? formatDateTime(evidence.redacted_at) : "—");
}
function PostboxTargetEvidenceSection({ targets }: { targets: unknown[] }) { function PostboxTargetEvidenceSection({ targets }: { targets: unknown[] }) {
const rows = targets.map(asRecord); const rows = targets.map(asRecord);
if (rows.length === 0) return null; if (rows.length === 0) return null;
@@ -18,6 +18,7 @@ const ReviewSendPage = lazy(() => import("./ReviewSendPage"));
const CreateWizard = lazy(() => import("./wizard/CreateWizard")); const CreateWizard = lazy(() => import("./wizard/CreateWizard"));
const ReviewWizard = lazy(() => import("./wizard/ReviewWizard")); const ReviewWizard = lazy(() => import("./wizard/ReviewWizard"));
const SendWizard = lazy(() => import("./wizard/SendWizard")); const SendWizard = lazy(() => import("./wizard/SendWizard"));
const WizardDirectoryPage = lazy(() => import("./wizard/WizardDirectoryPage"));
const CampaignJsonView = lazy(() => import("./CampaignJsonView")); const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
const CampaignReportPage = lazy(() => import("./CampaignReportPage")); const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage")); const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
@@ -109,9 +110,10 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="reports" element={<Navigate to="../report" replace />} /> <Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} /> <Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard" element={<WizardDirectoryPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} /> <Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/review" element={<ReviewWizard />} /> <Route path="wizard/review" element={<ReviewWizard settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="wizard/send" element={<SendWizard />} /> <Route path="wizard/send" element={<SendWizard settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="create" element={<Navigate to="../wizard/create" replace />} /> <Route path="create" element={<Navigate to="../wizard/create" replace />} />
<Route path="campaign" element={<Navigate to="../data" replace />} /> <Route path="campaign" element={<Navigate to="../data" replace />} />
<Route path="mail" element={<Navigate to="../mail-settings" replace />} /> <Route path="mail" element={<Navigate to="../mail-settings" replace />} />
@@ -111,7 +111,17 @@ type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "sen
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"]; const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
export default function ReviewSendPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { export default function ReviewSendPage({
settings,
auth,
campaignId,
initialStageId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
initialStageId?: string;
}) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox"); const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
@@ -188,6 +198,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null); const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null);
const persistedReview = storedMessageReviewState(version); const persistedReview = storedMessageReviewState(version);
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}|${JSON.stringify(persistedReview.issueDecisions)}`; const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}|${JSON.stringify(persistedReview.issueDecisions)}`;
const initialStageScrollKey = useRef("");
useEffect(() => { useEffect(() => {
setBuiltReviewRows([]); setBuiltReviewRows([]);
@@ -218,6 +229,20 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
resetDeltaWatermark(); resetDeltaWatermark();
}, [version?.id, resetDeltaWatermark]); }, [version?.id, resetDeltaWatermark]);
useEffect(() => {
if (!initialStageId || loading || !version?.id) return;
const key = `${version.id}:${initialStageId}`;
if (initialStageScrollKey.current === key) return;
const frame = window.requestAnimationFrame(() => {
const target = document.getElementById(initialStageId);
if (!target) return;
initialStageScrollKey.current = key;
target.scrollIntoView({ behavior: "smooth", block: "start" });
target.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frame);
}, [initialStageId, loading, version?.id]);
useEffect(() => { useEffect(() => {
setMessageReviewComplete(persistedReview.inspectionComplete); setMessageReviewComplete(persistedReview.inspectionComplete);
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys)); setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
@@ -120,7 +120,7 @@ export function WorkflowStage({
} as CSSProperties; } as CSSProperties;
return ( return (
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}> <section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style} tabIndex={-1}>
<div className="review-flow-stage-marker" aria-hidden="true"> <div className="review-flow-stage-marker" aria-hidden="true">
<div className="review-flow-stage-node"> <div className="review-flow-stage-node">
<Icon size={20} strokeWidth={1.8} /> <Icon size={20} strokeWidth={1.8} />
@@ -1,22 +1,21 @@
import { Card } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import ReviewSendPage from "../ReviewSendPage";
import { Button } from "@govoplan/core-webui";
export default function ReviewWizard() {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading workspace-heading">
<h1>i18n:govoplan-campaign.review_wizard.3d8bf0aa</h1>
</div>
<div className="metric-grid">
<MetricCard label="i18n:govoplan-campaign.needs_review.33a506cf" value="—" tone="warning" />
<MetricCard label="i18n:govoplan-campaign.missing_attachments.729ad125" value="—" tone="warning" />
<MetricCard label="i18n:govoplan-campaign.ambiguous_matches.dc658a9c" value="—" tone="info" />
<MetricCard label="i18n:govoplan-campaign.blocked.99613c74" value="—" tone="danger" />
</div>
<Card title="i18n:govoplan-campaign.resolution_workflow.708d6c0b">
<p className="muted">i18n:govoplan-campaign.this_wizard_will_guide_users_through_issues_one_.5cb212c3</p>
<Button variant="primary">i18n:govoplan-campaign.start_review.d0bc5cdf</Button>
</Card>
</div>);
export default function ReviewWizard({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
return (
<ReviewSendPage
settings={settings}
auth={auth}
campaignId={campaignId}
initialStageId="workflow-build-review"
/>
);
} }
@@ -1,21 +1,21 @@
import { Card } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import ReviewSendPage from "../ReviewSendPage";
export default function SendWizard() {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading workspace-heading">
<h1>i18n:govoplan-campaign.send_wizard.3c137422</h1>
</div>
<div className="dashboard-grid">
<Card title="i18n:govoplan-campaign.test_send.03dfff38">
<p className="muted">i18n:govoplan-campaign.send_one_generated_message_to_a_test_address.bc0a4e47</p>
<Button>i18n:govoplan-campaign.open_test_send_dialog.661db713</Button>
</Card>
<Card title="i18n:govoplan-campaign.queue_estimate.5480288a">
<p className="muted">i18n:govoplan-campaign.estimated_duration_will_be_based_on_ready_jobs_a.15b63283</p>
<Button variant="primary">i18n:govoplan-campaign.queue_dry_run.800e1606</Button>
</Card>
</div>
</div>);
export default function SendWizard({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
return (
<ReviewSendPage
settings={settings}
auth={auth}
campaignId={campaignId}
initialStageId="workflow-send"
/>
);
} }
@@ -0,0 +1,55 @@
import { Link } from "react-router";
import {
PageTitle,
WizardDirectory,
usePlatformUiCapabilities,
wizardEntriesForContext,
type ApiSettings,
type AuthInfo,
type WizardDirectoriesUiCapability
} from "@govoplan/core-webui";
import { campaignWizardDirectories } from "./directory";
export default function WizardDirectoryPage({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
const contributed = usePlatformUiCapabilities<WizardDirectoriesUiCapability>(
"wizard.directories"
);
const capabilities = contributed.some((capability) =>
capability.directories.some(
(directory) => directory.id === "campaigns.resource"
)
)
? contributed
: [...contributed, campaignWizardDirectories];
const entries = wizardEntriesForContext(capabilities, {
settings,
auth,
contextId: "campaign.resource",
resourceId: campaignId
});
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<PageTitle>i18n:govoplan-campaign.guided_workflows</PageTitle>
<Link className="btn btn-secondary" to="../">
i18n:govoplan-campaign.back_to_overview.ec986cba
</Link>
</div>
<WizardDirectory
entries={entries}
auth={auth}
emptyText="i18n:govoplan-campaign.no_guided_workflows"
/>
</div>
);
}
@@ -0,0 +1,57 @@
import type {
WizardDirectoriesUiCapability,
WizardDirectoryEntry
} from "@govoplan/core-webui";
const campaignRead = ["campaigns:campaign:read"];
export function campaignWizardEntries(
campaignId: string
): WizardDirectoryEntry[] {
const basePath = `/campaigns/${encodeURIComponent(campaignId)}/wizard`;
return [
{
id: "configure",
moduleId: "campaigns",
label: "i18n:govoplan-campaign.create_campaign.59812bbc",
description: "i18n:govoplan-campaign.name_and_scenario.f2bc5241",
to: `${basePath}/create`,
actionLabel: "i18n:govoplan-campaign.open.cf9b7706",
anyOf: campaignRead,
order: 10
},
{
id: "review",
moduleId: "campaigns",
label: "i18n:govoplan-campaign.review_wizard.3d8bf0aa",
description: "i18n:govoplan-campaign.lock_and_validate_the_campaign_then_inspect_bloc.e22b9266",
to: `${basePath}/review`,
actionLabel: "i18n:govoplan-campaign.open.cf9b7706",
anyOf: campaignRead,
order: 20
},
{
id: "send",
moduleId: "campaigns",
label: "i18n:govoplan-campaign.send_wizard.3c137422",
description: "i18n:govoplan-campaign.review_the_final_execution_summary_optionally_ru.6b32b00a",
to: `${basePath}/send`,
actionLabel: "i18n:govoplan-campaign.open.cf9b7706",
anyOf: campaignRead,
order: 30
}
];
}
export const campaignWizardDirectories: WizardDirectoriesUiCapability = {
directories: [
{
id: "campaigns.resource",
moduleId: "campaigns",
contextId: "campaign.resource",
order: 20,
entries: ({ resourceId }) =>
resourceId ? campaignWizardEntries(resourceId) : []
}
]
};
+36
View File
@@ -2,6 +2,8 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-campaign.guided_workflows": "Guided workflows",
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.",
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign", "i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns", "i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Appended", "i18n:govoplan-campaign.appended.979f5a82": "Appended",
@@ -317,6 +319,22 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.campaign_policies.0b5de1f5": "Campaign policies", "i18n:govoplan-campaign.campaign_policies.0b5de1f5": "Campaign policies",
"i18n:govoplan-campaign.campaign_queues.657785f4": "Campaign queues", "i18n:govoplan-campaign.campaign_queues.657785f4": "Campaign queues",
"i18n:govoplan-campaign.campaign_retention_policy.fcc9897c": "Campaign retention policy", "i18n:govoplan-campaign.campaign_retention_policy.fcc9897c": "Campaign retention policy",
"i18n:govoplan-campaign.retention_and_privacy.24418676": "Retention and privacy",
"i18n:govoplan-campaign.retention_information_is_unavailable.f68cc4d1": "Retention information is unavailable.",
"i18n:govoplan-campaign.policy_state.7e955a0d": "Policy state",
"i18n:govoplan-campaign.policy_sources.a592802f": "Policy sources",
"i18n:govoplan-campaign.raw_json_retention.19ea35f7": "Raw JSON retention",
"i18n:govoplan-campaign.generated_message_retention.ce6366ca": "Generated message retention",
"i18n:govoplan-campaign.report_detail_retention.68e30587": "Report detail retention",
"i18n:govoplan-campaign.audit_detail.2a85f905": "Audit detail",
"i18n:govoplan-campaign.raw_campaign_json.53d8522d": "Raw campaign JSON",
"i18n:govoplan-campaign.stored_report_detail.13a437d7": "Stored report detail",
"i18n:govoplan-campaign.generated_message_files.2d86ef64": "Generated message files",
"i18n:govoplan-campaign.postbox_copies.080b404f": "Postbox copies",
"i18n:govoplan-campaign.do_not_retain.9fd25c4d": "Do not retain",
"i18n:govoplan-campaign.no_automatic_expiry.52cb62f8": "No automatic expiry",
"i18n:govoplan-campaign.remove_when_eligible.385ff0eb": "Remove when eligible",
"i18n:govoplan-campaign.value_days.2bf9b447": "{value0} days",
"i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b": "Campaign review and sending workflow", "i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b": "Campaign review and sending workflow",
"i18n:govoplan-campaign.campaign_root.7eccd949": "Campaign root", "i18n:govoplan-campaign.campaign_root.7eccd949": "Campaign root",
"i18n:govoplan-campaign.campaign_sender.b12e8ae2": "Campaign sender", "i18n:govoplan-campaign.campaign_sender.b12e8ae2": "Campaign sender",
@@ -1284,6 +1302,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto" "i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
}, },
"de": { "de": {
"i18n:govoplan-campaign.guided_workflows": "Geführte Abläufe",
"i18n:govoplan-campaign.no_guided_workflows": "Es sind keine geführten Abläufe verfügbar.",
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign", "i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns", "i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt", "i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
@@ -1599,6 +1619,22 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.campaign_policies.0b5de1f5": "Campaign policies", "i18n:govoplan-campaign.campaign_policies.0b5de1f5": "Campaign policies",
"i18n:govoplan-campaign.campaign_queues.657785f4": "Campaign queues", "i18n:govoplan-campaign.campaign_queues.657785f4": "Campaign queues",
"i18n:govoplan-campaign.campaign_retention_policy.fcc9897c": "Campaign retention policy", "i18n:govoplan-campaign.campaign_retention_policy.fcc9897c": "Campaign retention policy",
"i18n:govoplan-campaign.retention_and_privacy.24418676": "Aufbewahrung und Datenschutz",
"i18n:govoplan-campaign.retention_information_is_unavailable.f68cc4d1": "Aufbewahrungsinformationen sind nicht verfügbar.",
"i18n:govoplan-campaign.policy_state.7e955a0d": "Richtlinienstatus",
"i18n:govoplan-campaign.policy_sources.a592802f": "Richtlinienquellen",
"i18n:govoplan-campaign.raw_json_retention.19ea35f7": "Aufbewahrung der Rohdaten",
"i18n:govoplan-campaign.generated_message_retention.ce6366ca": "Aufbewahrung erzeugter Nachrichten",
"i18n:govoplan-campaign.report_detail_retention.68e30587": "Aufbewahrung der Berichtsdetails",
"i18n:govoplan-campaign.audit_detail.2a85f905": "Auditdetails",
"i18n:govoplan-campaign.raw_campaign_json.53d8522d": "Kampagnen-Rohdaten",
"i18n:govoplan-campaign.stored_report_detail.13a437d7": "Gespeicherte Berichtsdetails",
"i18n:govoplan-campaign.generated_message_files.2d86ef64": "Erzeugte Nachrichtendateien",
"i18n:govoplan-campaign.postbox_copies.080b404f": "Postfachkopien",
"i18n:govoplan-campaign.do_not_retain.9fd25c4d": "Nicht aufbewahren",
"i18n:govoplan-campaign.no_automatic_expiry.52cb62f8": "Kein automatischer Ablauf",
"i18n:govoplan-campaign.remove_when_eligible.385ff0eb": "Nach Abschluss entfernen",
"i18n:govoplan-campaign.value_days.2bf9b447": "{value0} Tage",
"i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b": "Campaign review and sending workflow", "i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b": "Campaign review and sending workflow",
"i18n:govoplan-campaign.campaign_root.7eccd949": "Campaign root", "i18n:govoplan-campaign.campaign_root.7eccd949": "Campaign root",
"i18n:govoplan-campaign.campaign_sender.b12e8ae2": "Campaign sender", "i18n:govoplan-campaign.campaign_sender.b12e8ae2": "Campaign sender",
+3 -1
View File
@@ -12,6 +12,7 @@ import { getCampaign } from "./api/campaigns";
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget"; import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess"; import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import { campaignWizardDirectories } from "./features/campaigns/wizard/directory";
import "./styles/campaign-workspace.css"; import "./styles/campaign-workspace.css";
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage")); const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
@@ -103,7 +104,8 @@ export const campaignModule: PlatformWebModule = {
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }], { path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
uiCapabilities: { uiCapabilities: {
"dashboard.widgets": campaignDashboardWidgets "dashboard.widgets": campaignDashboardWidgets,
"wizard.directories": campaignWizardDirectories
} }
}; };
@@ -0,0 +1,55 @@
import { readFileSync } from "node:fs";
function read(path) {
return readFileSync(new URL(path, import.meta.url), "utf8");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const workspace = read("../src/features/campaigns/CampaignWorkspace.tsx");
const reviewWizard = read("../src/features/campaigns/wizard/ReviewWizard.tsx");
const sendWizard = read("../src/features/campaigns/wizard/SendWizard.tsx");
const directoryPage = read(
"../src/features/campaigns/wizard/WizardDirectoryPage.tsx"
);
const directory = read("../src/features/campaigns/wizard/directory.ts");
const moduleSource = read("../src/module.ts");
assert(
workspace.includes('path="wizard"')
&& workspace.includes("<WizardDirectoryPage"),
"campaign resources expose the wizard directory route"
);
assert(
reviewWizard.includes("<ReviewSendPage")
&& reviewWizard.includes('initialStageId="workflow-build-review"'),
"the review wizard focuses the canonical build and review stage"
);
assert(
sendWizard.includes("<ReviewSendPage")
&& sendWizard.includes('initialStageId="workflow-send"'),
"the send wizard focuses the canonical send stage"
);
assert(
!reviewWizard.includes("<MetricCard")
&& !sendWizard.includes("queue_dry_run"),
"placeholder review and send surfaces were removed"
);
assert(
directoryPage.includes("wizardEntriesForContext")
&& directoryPage.includes("<WizardDirectory"),
"the page uses the reusable wizard directory contract and renderer"
);
assert(
directory.includes('contextId: "campaign.resource"')
&& directory.includes('id: "configure"')
&& directory.includes('id: "review"')
&& directory.includes('id: "send"'),
"Campaign contributes configure, review, and send entries"
);
assert(
moduleSource.includes('"wizard.directories": campaignWizardDirectories'),
"Campaign publishes its wizard directory capability"
);