From 82ddc0c34c5f3a18cffef41336ffb2233536c30b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 22:48:07 +0200 Subject: [PATCH] Consume governed recipients and add operational checks --- docs/ACCESSIBILITY_REVIEW.md | 39 +++++++++++++ docs/CAMPAIGN_HANDBOOK.md | 12 +++- src/govoplan_campaign/backend/manifest.py | 14 ++++- .../backend/operational_checks.py | 55 +++++++++++++++++++ .../backend/persistence/campaigns.py | 12 +++- .../backend/routes/campaigns.py | 29 ++++++++-- .../backend/routes/versions.py | 1 + src/govoplan_campaign/backend/schemas.py | 17 ++++++ tests/test_operational_checks.py | 18 ++++++ tests/test_partial_validation.py | 52 ++++++++++++++++++ webui/package.json | 3 +- webui/src/api/campaigns.ts | 18 +++++- .../recipients/AddressSourceImportDialog.tsx | 24 +++++++- .../campaigns/utils/addressSourceImport.ts | 22 +++++++- webui/tests/accessibility-contract.test.mjs | 52 ++++++++++++++++++ 15 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 docs/ACCESSIBILITY_REVIEW.md create mode 100644 src/govoplan_campaign/backend/operational_checks.py create mode 100644 tests/test_operational_checks.py create mode 100644 webui/tests/accessibility-contract.test.mjs diff --git a/docs/ACCESSIBILITY_REVIEW.md b/docs/ACCESSIBILITY_REVIEW.md new file mode 100644 index 0000000..671a8bd --- /dev/null +++ b/docs/ACCESSIBILITY_REVIEW.md @@ -0,0 +1,39 @@ +# Campaign Accessibility Review + +The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form, +alert, and segmented-control components. Core owns focus trapping, focus return, +Escape handling, labels, disabled state, and keyboard behavior for those shared +primitives. + +## Repeatable review matrix + +Run this matrix for Campaign overview, wizard, sender and recipients, +attachments, template editing, review and send, operator queue, and reports: + +1. Navigate all actions with Tab and Shift+Tab; focus must remain visible and + follow the visual reading order. +2. Activate buttons and links with Enter, and native buttons with Space. +3. Open every dialog, verify initial focus remains within it, close with Escape, + and verify focus returns to the opener. +4. Use DataGrid sorting, filtering, pagination, row selection, and action menus + without a pointer. +5. At 200 percent browser zoom and a 320 CSS-pixel viewport, verify that content + reflows or scrolls without hiding actions. +6. With reduced motion enabled, verify that workflow state does not depend on + animation. +7. With a screen reader, verify page headings, field labels, validation errors, + workflow states, message navigation, and attachment evidence. + +## Automated structural guard + +`npm run test:accessibility-contract` rejects non-semantic click handlers, +unlabelled icon-only buttons in the message preview, and Campaign-local modal +implementations that bypass Core's `Dialog`. It complements rather than replaces +browser and assistive-technology testing. + +## Known boundary + +Translation keys may be visible in source because Core resolves them at runtime. +The review must use a built application with current language packages. WCAG +conformance is a release-level claim and still requires a bounded manual audit +of the release candidate. diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index 85c154a..d2dccf4 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -135,9 +135,15 @@ just the authoring form: 5. Confirm attachment behavior when a rule matches no files, ZIP/password behavior, and any recipient-specific files. 6. Record review completion and the inspected message keys through the review - surface. The current baseline does not persist a distinct approve/reject - decision or review reason. If content, recipients, attachment inputs, owner - context, or non-secret transport identity changes, revalidate and rebuild. + surface. Validation, build, review, and exception evidence records the actor, + timestamp, and immutable build token/message digest where applicable. If + content, recipients, attachment inputs, owner context, or non-secret + transport identity changes, revalidate and rebuild. + +This evidence is the Campaign input to separation-of-duties policy. Generic +approve/reject chains, delegation, substitutions, escalation, and signatures +belong to the optional Approvals capability. Campaign must not claim an +approval merely because validation, building, or message review completed. Normal readers and reviewers see business state and safe evidence. Process-local paths, storage keys, worker claim tokens, and raw provider diagnostics require diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 15efa97..7da2b71 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -27,6 +27,7 @@ from govoplan_core.core.modules import ( PermissionDefinition, RoleTemplate, ) +from govoplan_core.core.operations import OperationalCheckProviderRegistration from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_core.core.postbox import ( @@ -254,7 +255,7 @@ manifest = ModuleManifest( ), ModuleInterfaceRequirement( name="addresses.recipient_source", - version_min="0.1.0", + version_min="0.1.9", version_max_exclusive="0.2.0", optional=True, ), @@ -750,6 +751,17 @@ manifest = ModuleManifest( fromlist=["retention_capability"], ).retention_capability(context), }, + operational_check_providers=( + OperationalCheckProviderRegistration( + module_id="campaigns", + check_id="campaign.generated_eml_storage", + provider=lambda: __import__( + "govoplan_campaign.backend.operational_checks", + fromlist=["generated_eml_storage_check"], + ).generated_eml_storage_check(), + cache_seconds=60, + ), + ), ) diff --git a/src/govoplan_campaign/backend/operational_checks.py b/src/govoplan_campaign/backend/operational_checks.py new file mode 100644 index 0000000..906827b --- /dev/null +++ b/src/govoplan_campaign/backend/operational_checks.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import os +import secrets +from pathlib import Path + +from govoplan_core.core.operations import OperationalCheck +from govoplan_campaign.backend.persistence.campaigns import BUILD_OUTPUT_DIR + + +def generated_eml_storage_check() -> OperationalCheck: + """Verify the current EML evidence path and report its node-local boundary.""" + + root = Path(BUILD_OUTPUT_DIR) + probe = root / ".govoplan-health" / f"{secrets.token_hex(16)}.probe" + payload = secrets.token_bytes(64) + try: + probe.parent.mkdir(parents=True, mode=0o700, exist_ok=True) + with probe.open("xb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + if probe.read_bytes() != payload: + raise OSError("generated EML persistence returned different bytes") + except OSError as exc: + return OperationalCheck( + id="campaign.generated_eml_storage", + label="Generated Campaign EML evidence", + state="error", + detail=( + "The generated EML evidence path failed a bounded durable-write probe " + f"({type(exc).__name__})." + ), + readiness_critical=True, + metrics={"backend": "node_local_filesystem"}, + ) + finally: + try: + probe.unlink(missing_ok=True) + probe.parent.rmdir() + except OSError: + pass + + return OperationalCheck( + id="campaign.generated_eml_storage", + label="Generated Campaign EML evidence", + state="warning", + detail=( + "Generated EML passed a local write/fsync/read probe, but remains node-local. " + "Use one shared runtime volume and include it in coordinated backups until " + "Campaign evidence is migrated to managed object storage." + ), + metrics={"backend": "node_local_filesystem"}, + ) + diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index 1dfd831..a0a9ee7 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -356,7 +356,15 @@ def validate_campaign_version( postbox_available=postbox_integration().available, ) report_json = report.model_dump(mode="json") - report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count}) + report_json.update( + { + "ok": report.ok, + "error_count": report.error_count, + "warning_count": report.warning_count, + "validated_at": datetime.now(UTC).isoformat(), + "validated_by_user_id": user_id, + } + ) version.validation_summary = report_json # Replace version-level semantic issues from previous validations. @@ -642,6 +650,7 @@ def build_campaign_version( tenant_id: str, version_id: str, write_eml: bool = True, + user_id: str | None = None, ) -> dict[str, Any]: version, snapshot_path, config = load_version_config(session, version_id) campaign = session.get(Campaign, version.campaign_id) @@ -687,6 +696,7 @@ def build_campaign_version( entries_by_index=entries_by_index, ) report_json = _campaign_build_report(result, files) + report_json["built_by_user_id"] = user_id version.build_summary = report_json editor_state = copy.deepcopy(version.editor_state or {}) editor_state.pop("review_send", None) diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py index 7081a36..eac8b3a 100644 --- a/src/govoplan_campaign/backend/routes/campaigns.py +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -19,6 +19,7 @@ from govoplan_campaign.backend.schemas import ( CampaignRecipientAddressSourcesResponse, CampaignRecipientAddressSourceSnapshotRequest, CampaignRecipientAddressSourceSnapshotResponse, + CampaignRecipientSnapshotExcludedItem, CampaignRecipientSnapshotItem, RecipientImportMappingProfileListResponse, RecipientImportMappingProfilePayload, @@ -764,7 +765,11 @@ def snapshot_campaign_recipient_address_source( ) try: snapshot = getattr(capability, "snapshot")( - session, principal, source_id=payload.source_id + session, + principal, + source_id=payload.source_id, + purpose=payload.purpose, + requested_channels=("email",), ) except ValueError as exc: raise HTTPException( @@ -775,6 +780,15 @@ def snapshot_campaign_recipient_address_source( CampaignRecipientSnapshotItem.model_validate(_capability_payload(item)) for item in snapshot_payload.get("recipients", []) ] + excluded = [ + CampaignRecipientSnapshotExcludedItem.model_validate(_capability_payload(item)) + for item in snapshot_payload.get("excluded", []) + ] + provenance = ( + snapshot_payload.get("provenance") + if isinstance(snapshot_payload.get("provenance"), dict) + else {} + ) return CampaignRecipientAddressSourceSnapshotResponse( source_id=str(snapshot_payload.get("source_id") or ""), source_label=str(snapshot_payload.get("source_label") or ""), @@ -782,9 +796,16 @@ def snapshot_campaign_recipient_address_source( source_revision=str(snapshot_payload.get("source_revision") or ""), generated_at=str(snapshot_payload.get("generated_at") or ""), recipients=recipients, - provenance=snapshot_payload.get("provenance") - if isinstance(snapshot_payload.get("provenance"), dict) - else {}, + excluded=excluded, + included_count=len(recipients), + excluded_count=len(excluded), + purpose=str(snapshot_payload.get("purpose") or payload.purpose), + provenance={ + **provenance, + "campaign_id": campaign_id, + "purpose": payload.purpose, + "requested_channels": ["email"], + }, ) diff --git a/src/govoplan_campaign/backend/routes/versions.py b/src/govoplan_campaign/backend/routes/versions.py index b45106e..1005440 100644 --- a/src/govoplan_campaign/backend/routes/versions.py +++ b/src/govoplan_campaign/backend/routes/versions.py @@ -658,6 +658,7 @@ def build_version( tenant_id=principal.tenant_id, version_id=version_id, write_eml=payload.write_eml if payload else True, + user_id=principal.user.id, ) audit_from_principal( session, diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index fffd8a0..c4b240a 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -378,6 +378,7 @@ class CampaignRecipientAddressSourceSnapshotRequest(BaseModel): model_config = ConfigDict(extra="forbid") source_id: str = Field(min_length=1) + purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120) class CampaignRecipientSnapshotItem(BaseModel): @@ -389,6 +390,18 @@ class CampaignRecipientSnapshotItem(BaseModel): provenance: dict[str, Any] = Field(default_factory=dict) +class CampaignRecipientSnapshotExcludedItem(BaseModel): + contact_id: str + display_name: str + channel: str + target: str + contact_point_id: str | None = None + status: str + reason_code: str | None = None + explanation: str | None = None + provenance: dict[str, Any] = Field(default_factory=dict) + + class CampaignRecipientAddressSourceSnapshotResponse(BaseModel): source_id: str source_label: str @@ -396,6 +409,10 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel): source_revision: str generated_at: str recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list) + excluded: list[CampaignRecipientSnapshotExcludedItem] = Field(default_factory=list) + included_count: int = 0 + excluded_count: int = 0 + purpose: str = "campaign_delivery" provenance: dict[str, Any] = Field(default_factory=dict) diff --git a/tests/test_operational_checks.py b/tests/test_operational_checks.py new file mode 100644 index 0000000..cfe4041 --- /dev/null +++ b/tests/test_operational_checks.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from unittest.mock import patch + +from govoplan_campaign.backend.operational_checks import generated_eml_storage_check + + +def test_generated_eml_probe_reports_node_local_boundary(tmp_path) -> None: + with patch( + "govoplan_campaign.backend.operational_checks.BUILD_OUTPUT_DIR", + tmp_path, + ): + result = generated_eml_storage_check() + + assert result.state == "warning" + assert "node-local" in result.detail + assert list(tmp_path.rglob("*.probe")) == [] + diff --git a/tests/test_partial_validation.py b/tests/test_partial_validation.py index 845ba9d..c5c0ea7 100644 --- a/tests/test_partial_validation.py +++ b/tests/test_partial_validation.py @@ -78,6 +78,58 @@ class CampaignPartialValidationTests(unittest.TestCase): self.assertTrue(response.available) self.assertEqual(response.candidates[0].email, "ada@example.local") + def test_address_source_snapshot_preserves_governance_exclusions(self) -> None: + class RecipientSourceCapability: + def snapshot(self, _session, _principal, **kwargs): + self.kwargs = kwargs + return { + "source_id": "addresses:address_book:book-1", + "source_label": "Residents", + "source_kind": "local", + "source_revision": "revision-2", + "generated_at": "2026-07-31T10:00:00+00:00", + "purpose": "campaign_delivery", + "recipients": [ + { + "contact_id": "contact-1", + "display_name": "Ada Lovelace", + "email": "ada@example.local", + "fields": {}, + "provenance": {"channel_decision": {"governance_state": "opted_in"}}, + } + ], + "excluded": [ + { + "contact_id": "contact-2", + "display_name": "Grace Hopper", + "channel": "email", + "target": "grace@example.local", + "status": "suppressed", + "reason_code": "addresses.channel.opted_out", + "provenance": {"channel_decision": {"governance_state": "opted_out"}}, + } + ], + "provenance": {"governance_applied": True}, + } + + capability = RecipientSourceCapability() + payload = router.CampaignRecipientAddressSourceSnapshotRequest( + source_id="addresses:address_book:book-1" + ) + with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object( + router, "_registry_capability", return_value=capability + ): + response = router.snapshot_campaign_recipient_address_source( + "campaign-1", payload, session=object(), principal=object() + ) + + self.assertEqual(capability.kwargs["purpose"], "campaign_delivery") + self.assertEqual(capability.kwargs["requested_channels"], ("email",)) + self.assertEqual(response.included_count, 1) + self.assertEqual(response.excluded_count, 1) + self.assertEqual(response.excluded[0].reason_code, "addresses.channel.opted_out") + self.assertTrue(response.provenance["governance_applied"]) + class CampaignSemanticValidationTests(unittest.TestCase): def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None: diff --git a/webui/package.json b/webui/package.json index 3655b8d..0f531e8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -32,7 +32,8 @@ "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:wizards": "node tests/wizard-directory-ui-structure.test.mjs" + "test:wizards": "node tests/wizard-directory-ui-structure.test.mjs", + "test:accessibility-contract": "node tests/accessibility-contract.test.mjs" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index d506632..638b9c4 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -256,6 +256,18 @@ export type CampaignRecipientSnapshotItem = { provenance: Record; }; +export type CampaignRecipientSnapshotExcludedItem = { + contact_id: string; + display_name: string; + channel: string; + target: string; + contact_point_id?: string | null; + status: string; + reason_code?: string | null; + explanation?: string | null; + provenance: Record; +}; + export type CampaignRecipientAddressSourceSnapshot = { source_id: string; source_label: string; @@ -263,6 +275,10 @@ export type CampaignRecipientAddressSourceSnapshot = { source_revision: string; generated_at: string; recipients: CampaignRecipientSnapshotItem[]; + excluded: CampaignRecipientSnapshotExcludedItem[]; + included_count: number; + excluded_count: number; + purpose: string; provenance: Record; }; @@ -714,7 +730,7 @@ sourceId: string) : Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, { method: "POST", - body: JSON.stringify({ source_id: sourceId }) + body: JSON.stringify({ source_id: sourceId, purpose: "campaign_delivery" }) }); } diff --git a/webui/src/features/campaigns/recipients/AddressSourceImportDialog.tsx b/webui/src/features/campaigns/recipients/AddressSourceImportDialog.tsx index 6d8629e..9ae8209 100644 --- a/webui/src/features/campaigns/recipients/AddressSourceImportDialog.tsx +++ b/webui/src/features/campaigns/recipients/AddressSourceImportDialog.tsx @@ -74,6 +74,14 @@ export default function AddressSourceImportDialog({ () => sources.find((source) => source.source_id === selectedSourceId) ?? null, [selectedSourceId, sources] ); + const exclusionCounts = useMemo(() => { + const counts = new Map(); + for (const item of snapshot?.excluded ?? []) { + const reason = item.reason_code || item.status || "excluded"; + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + return [...counts.entries()].sort((left, right) => right[1] - left[1]); + }, [snapshot]); useEffect(() => { if (filteredSources.some((source) => source.source_id === selectedSourceId)) { @@ -276,14 +284,26 @@ export default function AddressSourceImportDialog({
-
Recipients
-
{snapshot.recipients.length}
+
Included
+
{snapshot.included_count}
+
+
+
Excluded
+
{snapshot.excluded_count}
Revision
{snapshot.source_revision}
+ {snapshot.excluded_count > 0 && ( + + {snapshot.excluded_count} contact point{snapshot.excluded_count === 1 ? " was" : "s were"} excluded by effective communication-governance facts. + {exclusionCounts.length > 0 && ( + {exclusionCounts.map(([reason, count]) => `${reason}: ${count}`).join(" ยท ")} + )} + + )} {snapshot.recipients.length > 20 && (

diff --git a/webui/src/features/campaigns/utils/addressSourceImport.ts b/webui/src/features/campaigns/utils/addressSourceImport.ts index 45878f4..e94d652 100644 --- a/webui/src/features/campaigns/utils/addressSourceImport.ts +++ b/webui/src/features/campaigns/utils/addressSourceImport.ts @@ -98,7 +98,27 @@ export function createAddressSourceImportProvenance( source_id: snapshot.source_id, source_label: snapshot.source_label, source_revision: snapshot.source_revision, - source_provenance: snapshot.provenance, + source_provenance: { + ...snapshot.provenance, + generated_at: snapshot.generated_at, + purpose: snapshot.purpose, + included_count: snapshot.included_count, + excluded_count: snapshot.excluded_count, + included_decisions: snapshot.recipients.map((recipient) => ({ + contact_id: recipient.contact_id, + email: recipient.email, + provenance: recipient.provenance + })), + exclusions: snapshot.excluded.map((item) => ({ + contact_id: item.contact_id, + channel: item.channel, + target: item.target, + status: item.status, + reason_code: item.reason_code, + explanation: item.explanation, + provenance: item.provenance + })) + }, filename: null, sheet_name: null, encoding: null, diff --git a/webui/tests/accessibility-contract.test.mjs b/webui/tests/accessibility-contract.test.mjs new file mode 100644 index 0000000..5023a35 --- /dev/null +++ b/webui/tests/accessibility-contract.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const sourceRoot = path.resolve("src"); + +function sourceFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(target); + return /\.(tsx|ts)$/.test(entry.name) ? [target] : []; + }); +} + +const sources = sourceFiles(sourceRoot).map((file) => ({ + file, + value: fs.readFileSync(file, "utf8"), +})); + +const nonSemanticClicks = sources.flatMap(({ file, value }) => + [...value.matchAll(/<(div|span|li|tr)\b[^>]*\bonClick\s*=/g)].map( + (match) => `${path.relative(sourceRoot, file)}:${match.index}`, + ), +); +assert.deepEqual( + nonSemanticClicks, + [], + `Use a semantic button/link instead of clickable layout elements: ${nonSemanticClicks.join(", ")}`, +); + +const preview = fs.readFileSync( + path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"), + "utf8", +); +for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) { + const buttonLine = preview + .split("\n") + .find((line) => line.includes(`