diff --git a/tests/test_retention.py b/tests/test_retention.py new file mode 100644 index 0000000..737d77c --- /dev/null +++ b/tests/test_retention.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +from govoplan_campaign.backend.retention import ( + _apply_eml_retention, + _apply_raw_json_retention, + _apply_report_detail_retention, +) + + +def _querying(items: list[object]) -> MagicMock: + session = MagicMock() + session.query.return_value.order_by.return_value.all.return_value = items + session.query.return_value.filter.return_value.order_by.return_value.all.return_value = items + return session + + +def test_raw_json_retention_preserves_hash_for_final_version() -> None: + now = datetime.now(timezone.utc) + version = SimpleNamespace( + campaign_id="campaign-1", + updated_at=now - timedelta(days=10), + workflow_state="completed", + raw_json={"version": "1.0", "campaign": {"name": "Sensitive"}}, + schema_version="1.0", + execution_snapshot={"campaign_json_sha256": "sealed-hash"}, + ) + session = _querying([version]) + policy = SimpleNamespace( + store_raw_campaign_json=True, + raw_campaign_json_retention_days=1, + ) + + result = _apply_raw_json_retention( + session, + dry_run=False, + now=now, + policy_for_campaign_id=lambda _campaign_id: policy, + ) + + assert result["redacted"] == 1 + assert version.raw_json == { + "version": "1.0", + "_retention": { + "raw_json_redacted": True, + "redacted_at": now.isoformat(), + "original_sha256": "sealed-hash", + }, + } + session.add.assert_called_once_with(version) + + +def test_raw_json_retention_does_not_redact_unfinished_version() -> None: + now = datetime.now(timezone.utc) + original = {"version": "1.0", "campaign": {"name": "Editable"}} + version = SimpleNamespace( + campaign_id="campaign-1", + updated_at=now - timedelta(days=10), + workflow_state="editing", + raw_json=original, + schema_version="1.0", + execution_snapshot=None, + ) + session = _querying([version]) + policy = SimpleNamespace( + store_raw_campaign_json=True, + raw_campaign_json_retention_days=1, + ) + + result = _apply_raw_json_retention( + session, + dry_run=False, + now=now, + policy_for_campaign_id=lambda _campaign_id: policy, + ) + + assert result["skipped_not_final"] == 1 + assert version.raw_json is original + session.add.assert_not_called() + + +def test_report_detail_retention_keeps_aggregate_evidence() -> None: + now = datetime.now(timezone.utc) + version = SimpleNamespace( + campaign_id="campaign-1", + updated_at=now - timedelta(days=10), + validation_summary={"ready": 4, "issues": [{"entry_id": "secret"}]}, + build_summary={"built": 3, "jobs": [{"recipient": "person@example.test"}]}, + ) + session = _querying([version]) + policy = SimpleNamespace(stored_report_detail_retention_days=1) + + result = _apply_report_detail_retention( + session, + dry_run=False, + now=now, + policy_for_campaign_id=lambda _campaign_id: policy, + ) + + assert result["summaries_redacted"] == 2 + assert version.validation_summary["ready"] == 4 + assert "issues" not in version.validation_summary + assert version.build_summary["built"] == 3 + assert "jobs" not in version.build_summary + assert version.validation_summary["_retention"]["report_detail_redacted"] is True + session.add.assert_called_once_with(version) + + +def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None: + now = datetime.now(timezone.utc) + eml_path = tmp_path / "message.eml" + eml_path.write_bytes(b"message") + job = SimpleNamespace( + campaign_id="campaign-1", + updated_at=now - timedelta(days=10), + queue_status="draft", + send_status="smtp_accepted", + imap_status="appended", + eml_local_path=str(eml_path), + eml_storage_key="campaigns/message.eml", + ) + session = _querying([job]) + policy = SimpleNamespace(generated_eml_retention_days=1) + + result = _apply_eml_retention( + session, + dry_run=False, + now=now, + policy_for_campaign_id=lambda _campaign_id: policy, + ) + + assert result["files_deleted"] == 1 + assert result["metadata_cleared"] == 1 + assert not eml_path.exists() + assert job.eml_local_path is None + assert job.eml_storage_key is None + session.add.assert_called_once_with(job) diff --git a/webui/package.json b/webui/package.json index 9192962..ec18f96 100644 --- a/webui/package.json +++ b/webui/package.json @@ -19,9 +19,9 @@ "peerDependencies": { "@govoplan/core-webui": "^0.1.14", "lucide-react": "^1.23.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": ">=7.18.2 <8" + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" }, "scripts": { "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", diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx index d63d0ba..0962857 100644 --- a/webui/src/features/campaigns/AttachmentsDataPage.tsx +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -414,7 +414,7 @@ type ZipArchiveColumnContext = { function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn[] { return [ { - id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start", + id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start", render: (archive, index) => - - }> - -
- {validationError && {validationError}} - {columns.map((column) => - { - setDraftValues((current) => ({ ...current, [column.key]: addresses })); - setValidationError(""); - }} - onPasteAddresses={applyPastedAddresses} /> - - )} -
- ); -} - -function RecipientAddressEditorDialog({ - entry, - index, - locked, - recipientsSection, - entryDefaults, - onSave, - onClose -}: RecipientAddressEditorDialogProps) { - const { translateText } = usePlatformLanguage(); - const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key)); - const availableKeys = new Set(availableColumns.map((column) => column.key)); - const [draftValues, setDraftValues] = useState(() => - Object.fromEntries(availableColumns.map((column) => [column.key, getEntryAddresses(entry, column.key).map(cloneMailboxAddress)])) - ); - const [draftMerges, setDraftMerges] = useState(() => - Object.fromEntries( - availableColumns - .filter((column) => column.mergeKey) - .map((column) => [column.mergeKey!, getEntryMerge(entry, entryDefaults, column.mergeKey!)]) - ) - ); - const [validationError, setValidationError] = useState(""); - - function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean { - const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key)); - if (groups.length === 0) return false; - setDraftValues((current) => mergeAddressGroups(current, groups)); - setValidationError(""); - return true; - } - - function save() { - const prepared = prepareAddressValues(availableColumns, draftValues, translateText); - if (prepared.error) { - setValidationError(prepared.error); - return; - } - onSave(prepared.values, draftMerges); - } - - return ( - - - - }> - -
- {validationError && {validationError}} - {availableColumns.length === 0 && -

No individual recipient address fields are enabled.

- } - {availableColumns.map((column) => - { - setDraftValues((current) => ({ ...current, [column.key]: addresses })); - setValidationError(""); - }} - onMergeChange={column.mergeKey ? (merge) => { - setDraftMerges((current) => ({ ...current, [column.mergeKey!]: merge })); - } : undefined} - onPasteAddresses={applyPastedAddresses} /> - - )} -
-
); -} - -type RecipientAddressCategoryEditorProps = { - column: EntryAddressColumn; - addresses: MailboxAddress[]; - merge: boolean; - locked: boolean; - onAddressesChange: (addresses: MailboxAddress[]) => void; - onMergeChange?: (merge: boolean) => void; - onPasteAddresses?: (targetKey: AddressFieldKey, text: string) => boolean; -}; - -function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAddressesChange, onMergeChange, onPasteAddresses }: RecipientAddressCategoryEditorProps) { - const { translateText } = usePlatformLanguage(); - const [draftAddresses, setDraftAddresses] = useState(() => normalizeEditorAddressRows(column, addresses)); - const canAdd = !locked && column.allowMultiple; - const translatedAddLabel = translateText(column.addLabel); - const translatedMoveUpLabel = "Move address up"; - const translatedMoveDownLabel = "Move address down"; - const translatedRemoveLabel = "Remove address"; - - useEffect(() => { - setDraftAddresses(normalizeEditorAddressRows(column, addresses)); - }, [addresses, column]); - - function commitAddresses(nextAddresses: MailboxAddress[]) { - setDraftAddresses(nextAddresses); - onAddressesChange(nextAddresses); - } - - function patchAddress(addressIndex: number, patch: Partial) { - commitAddresses(draftAddresses.map((address, currentIndex) => currentIndex === addressIndex ? { ...address, ...patch } : address)); - } - - function addAddress(afterIndex = draftAddresses.length - 1) { - if (!column.allowMultiple) return; - const insertIndex = Math.max(0, Math.min(afterIndex + 1, draftAddresses.length)); - const nextAddresses = [ - ...draftAddresses.slice(0, insertIndex), - { name: "", email: "" }, - ...draftAddresses.slice(insertIndex)]; - - commitAddresses(nextAddresses); - } - - function removeAddress(addressIndex: number) { - commitAddresses(draftAddresses.filter((_, currentIndex) => currentIndex !== addressIndex)); - } - - function moveAddress(addressIndex: number, targetIndex: number) { - if (addressIndex === targetIndex) return; - commitAddresses(moveArrayItem(draftAddresses, addressIndex, targetIndex)); - } - - function handlePaste(event: ClipboardEvent) { - if (locked || !onPasteAddresses) return; - const text = event.clipboardData.getData("text/plain"); - if (onPasteAddresses(column.key, text)) event.preventDefault(); - } - - return ( -
-
-

{column.label}

-
- {onMergeChange ? - : - null} -
-
- -
- {column.allowMultiple && draftAddresses.length === 0 && -
-

{column.emptyText}

-
- } - {draftAddresses.map((address, addressIndex) => -
- patchAddress(addressIndex, { name: event.target.value })} /> - patchAddress(addressIndex, { email: event.target.value })} /> - {column.allowMultiple && -
- )} -
-
); - -} - -type RecipientImportDialogProps = { - settings: ApiSettings; - campaignId: string; - existingEntries: Record[]; - existingFields: ReturnType; - defaultAttachmentBasePath: AttachmentBasePath | null; - onCancel: () => void; - onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void; -}; - -type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files"; - -const allRecipientImportSteps: Array<{id: RecipientImportStepId;label: string;}> = [ -{ id: "upload", label: "i18n:govoplan-campaign.upload.8bdf057f" }, -{ id: "parse", label: "i18n:govoplan-campaign.parse.b7e45a36" }, -{ id: "map", label: "i18n:govoplan-campaign.map.ab478f3e" }, -{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" }, -{ id: "files", label: "i18n:govoplan-campaign.files.6ce6c512" }]; - - -const recipientColumnKindOptions: Array<{value: RecipientColumnKind;label: string;}> = [ -{ value: "ignore", label: "i18n:govoplan-campaign.ignore.98f55db5" }, -{ value: "id", label: "i18n:govoplan-campaign.id.89f89c02" }, -{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, -{ value: "name", label: "i18n:govoplan-campaign.display_name.c7874aaa" }, -{ value: "from", label: "i18n:govoplan-campaign.from.3f66052a" }, -{ value: "to", label: "i18n:govoplan-campaign.to.ae79ea1e" }, -{ value: "cc", label: "i18n:govoplan-campaign.cc.c5a976de" }, -{ value: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3" }, -{ value: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667" }, -{ value: "field", label: "i18n:govoplan-campaign.existing_field.681f2b09" }, -{ value: "new_field", label: "i18n:govoplan-campaign.new_field.57fc8a2e" }, -{ value: "attachment_pattern", label: "i18n:govoplan-campaign.attachment_pattern.59649690" }]; - - -const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55; - -const RECIPIENT_IMPORT_ENCODINGS = [ -{ value: "utf-8", label: "i18n:govoplan-campaign.utf_8.663b90c8" }, -{ value: "windows-1252", label: "i18n:govoplan-campaign.windows_1252.6944986d" }, -{ value: "iso-8859-1", label: "i18n:govoplan-campaign.iso_8859_1.e4d77380" }, -{ value: "utf-16le", label: "i18n:govoplan-campaign.utf_16_le.8d74294c" }, -{ value: "utf-16be", label: "i18n:govoplan-campaign.utf_16_be.1de115c7" }]; - - -export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) { - const filesFileExplorer = usePlatformUiCapability("files.fileExplorer"); - const fileLinkCapability = useMemo(() => { - if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null; - return { - listFiles: filesFileExplorer.listFiles, - resolveFilePatterns: filesFileExplorer.resolveFilePatterns, - shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget - }; - }, [filesFileExplorer]); - const recipientImportSteps = useMemo( - () => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"), - [fileLinkCapability] - ); - const [activeStep, setActiveStep] = useState("upload"); - const [csvText, setCsvText] = useState(""); - const [sourceType, setSourceType] = useState("text"); - const [filename, setFilename] = useState(""); - const [fileBuffer, setFileBuffer] = useState(null); - const [encoding, setEncoding] = useState("utf-8"); - const [workbookSheets, setWorkbookSheets] = useState([]); - const [selectedSheetName, setSelectedSheetName] = useState(""); - const [mode, setMode] = useState("append"); - const [delimiter, setDelimiter] = useState("auto"); - const [headerRows, setHeaderRows] = useState(1); - const [quoted, setQuoted] = useState(true); - const [valueSeparators, setValueSeparators] = useState(",;|"); - const [mappings, setMappings] = useState([]); - const [mappingProfiles, setMappingProfiles] = useState([]); - const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false); - const [mappingProfileError, setMappingProfileError] = useState(""); - const [mappingProfileNotice, setMappingProfileNotice] = useState(""); - const [fileError, setFileError] = useState(""); - const [fileLinkResolution, setFileLinkResolution] = useState(null); - const [fileLinkResolving, setFileLinkResolving] = useState(false); - const [fileLinking, setFileLinking] = useState(false); - const [fileLinkError, setFileLinkError] = useState(""); - const [fileLinkNotice, setFileLinkNotice] = useState(""); - const selectedSheet = useMemo( - () => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null, - [selectedSheetName, workbookSheets] - ); - const hasContent = sourceType === "xlsx" ? - Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim()))) : - csvText.trim().length > 0; - const table = useMemo( - () => { - if (!hasContent) return null; - if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null; - return buildImportTable(csvText, { delimiter, headerRows, quoted }); - }, - [csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType] - ); - const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : ""; - const mappingProfileMatches = useMemo( - () => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [], - [mappingProfiles, table] - ); - const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim()))); - const preview = useMemo( - () => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null, - [existingEntries, existingFields, mappings, table, valueSeparators] - ); - const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep); - const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null; - const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null; - const isLastStep = activeStepIndex === recipientImportSteps.length - 1; - const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length; - const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0; - const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]); - - useEffect(() => { - let cancelled = false; - setMappingProfileError(""); - void listRecipientImportMappingProfiles(settings). - then((profiles) => { - if (!cancelled) setMappingProfiles(profiles); - }). - catch((err) => { - if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err)); - }); - return () => {cancelled = true;}; - }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); - - useEffect(() => { - if (!fileBuffer || sourceType !== "csv") return; - try { - setCsvText(decodeImportText(fileBuffer, encoding)); - setFileError(""); - } catch (err) { - setCsvText(""); - setFileError(err instanceof Error ? err.message : String(err)); - } - }, [encoding, fileBuffer, sourceType]); - - useEffect(() => { - setMappingManuallyChanged(false); - }, [tableHeaderKey]); - - useEffect(() => { - if (!table) { - setMappings([]); - setMappingProfileNotice(""); - return; - } - if (mappingManuallyChanged) return; - const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch); - if (automaticMatch) { - setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields)); - setValueSeparators(automaticMatch.profile.valueSeparators || ",;|"); - setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch)); - return; - } - setMappings(defaultColumnMappings(table.headers, existingFields)); - setMappingProfileNotice(""); - }, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]); - - useEffect(() => { - if (!hasContent) setActiveStep("upload"); - }, [hasContent]); - - useEffect(() => { - if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview"); - }, [activeStep, fileLinkCapability]); - - useEffect(() => { - if (activeStep === "files" && fileLinkCapability) void refreshFileLinks(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]); - - async function readFile(file: File | undefined) { - if (!file) return; - setFilename(file.name); - setFileError(""); - setWorkbookSheets([]); - setSelectedSheetName(""); - try { - const buffer = await file.arrayBuffer(); - if (isXlsxFile(file)) { - const sheets = await xlsxSheetsFromArrayBuffer(buffer); - if (sheets.length === 0) { - throw new Error("i18n:govoplan-campaign.workbook_contains_no_readable_sheets.02c18603"); - } - setSourceType("xlsx"); - setFileBuffer(null); - setCsvText(""); - setWorkbookSheets(sheets); - setSelectedSheetName(sheets[0]?.name ?? ""); - } else { - setSourceType("csv"); - setFileBuffer(buffer); - setCsvText(decodeImportText(buffer, encoding)); - } - setActiveStep("parse"); - } catch (err) { - setFileError(err instanceof Error ? err.message : String(err)); - } - } - - function canOpenStep(stepId: RecipientImportStepId): boolean { - if (stepId === "upload") return true; - if (stepId === "parse") return hasContent; - if (stepId === "map") return parseReady; - if (stepId === "files" && !fileLinkCapability) return false; - return parseReady && mappings.length > 0; - } - - function stepStatus(stepId: RecipientImportStepId): string { - if (stepId === "upload") return filename || (hasContent ? "i18n:govoplan-campaign.pasted_data.5b58080b" : "i18n:govoplan-campaign.waiting.33d30632"); - if (stepId === "parse") return table ? `${table.dataRows.length} rows` : "i18n:govoplan-campaign.set_parsing.be7b05fd"; - if (stepId === "map") return `${mappedColumnCount} mapped`; - if (stepId === "preview") return preview ? `${preview.validCount} valid` : "i18n:govoplan-campaign.review.e29a79fe"; - if (!preview || preview.patternCount === 0) return "i18n:govoplan-campaign.no_patterns.5e1e46fa"; - if (fileLinkResolving) return "i18n:govoplan-campaign.checking.97876b83"; - if (fileLinkResolution) return i18nMessage("i18n:govoplan-campaign.value_to_link.50cdc659", { value0: fileLinkResolution.linkableFiles.length }); - if (fileLinkError) return "i18n:govoplan-campaign.needs_setup.522ebae4"; - return `${renderedPatterns.length} patterns`; - } - - function goNext() { - if (nextStep && canOpenStep(nextStep)) setActiveStep(nextStep); - } - - async function refreshFileLinks() { - setFileLinkNotice(""); - if (!fileLinkCapability) { - setFileLinkResolution(null); - setFileLinkError(""); - return; - } - if (!preview || preview.patternCount === 0) { - setFileLinkResolution(null); - setFileLinkError(""); - return; - } - if (!defaultAttachmentBasePath?.source) { - setFileLinkResolution(null); - setFileLinkError("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.9c528afa"); - return; - } - setFileLinkResolving(true); - setFileLinkError(""); - try { - setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview)); - } catch (err) { - setFileLinkResolution(null); - setFileLinkError(err instanceof Error ? err.message : String(err)); - } finally { - setFileLinkResolving(false); - } - } - - async function linkImportedFiles() { - if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return; - setFileLinking(true); - setFileLinkError(""); - setFileLinkNotice(""); - try { - const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles); - await refreshFileLinks(); - setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`); - } catch (err) { - setFileLinkError(err instanceof Error ? err.message : String(err)); - } finally { - setFileLinking(false); - } - } - - function rememberCurrentMappingProfile() { - if (!table || mappings.length === 0) return; - const tableSnapshot = table; - const mappingsSnapshot = mappings; - const fallbackProfiles = mappingProfiles; - const filenameSnapshot = filename; - const parseOptions = { headerRows, quoted }; - const valueSeparatorsSnapshot = valueSeparators; - - void (async () => { - try { - const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles); - const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles); - const draftProfile = buildRecipientMappingProfile({ - id: reusableProfile?.id, - name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot), - table: tableSnapshot, - mappings: mappingsSnapshot, - parseOptions, - valueSeparators: valueSeparatorsSnapshot, - createdAt: reusableProfile?.createdAt, - updatedAt: new Date().toISOString() - }); - const payload = mappingProfilePayload(draftProfile); - await (reusableProfile ? - updateRecipientImportMappingProfile(settings, reusableProfile.id, payload) : - createRecipientImportMappingProfile(settings, payload)); - } catch { - - - - - - // Import acceptance must not be blocked by passive profile learning. - }})();}function confirmRecipientImport(nextPreview: RecipientImportPreview) {rememberCurrentMappingProfile(); - const provenance = createRecipientImportProvenance({ - preview: nextPreview, - mappings, - mode, - sourceType, - filename, - sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null, - encoding: sourceType === "xlsx" ? null : encoding, - delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter), - headerRows, - quoted: sourceType === "xlsx" ? null : quoted, - valueSeparators - }); - onImport(nextPreview, mode, provenance); - } - - function replaceColumnMapping(nextMapping: RecipientColumnMapping) { - const columnCount = table?.headers.length ?? 0; - setMappingManuallyChanged(true); - setMappings((current) => { - const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping])); - byColumn.set(nextMapping.columnIndex, nextMapping); - return Array.from({ length: columnCount }, (_value, columnIndex) => byColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" }); - }); - } - - function changeColumnKind(columnIndex: number, kind: RecipientColumnKind) { - const header = table?.headers[columnIndex] ?? `Column ${columnIndex + 1}`; - const current = mappings.find((mapping) => mapping.columnIndex === columnIndex); - const nextMapping: RecipientColumnMapping = { columnIndex, kind }; - if (kind === "field") nextMapping.fieldName = current?.fieldName || existingFields[0]?.name || ""; - if (kind === "new_field") nextMapping.newFieldName = current?.newFieldName || suggestImportFieldName(header); - replaceColumnMapping(nextMapping); - } - - const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({ - id: `${columnIndex}-${header}`, - header, - columnIndex, - mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const } - })); - type MappingRow = (typeof mappingRows)[number]; - const mappingColumns: DataGridColumn[] = [ - { id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => {row.header} }, - { id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => {table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""} }, - { id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => }, - { id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && }{row.mapping.kind === "new_field" && replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />} } - ]; - - type PreviewRow = RecipientImportPreview["rows"][number]; - const previewColumns: DataGridColumn[] = [ - { id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber }, - { id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) }, - { id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.name }, - { id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length }, - { id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length }, - { id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" } - ]; - - const stepContent = activeStep === "upload" ? - <> -
- - readFile(files[0])} /> - - - - - -
- {fileError && {fileError}} - {sourceType === "xlsx" ? - - i18n:govoplan-campaign.workbook_loaded.cfaf40eb {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}. - : - - -