diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index e1646ce..85c154a 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -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 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 Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed diff --git a/src/govoplan_campaign/backend/reports/campaigns.py b/src/govoplan_campaign/backend/reports/campaigns.py index 72ec876..e09bc06 100644 --- a/src/govoplan_campaign/backend/reports/campaigns.py +++ b/src/govoplan_campaign/backend/reports/campaigns.py @@ -12,6 +12,11 @@ from typing import Any from sqlalchemy import func 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_campaign.backend.db.models import ( @@ -26,6 +31,7 @@ from govoplan_campaign.backend.db.models import ( SendAttempt, ) 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.response_security import ( public_campaign_payload, @@ -301,6 +307,9 @@ class _JobReportAggregate: attachment_ambiguous: int = 0 attachment_status: 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) def add( @@ -315,6 +324,7 @@ class _JobReportAggregate: self._add_delivery_counts(job, retry_max_attempts=retry_max_attempts) self._add_issues(job) self._add_attachments(job) + self._add_eml_evidence(job) self._add_recent_failure(job, include_recent_failures=include_recent_failures) def _add_statuses(self, job: CampaignJob) -> None: @@ -375,6 +385,14 @@ class _JobReportAggregate: if status_value == "ambiguous": 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 = { "skipped", @@ -865,6 +883,12 @@ def _campaign_report_payload( include_diagnostics: bool, review_decisions: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: + postbox_receipts = _campaign_postbox_receipt_summary( + session, + tenant_id=tenant_id, + campaign_id=campaign.id, + version=version, + ) report = { "generated_at": _utcnow_iso(), "campaign": _campaign_report_campaign_payload(campaign), @@ -903,12 +927,7 @@ def _campaign_report_payload( campaign_id=campaign.id, version=version, ), - "postbox_receipts": _campaign_postbox_receipt_summary( - session, - tenant_id=tenant_id, - campaign_id=campaign.id, - version=version, - ), + "postbox_receipts": postbox_receipts, "message_actions": _campaign_message_action_summary( session, tenant_id=tenant_id, @@ -920,6 +939,13 @@ def _campaign_report_payload( pending_job_count=aggregate.pending, include_diagnostics=include_diagnostics, ), + "retention": _campaign_retention_projection( + session, + campaign=campaign, + version=version, + aggregate=aggregate, + postbox_receipts=postbox_receipts, + ), } if include_recent_failures: report["recent_failures"] = _recent_failures( @@ -928,6 +954,221 @@ def _campaign_report_payload( 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( session: Session, *, diff --git a/tests/test_campaign_reports.py b/tests/test_campaign_reports.py index dab5433..ad74b2c 100644 --- a/tests/test_campaign_reports.py +++ b/tests/test_campaign_reports.py @@ -6,7 +6,10 @@ import unittest from unittest.mock import patch from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef +from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem from govoplan_campaign.backend.reports.campaigns import ( + _JobReportAggregate, + _campaign_retention_projection, _campaign_postbox_receipt_summary, _job_evidence_row, _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) +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): def test_evidence_projection(self) -> None: test_job_evidence_row_contains_transport_and_message_evidence() diff --git a/webui/package.json b/webui/package.json index ec18f96..3655b8d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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: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: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": { "typescript": "^5.7.2" diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 97d5b63..d506632 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -295,6 +295,43 @@ export type CampaignPartialValidationResponse = { issues: Record[]; }; +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; + privacy_impact: { + state: string; + summary: string; + retained_categories: string[]; + minimized_categories: string[]; + }; +}; + export type CampaignSummary = { generated_at?: string; selected_version_id?: string | null; @@ -336,6 +373,7 @@ export type CampaignSummary = { attachments?: Record; attempts?: Record; postbox_receipts?: Record; + retention?: CampaignRetentionReport; delivery?: Record; recent_failures?: Record[]; }; diff --git a/webui/src/features/campaigns/CampaignListPage.tsx b/webui/src/features/campaigns/CampaignListPage.tsx index 6ccf58e..2d85b2a 100644 --- a/webui/src/features/campaigns/CampaignListPage.tsx +++ b/webui/src/features/campaigns/CampaignListPage.tsx @@ -129,7 +129,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;}) { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", - width: 70, + width: 120, sticky: "end", align: "right", render: (campaign) => i18n:govoplan-campaign.edit_with_wizard.672a7d1a}> + actions={i18n:govoplan-campaign.edit_with_wizard.672a7d1a}>
patchIdentity("external_id", event.target.value)} /> @@ -308,8 +308,9 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", - width: 150, + width: 180, sticky: "end", + align: "right", render: (version) => { const isCurrent = version.id === currentVersionId; const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version); diff --git a/webui/src/features/campaigns/CampaignReportPage.tsx b/webui/src/features/campaigns/CampaignReportPage.tsx index d02d200..5164d36 100644 --- a/webui/src/features/campaigns/CampaignReportPage.tsx +++ b/webui/src/features/campaigns/CampaignReportPage.tsx @@ -11,7 +11,8 @@ import { sendCampaignJob, sendUnattemptedCampaignJobs, type CampaignJobDetailResponse, - type CampaignJobsResponse } from + type CampaignJobsResponse, + type CampaignRetentionReport } from "../../api/campaigns"; import { Card } 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 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); @@ -500,6 +502,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
Withdrawn / expired copies
{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}
+

i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80

@@ -623,6 +626,102 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: } +function RetentionPrivacyCard({ retention }: { retention?: CampaignRetentionReport }) { + if (!retention) { + return ( + +

i18n:govoplan-campaign.retention_information_is_unavailable.f68cc4d1

+
+ ); + } + 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 ( + +

{retention.privacy_impact.summary}

+ {retention.policy_reason &&

{retention.policy_reason}

} +
+
+
i18n:govoplan-campaign.policy_state.7e955a0d
+
+ +
+
+
+
i18n:govoplan-campaign.policy_sources.a592802f
+
{retention.sources.map((source) => source.label || source.path).filter(Boolean).join(" → ") || "—"}
+
+
+
i18n:govoplan-campaign.raw_json_retention.19ea35f7
+
{policy.store_raw_campaign_json === false ? "i18n:govoplan-campaign.do_not_retain.9fd25c4d" : retentionDuration(policy.raw_campaign_json_retention_days)}
+
+
+
i18n:govoplan-campaign.generated_message_retention.ce6366ca
+
{retentionDuration(policy.generated_eml_retention_days)}
+
+
+
i18n:govoplan-campaign.report_detail_retention.68e30587
+
{retentionDuration(policy.stored_report_detail_retention_days)}
+
+
+
i18n:govoplan-campaign.audit_detail.2a85f905
+
{humanize(policy.audit_detail_level ?? "full")} · {retentionDuration(policy.audit_detail_retention_days)}
+
+ {evidenceRows.map(([label, evidence]) => +
+
{label}
+
+ + · {retentionEvidenceDetail(evidence)} +
+
+ )} +
+
+ ); +} + +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[] }) { const rows = targets.map(asRecord); if (rows.length === 0) return null; diff --git a/webui/src/features/campaigns/CampaignWorkspace.tsx b/webui/src/features/campaigns/CampaignWorkspace.tsx index a98dfd4..c1e6098 100644 --- a/webui/src/features/campaigns/CampaignWorkspace.tsx +++ b/webui/src/features/campaigns/CampaignWorkspace.tsx @@ -18,6 +18,7 @@ const ReviewSendPage = lazy(() => import("./ReviewSendPage")); const CreateWizard = lazy(() => import("./wizard/CreateWizard")); const ReviewWizard = lazy(() => import("./wizard/ReviewWizard")); const SendWizard = lazy(() => import("./wizard/SendWizard")); +const WizardDirectoryPage = lazy(() => import("./wizard/WizardDirectoryPage")); const CampaignJsonView = lazy(() => import("./CampaignJsonView")); const CampaignReportPage = lazy(() => import("./CampaignReportPage")); const CampaignAuditPage = lazy(() => import("./CampaignAuditPage")); @@ -109,9 +110,10 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut } /> } /> } /> + } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index d033d61..32ece6e 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -111,7 +111,17 @@ type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "sen 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 devMailboxCapability = usePlatformUiCapability("mail.devMailbox"); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); @@ -188,6 +198,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState | null>(null); const persistedReview = storedMessageReviewState(version); const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}|${JSON.stringify(persistedReview.issueDecisions)}`; + const initialStageScrollKey = useRef(""); useEffect(() => { setBuiltReviewRows([]); @@ -218,6 +229,20 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings 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(() => { setMessageReviewComplete(persistedReview.inspectionComplete); setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys)); diff --git a/webui/src/features/campaigns/review/WorkflowNavigation.tsx b/webui/src/features/campaigns/review/WorkflowNavigation.tsx index 2397ea8..ec28cf2 100644 --- a/webui/src/features/campaigns/review/WorkflowNavigation.tsx +++ b/webui/src/features/campaigns/review/WorkflowNavigation.tsx @@ -120,7 +120,7 @@ export function WorkflowStage({ } as CSSProperties; return ( -
+