Consume governed recipients and add operational checks
This commit is contained in:
+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: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"
|
||||
|
||||
@@ -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.");
|
||||
Reference in New Issue
Block a user