Consume governed recipients and add operational checks

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent fa4eb39e0b
commit 82ddc0c34c
15 changed files with 354 additions and 14 deletions
+2 -1
View File
@@ -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"
+17 -1
View File
@@ -256,6 +256,18 @@ export type CampaignRecipientSnapshotItem = {
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 = {
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<string, unknown>;
};
@@ -714,7 +730,7 @@ sourceId: string)
: Promise<CampaignRecipientAddressSourceSnapshot> {
return apiFetch<CampaignRecipientAddressSourceSnapshot>(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" })
});
}
@@ -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<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(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) {
@@ -276,14 +284,26 @@ export default function AddressSourceImportDialog({
</dd>
</div>
<div>
<dt>Recipients</dt>
<dd>{snapshot.recipients.length}</dd>
<dt>Included</dt>
<dd>{snapshot.included_count}</dd>
</div>
<div>
<dt>Excluded</dt>
<dd>{snapshot.excluded_count}</dd>
</div>
<div>
<dt>Revision</dt>
<dd className="mono-small">{snapshot.source_revision}</dd>
</div>
</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)} />
{snapshot.recipients.length > 20 && (
<p className="muted small-note">
@@ -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,
@@ -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.");