diff --git a/webui/package.json b/webui/package.json index b624671..033a756 100644 --- a/webui/package.json +++ b/webui/package.json @@ -27,6 +27,7 @@ "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js", "test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js", + "test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs", "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", diff --git a/webui/src/features/campaigns/CampaignReportPage.tsx b/webui/src/features/campaigns/CampaignReportPage.tsx index f2b7028..8598e96 100644 --- a/webui/src/features/campaigns/CampaignReportPage.tsx +++ b/webui/src/features/campaigns/CampaignReportPage.tsx @@ -561,7 +561,9 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
Postbox targets
{String(detail.job.postbox_target_count ?? 0)}
i18n:govoplan-campaign.imap_state.03b83be0
i18n:govoplan-campaign.attachments.6771ade6
{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}
+
Message SHA-256
{String(detail.job.eml_sha256 ?? "—")}
+ @@ -585,6 +587,114 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: } +type AttachmentEvidenceRow = { + id: string; + rule: string; + status: string; + delivery: string; + file: string; + assetId: string; + versionId: string; + sourceRevision: string; + checksum: string; + sizeBytes: number | null; +}; + +function AttachmentEvidenceSection({ attachments }: {attachments: unknown[];}) { + const rows = attachmentEvidenceRows(attachments); + const columns: DataGridColumn[] = [ + { id: "rule", header: "Rule", width: 180, resizable: true, sortable: true, filterable: true, value: (row) => row.rule }, + { id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => , value: (row) => row.status }, + { id: "delivery", header: "Attachment output", width: 220, resizable: true, filterable: true, value: (row) => row.delivery }, + { id: "file", header: "Frozen file", width: "minmax(240px, 1fr)", minWidth: 220, resizable: true, filterable: true, value: (row) => row.file }, + { + id: "version", + header: "Managed version", + width: 230, + resizable: true, + filterable: true, + value: (row) => `${row.versionId} ${row.assetId} ${row.sourceRevision}`, + render: (row) => + + {row.versionId || "Legacy source"} + {row.assetId && Asset {shortEvidenceId(row.assetId)}} + {row.sourceRevision && Source {shortEvidenceId(row.sourceRevision)}} + + }, + { + id: "checksum", + header: "SHA-256", + width: 170, + filterable: true, + value: (row) => row.checksum, + render: (row) => {row.checksum ? shortEvidenceId(row.checksum) : "—"} + }, + { id: "size", header: "Size", width: 110, align: "right", sortable: true, value: (row) => row.sizeBytes ?? -1, render: (row) => row.sizeBytes === null ? "—" : `${row.sizeBytes.toLocaleString()} B` } + ]; + + return ( +
+

Frozen attachment evidence

+ {rows.length === 0 ? +

No attachment rule or frozen file is recorded for this delivery job.

: + <> +

Exact managed versions and checksums shown here are the immutable files used when this message was built.

+ row.id} + resizeBehavior="free" /> + + } +
+ ); +} + +function attachmentEvidenceRows(attachments: unknown[]): AttachmentEvidenceRow[] { + const rows: AttachmentEvidenceRow[] = []; + attachments.forEach((value, ruleIndex) => { + const attachment = asRecord(value); + const rule = String(attachment.label ?? attachment.attachment_id ?? `Rule ${ruleIndex + 1}`); + const status = String(attachment.status ?? "unknown"); + const zipEnabled = attachment.zip_enabled === true; + const delivery = zipEnabled + ? `ZIP ${String(attachment.zip_filename ?? attachment.zip_archive_id ?? "")}`.trim() + ` (${String(attachment.zip_mode ?? "inherit")})` + : "Direct attachment"; + const managedMatches = Array.isArray(attachment.managed_matches) ? attachment.managed_matches.map(asRecord) : []; + const legacyMatches = Array.isArray(attachment.matches) ? attachment.matches : []; + const matches: Array | string | null> = managedMatches.length > 0 + ? managedMatches + : legacyMatches.length > 0 + ? legacyMatches.map((match) => typeof match === "string" ? match : asRecord(match)) + : [null]; + + matches.forEach((match, matchIndex) => { + const managed = typeof match === "string" || match === null ? {} : match; + const filename = typeof match === "string" + ? match + : String(managed.display_path ?? managed.relative_path ?? managed.filename ?? "No matched file"); + rows.push({ + id: `${ruleIndex}:${matchIndex}:${String(managed.version_id ?? filename)}`, + rule, + status, + delivery, + file: filename, + assetId: String(managed.asset_id ?? ""), + versionId: String(managed.version_id ?? ""), + sourceRevision: String(managed.source_revision ?? ""), + checksum: String(managed.checksum_sha256 ?? ""), + sizeBytes: Number.isFinite(Number(managed.size_bytes)) ? Number(managed.size_bytes) : null + }); + }); + }); + return rows; +} + +function shortEvidenceId(value: string): string { + return value.length > 16 ? `${value.slice(0, 16)}...` : value; +} + function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record[];}) { const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index 99267ec..36ed816 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -24,7 +24,7 @@ import { DismissibleAlert } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { SegmentedControl } from "@govoplan/core-webui"; import { TableActionGroup } from "@govoplan/core-webui"; -import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui"; +import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; @@ -124,6 +124,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A const [addressSources, setAddressSources] = useState([]); const [recipientProfilesPage, setRecipientProfilesPage] = useState(1); const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10); + const [recipientProfilesQuery, setRecipientProfilesQuery] = useState({ sort: null, filters: {} }); const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState(null); const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState(null); const [postboxCatalog, setPostboxCatalog] = useState({ @@ -526,6 +527,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A fieldDefinitions, individualAttachmentBasePaths, zipConfig, + addressFilter: recipientProfilesQuery.filters.recipients ?? "", translateText, openAddressEditor: setRecipientAddressEditorIndex, openPostboxTargetEditor: setPostboxTargetEditorIndex, @@ -549,7 +551,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A setRecipientProfilesPageSize(pageSize); setRecipientProfilesPage(1); } - }} /> + }} + onQueryChange={setRecipientProfilesQuery} /> } @@ -1735,6 +1738,7 @@ type RecipientProfileColumnContext = { fieldDefinitions: ReturnType; individualAttachmentBasePaths: ReturnType; zipConfig: AttachmentZipCollection; + addressFilter: string; translateText: (value: string) => string; openAddressEditor: (index: number) => void; openPostboxTargetEditor: (index: number) => void; @@ -1746,7 +1750,7 @@ type RecipientProfileColumnContext = { removeEntry: (index: number) => void; }; -function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] { +function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] { return [ { id: "number", @@ -1770,6 +1774,7 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod filterable: true, render: (entry, index) => { const summary = recipientAddressSummary(entry, translateText); + const hiddenMatch = hiddenRecipientAddressMatch(entry, addressFilter); const content = ( <> @@ -1777,6 +1782,11 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod {summary.badges.map((badge) => {badge} )} + {hiddenMatch && + + Matched in {hiddenMatch.label}: {hiddenMatch.address} + + } {!locked &&