Consume governed recipients and add operational checks
This commit is contained in:
@@ -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.
|
||||||
@@ -135,9 +135,15 @@ just the authoring form:
|
|||||||
5. Confirm attachment behavior when a rule matches no files, ZIP/password
|
5. Confirm attachment behavior when a rule matches no files, ZIP/password
|
||||||
behavior, and any recipient-specific files.
|
behavior, and any recipient-specific files.
|
||||||
6. Record review completion and the inspected message keys through the review
|
6. Record review completion and the inspected message keys through the review
|
||||||
surface. The current baseline does not persist a distinct approve/reject
|
surface. Validation, build, review, and exception evidence records the actor,
|
||||||
decision or review reason. If content, recipients, attachment inputs, owner
|
timestamp, and immutable build token/message digest where applicable. If
|
||||||
context, or non-secret transport identity changes, revalidate and rebuild.
|
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
|
Normal readers and reviewers see business state and safe evidence. Process-local
|
||||||
paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.core.postbox import (
|
from govoplan_core.core.postbox import (
|
||||||
@@ -254,7 +255,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name="addresses.recipient_source",
|
name="addresses.recipient_source",
|
||||||
version_min="0.1.0",
|
version_min="0.1.9",
|
||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
@@ -750,6 +751,17 @@ manifest = ModuleManifest(
|
|||||||
fromlist=["retention_capability"],
|
fromlist=["retention_capability"],
|
||||||
).retention_capability(context),
|
).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,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
)
|
||||||
|
|
||||||
@@ -356,7 +356,15 @@ def validate_campaign_version(
|
|||||||
postbox_available=postbox_integration().available,
|
postbox_available=postbox_integration().available,
|
||||||
)
|
)
|
||||||
report_json = report.model_dump(mode="json")
|
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
|
version.validation_summary = report_json
|
||||||
|
|
||||||
# Replace version-level semantic issues from previous validations.
|
# Replace version-level semantic issues from previous validations.
|
||||||
@@ -642,6 +650,7 @@ def build_campaign_version(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
write_eml: bool = True,
|
write_eml: bool = True,
|
||||||
|
user_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
version, snapshot_path, config = load_version_config(session, version_id)
|
version, snapshot_path, config = load_version_config(session, version_id)
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
@@ -687,6 +696,7 @@ def build_campaign_version(
|
|||||||
entries_by_index=entries_by_index,
|
entries_by_index=entries_by_index,
|
||||||
)
|
)
|
||||||
report_json = _campaign_build_report(result, files)
|
report_json = _campaign_build_report(result, files)
|
||||||
|
report_json["built_by_user_id"] = user_id
|
||||||
version.build_summary = report_json
|
version.build_summary = report_json
|
||||||
editor_state = copy.deepcopy(version.editor_state or {})
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
editor_state.pop("review_send", None)
|
editor_state.pop("review_send", None)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
CampaignRecipientAddressSourcesResponse,
|
CampaignRecipientAddressSourcesResponse,
|
||||||
CampaignRecipientAddressSourceSnapshotRequest,
|
CampaignRecipientAddressSourceSnapshotRequest,
|
||||||
CampaignRecipientAddressSourceSnapshotResponse,
|
CampaignRecipientAddressSourceSnapshotResponse,
|
||||||
|
CampaignRecipientSnapshotExcludedItem,
|
||||||
CampaignRecipientSnapshotItem,
|
CampaignRecipientSnapshotItem,
|
||||||
RecipientImportMappingProfileListResponse,
|
RecipientImportMappingProfileListResponse,
|
||||||
RecipientImportMappingProfilePayload,
|
RecipientImportMappingProfilePayload,
|
||||||
@@ -764,7 +765,11 @@ def snapshot_campaign_recipient_address_source(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
snapshot = getattr(capability, "snapshot")(
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -775,6 +780,15 @@ def snapshot_campaign_recipient_address_source(
|
|||||||
CampaignRecipientSnapshotItem.model_validate(_capability_payload(item))
|
CampaignRecipientSnapshotItem.model_validate(_capability_payload(item))
|
||||||
for item in snapshot_payload.get("recipients", [])
|
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(
|
return CampaignRecipientAddressSourceSnapshotResponse(
|
||||||
source_id=str(snapshot_payload.get("source_id") or ""),
|
source_id=str(snapshot_payload.get("source_id") or ""),
|
||||||
source_label=str(snapshot_payload.get("source_label") 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 ""),
|
source_revision=str(snapshot_payload.get("source_revision") or ""),
|
||||||
generated_at=str(snapshot_payload.get("generated_at") or ""),
|
generated_at=str(snapshot_payload.get("generated_at") or ""),
|
||||||
recipients=recipients,
|
recipients=recipients,
|
||||||
provenance=snapshot_payload.get("provenance")
|
excluded=excluded,
|
||||||
if isinstance(snapshot_payload.get("provenance"), dict)
|
included_count=len(recipients),
|
||||||
else {},
|
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"],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -658,6 +658,7 @@ def build_version(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
write_eml=payload.write_eml if payload else True,
|
write_eml=payload.write_eml if payload else True,
|
||||||
|
user_id=principal.user.id,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
source_id: str = Field(min_length=1)
|
source_id: str = Field(min_length=1)
|
||||||
|
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
class CampaignRecipientSnapshotItem(BaseModel):
|
class CampaignRecipientSnapshotItem(BaseModel):
|
||||||
@@ -389,6 +390,18 @@ class CampaignRecipientSnapshotItem(BaseModel):
|
|||||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
|
||||||
source_id: str
|
source_id: str
|
||||||
source_label: str
|
source_label: str
|
||||||
@@ -396,6 +409,10 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
|
|||||||
source_revision: str
|
source_revision: str
|
||||||
generated_at: str
|
generated_at: str
|
||||||
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
|
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)
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")) == []
|
||||||
|
|
||||||
@@ -78,6 +78,58 @@ class CampaignPartialValidationTests(unittest.TestCase):
|
|||||||
self.assertTrue(response.available)
|
self.assertTrue(response.available)
|
||||||
self.assertEqual(response.candidates[0].email, "ada@example.local")
|
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):
|
class CampaignSemanticValidationTests(unittest.TestCase):
|
||||||
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
|
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
|
||||||
|
|||||||
+2
-1
@@ -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: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"
|
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||||
|
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
@@ -256,6 +256,18 @@ export type CampaignRecipientSnapshotItem = {
|
|||||||
provenance: Record<string, unknown>;
|
provenance: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignRecipientAddressSourceSnapshot = {
|
export type CampaignRecipientAddressSourceSnapshot = {
|
||||||
source_id: string;
|
source_id: string;
|
||||||
source_label: string;
|
source_label: string;
|
||||||
@@ -263,6 +275,10 @@ export type CampaignRecipientAddressSourceSnapshot = {
|
|||||||
source_revision: string;
|
source_revision: string;
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
recipients: CampaignRecipientSnapshotItem[];
|
recipients: CampaignRecipientSnapshotItem[];
|
||||||
|
excluded: CampaignRecipientSnapshotExcludedItem[];
|
||||||
|
included_count: number;
|
||||||
|
excluded_count: number;
|
||||||
|
purpose: string;
|
||||||
provenance: Record<string, unknown>;
|
provenance: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -714,7 +730,7 @@ sourceId: string)
|
|||||||
: Promise<CampaignRecipientAddressSourceSnapshot> {
|
: Promise<CampaignRecipientAddressSourceSnapshot> {
|
||||||
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
|
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ source_id: sourceId })
|
body: JSON.stringify({ source_id: sourceId, purpose: "campaign_delivery" })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ export default function AddressSourceImportDialog({
|
|||||||
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
|
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
|
||||||
[selectedSourceId, sources]
|
[selectedSourceId, sources]
|
||||||
);
|
);
|
||||||
|
const exclusionCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (filteredSources.some((source) => source.source_id === selectedSourceId)) {
|
if (filteredSources.some((source) => source.source_id === selectedSourceId)) {
|
||||||
@@ -276,14 +284,26 @@ export default function AddressSourceImportDialog({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Recipients</dt>
|
<dt>Included</dt>
|
||||||
<dd>{snapshot.recipients.length}</dd>
|
<dd>{snapshot.included_count}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Excluded</dt>
|
||||||
|
<dd>{snapshot.excluded_count}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Revision</dt>
|
<dt>Revision</dt>
|
||||||
<dd className="mono-small">{snapshot.source_revision}</dd>
|
<dd className="mono-small">{snapshot.source_revision}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{snapshot.excluded_count > 0 && (
|
||||||
|
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||||
|
{snapshot.excluded_count} contact point{snapshot.excluded_count === 1 ? " was" : "s were"} excluded by effective communication-governance facts.
|
||||||
|
{exclusionCounts.length > 0 && (
|
||||||
|
<span> {exclusionCounts.map(([reason, count]) => `${reason}: ${count}`).join(" · ")}</span>
|
||||||
|
)}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
||||||
{snapshot.recipients.length > 20 && (
|
{snapshot.recipients.length > 20 && (
|
||||||
<p className="muted small-note">
|
<p className="muted small-note">
|
||||||
|
|||||||
@@ -98,7 +98,27 @@ export function createAddressSourceImportProvenance(
|
|||||||
source_id: snapshot.source_id,
|
source_id: snapshot.source_id,
|
||||||
source_label: snapshot.source_label,
|
source_label: snapshot.source_label,
|
||||||
source_revision: snapshot.source_revision,
|
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,
|
filename: null,
|
||||||
sheet_name: null,
|
sheet_name: null,
|
||||||
encoding: null,
|
encoding: null,
|
||||||
|
|||||||
@@ -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(`<button`) && line.includes(`navigation.${handler}`));
|
||||||
|
assert.ok(
|
||||||
|
buttonLine?.includes("aria-label="),
|
||||||
|
`${handler} must remain an explicitly labelled button`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { file, value } of sources) {
|
||||||
|
if (!value.includes('role="dialog"')) continue;
|
||||||
|
assert.fail(
|
||||||
|
`${path.relative(sourceRoot, file)} implements a local dialog; use Core's Dialog component`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Campaign accessibility structural contract passed.");
|
||||||
Reference in New Issue
Block a user