diff --git a/.gitignore b/.gitignore index 6750d45..4dc0144 100644 --- a/.gitignore +++ b/.gitignore @@ -142,11 +142,13 @@ dist .policy-test-build/ .template-preview-test-build/ .import-test-build/ +.import-run-test-build/ webui/.component-test-build/ webui/.module-test-build/ webui/.policy-test-build/ webui/.template-preview-test-build/ webui/.import-test-build/ +webui/.import-run-test-build/ # ---> Python # Byte-compiled / optimized / DLL files diff --git a/docs/ADDRESS_MODULE_ARCHITECTURE.md b/docs/ADDRESS_MODULE_ARCHITECTURE.md index 24820a5..05d2cb8 100644 --- a/docs/ADDRESS_MODULE_ARCHITECTURE.md +++ b/docs/ADDRESS_MODULE_ARCHITECTURE.md @@ -207,6 +207,14 @@ matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's responsibility; Files and Datasources are optional origins, not prerequisites for direct upload. +Persisted import runs can be resumed through `/address-book?import_run=`. +The WebUI reloads the bounded run projection, selects its address book and +mapping version, and restores statistics, diagnostics, effects, and lifecycle +state. Apply and rollback both carry the reviewed plan hash. The normal read +projection never includes uploaded bytes or private before-images, and an +unknown, expired, hidden, or cross-tenant id is presented as one unavailable +state so the deep link cannot enumerate another tenant's imports. + ## Quality, Deduplication, And Recovery Quality is evidence about a concrete contact point, separate from communication diff --git a/src/govoplan_addresses/backend/import_schemas.py b/src/govoplan_addresses/backend/import_schemas.py index e3c83a6..d0ab93e 100644 --- a/src/govoplan_addresses/backend/import_schemas.py +++ b/src/govoplan_addresses/backend/import_schemas.py @@ -151,6 +151,7 @@ class AddressImportCommitRequest(BaseModel): class AddressImportRollbackRequest(BaseModel): + expected_plan_hash: str = Field(min_length=64, max_length=64) reason: str = Field(min_length=3, max_length=2000) diff --git a/src/govoplan_addresses/backend/imports.py b/src/govoplan_addresses/backend/imports.py index 5346302..17f4fee 100644 --- a/src/govoplan_addresses/backend/imports.py +++ b/src/govoplan_addresses/backend/imports.py @@ -336,6 +336,8 @@ def rollback_address_import( payload: AddressImportRollbackRequest, ) -> AddressImportRun: run = get_import_run(session, principal, run_id) + if run.plan_hash != payload.expected_plan_hash: + raise AddressBookError("The reviewed import plan changed; reload the import run.") if run.status == "rolled_back": return run if run.status != "applied": diff --git a/src/govoplan_addresses/backend/manifest.py b/src/govoplan_addresses/backend/manifest.py index 94a6701..8fda87e 100644 --- a/src/govoplan_addresses/backend/manifest.py +++ b/src/govoplan_addresses/backend/manifest.py @@ -446,8 +446,11 @@ manifest = ModuleManifest( "CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, " "encodings, source keys, duplicates, blank values, workbook limits, and contact identity before any mutation. " "The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, " - "rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. XLSX formulas, " - "macros, and legacy workbook formats are never executed or imported." + "rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run " + "can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and " + "unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, " + "hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats " + "are never executed or imported." ), layer="configured", documentation_types=("admin", "user"), diff --git a/tests/test_tabular_imports.py b/tests/test_tabular_imports.py index d5096c0..79d298b 100644 --- a/tests/test_tabular_imports.py +++ b/tests/test_tabular_imports.py @@ -19,6 +19,7 @@ from govoplan_addresses.backend.import_schemas import ( from govoplan_addresses.backend.imports import ( apply_address_import, create_import_profile, + get_import_run, import_run_payload, preview_address_import, rollback_address_import, @@ -44,6 +45,12 @@ class Principal: } +class OtherTenantPrincipal(Principal): + @property + def tenant_id(self) -> str: + return "tenant-2" + + def encoded(value: str) -> str: return base64.b64encode(value.encode()).decode() @@ -121,11 +128,69 @@ class AddressTabularImportTests(unittest.TestCase): self.session, self.principal, run.id, - AddressImportRollbackRequest(reason="The operator selected the wrong monthly file."), + AddressImportRollbackRequest( + expected_plan_hash=run.plan_hash, + reason="The operator selected the wrong monthly file.", + ), ) self.assertEqual("rolled_back", rolled_back.status) self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count()) + def test_rollback_rejects_a_stale_review_hash(self) -> None: + run = preview_address_import( + self.session, + self.principal, + self.book.id, + AddressImportPreviewRequest( + profile_id=self.profile.id, + filename="contacts.csv", + content_base64=encoded( + "id;first;last;email;organization\n" + "1;Ada;Lovelace;ada@example.test;Analysis Office\n" + ), + ), + ) + apply_address_import( + self.session, + self.principal, + run.id, + expected_plan_hash=run.plan_hash, + ) + + with self.assertRaisesRegex(ValueError, "reviewed import plan changed"): + rollback_address_import( + self.session, + self.principal, + run.id, + AddressImportRollbackRequest( + expected_plan_hash="0" * 64, + reason="The operator selected the wrong monthly file.", + ), + ) + + def test_persisted_run_read_is_tenant_bounded_and_source_safe(self) -> None: + source = "id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Analysis Office\n" + run = preview_address_import( + self.session, + self.principal, + self.book.id, + AddressImportPreviewRequest( + profile_id=self.profile.id, + filename="contacts.csv", + content_base64=encoded(source), + ), + ) + self.session.flush() + + payload = import_run_payload(get_import_run(self.session, self.principal, run.id)) + self.assertEqual("previewed", payload["status"]) + self.assertEqual(run.plan_hash, payload["plan_hash"]) + self.assertNotIn("plan_data", payload) + self.assertNotIn(source, repr(payload)) + + with self.assertRaisesRegex(ValueError, "not found"): + get_import_run(self.session, OtherTenantPrincipal(), run.id) + def test_duplicate_keys_and_changed_targets_block_apply(self) -> None: duplicate = preview_address_import( self.session, diff --git a/webui/package.json b/webui/package.json index ea33789..e8508ae 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,7 +14,8 @@ "./styles/addresses.css": "./src/styles/addresses.css" }, "scripts": { - "test:ui-structure": "node scripts/test-selection-list-structure.mjs" + "test:ui-structure": "node scripts/test-selection-list-structure.mjs", + "test:import-run": "rm -rf .import-run-test-build && mkdir -p .import-run-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-run-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.import-run-tests.json && node .import-run-test-build/tests/import-run-state.test.js" }, "peerDependencies": { "@govoplan/core-webui": "^0.1.18", diff --git a/webui/src/api/addresses.ts b/webui/src/api/addresses.ts index 1af8d38..06c7262 100644 --- a/webui/src/api/addresses.ts +++ b/webui/src/api/addresses.ts @@ -1100,17 +1100,21 @@ export function previewAddressImport( }); } +export function getAddressImportRun(settings: ApiSettings, runId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/imports/${encodeURIComponent(runId)}`); +} + export function applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise { - return apiFetch(settings, `/api/v1/addresses/imports/${run.id}/apply`, { + return apiFetch(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/apply`, { method: "POST", body: JSON.stringify({ expected_plan_hash: run.plan_hash }) }); } -export function rollbackAddressImport(settings: ApiSettings, runId: string, reason: string): Promise { - return apiFetch(settings, `/api/v1/addresses/imports/${runId}/rollback`, { +export function rollbackAddressImport(settings: ApiSettings, run: AddressImportRun, reason: string): Promise { + return apiFetch(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/rollback`, { method: "POST", - body: JSON.stringify({ reason }) + body: JSON.stringify({ expected_plan_hash: run.plan_hash, reason }) }); } diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index c7cc5f9..cb80959 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -1,6 +1,7 @@ import { MetricGrid } from "@govoplan/core-webui"; import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react"; +import { useSearchParams } from "react-router"; import { DialogSection, DialogForm, FormGrid, ActionToolbar, ApiError, ActionBlockerHint, @@ -51,6 +52,7 @@ import { endContactChannelRule, exportAddressBookVcards, exportContactVcard, + getAddressImportRun, importAddressBookVcards, applyAddressImport, getAddressQualitySummary, @@ -71,6 +73,7 @@ import { listContactProvenance, previewAddressSyncSource, previewAddressImport, + rollbackAddressImport, mergeContacts, recoverContactMerge, restoreAddressBook, @@ -114,6 +117,12 @@ import { ADDRESSES_DOCUMENTATION, ADDRESSES_I18N } from "./interfacePatterns"; +import { + importRunIdFromSearch, + importRunLifecycle, + unavailableImportRunMessage, + withImportRunSearch +} from "./importRunState"; type Props = { settings: ApiSettings; @@ -922,6 +931,8 @@ function formKey(value: unknown): string { } export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { + const [searchParams, setSearchParams] = useSearchParams(); + const requestedImportRunId = importRunIdFromSearch(searchParams); const [books, setBooks] = useState([]); const [addressLists, setAddressLists] = useState([]); const [addressListEntries, setAddressListEntries] = useState([]); @@ -980,6 +991,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const [importProfileForm, setImportProfileForm] = useState(EMPTY_IMPORT_PROFILE_FORM); const [importFile, setImportFile] = useState(null); const [importRun, setImportRun] = useState(null); + const [importRunLoading, setImportRunLoading] = useState(false); + const [importRunUnavailable, setImportRunUnavailable] = useState(""); + const [importRollbackOpen, setImportRollbackOpen] = useState(false); + const [importRollbackReason, setImportRollbackReason] = useState(""); const [cardDavOpen, setCardDavOpen] = useState(false); const [cardDavForm, setCardDavForm] = useState(EMPTY_CARDDAV_FORM); const [cardDavDiscovery, setCardDavDiscovery] = useState([]); @@ -1077,13 +1092,41 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) if (!active) return; setImportProfiles(profiles); setSelectedImportProfileId((current) => current || profiles[0]?.id || ""); - setCreatingImportProfile(profiles.length === 0); + setCreatingImportProfile(profiles.length === 0 && !requestedImportRunId); }) .catch((err) => { if (active) setError(errorMessage(err)); }); return () => { active = false; }; - }, [importMode, importOpen, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + }, [importMode, importOpen, requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + + useEffect(() => { + if (!requestedImportRunId) return; + let active = true; + setImportOpen(true); + setImportMode("tabular"); + setCreatingImportProfile(false); + setImportRunLoading(true); + setImportRunUnavailable(""); + getAddressImportRun(settings, requestedImportRunId) + .then((run) => { + if (!active) return; + setImportRun(run); + setSelectedBookId(run.address_book_id); + setSelectedImportProfileId(run.profile_id); + }) + .catch((err) => { + if (!active) return; + setImportRun(null); + setImportRunUnavailable( + unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined) + ); + }) + .finally(() => { + if (active) setImportRunLoading(false); + }); + return () => { active = false; }; + }, [requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); useEffect(() => { if (auth.groups_loaded || !onAuthChange) return; @@ -1095,6 +1138,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) }, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null; + const importLifecycle = importRun ? importRunLifecycle(importRun.status) : null; const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null; const selectedBookSyncSources = useMemo( () => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [], @@ -1261,7 +1305,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) [saving, savingReason], [!importRun, "Preview the import first."], [!importRun?.can_apply, "Resolve all import diagnostics before applying."], - [importRun?.status !== "previewed", "This import plan is no longer pending."] + [!importLifecycle?.canApply, "This import plan is no longer pending."] + ); + const tabularRollbackReason = disabledReason( + [saving, savingReason], + [!importRun, "Load an applied import run before rolling it back."], + [!importLifecycle?.canRollback, "Only an applied import run can be rolled back."], + [importRollbackReason.trim().length < 3, "Record why this import is being rolled back."] ); const connectLdapReason = disabledReason( [!selectedBook, "Select an address book before connecting LDAP."], @@ -2263,8 +2313,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) } function openImportDialog() { + setSearchParams(withImportRunSearch(searchParams, null), { replace: true }); setImportMode("vcard"); setImportRun(null); + setImportRunUnavailable(""); setImportFile(null); setCreatingImportProfile(false); setEditingImportProfileId(""); @@ -2272,6 +2324,46 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setImportOpen(true); } + function closeImportDialog() { + setImportOpen(false); + setImportRun(null); + setImportRunUnavailable(""); + setImportRollbackOpen(false); + setImportRollbackReason(""); + setSearchParams(withImportRunSearch(searchParams, null), { replace: true }); + } + + function retainImportRun(run: AddressImportRun) { + setImportRun(run); + setImportRunUnavailable(""); + setSearchParams(withImportRunSearch(searchParams, run.id), { replace: true }); + } + + function clearRetainedImportRun() { + setImportRun(null); + setImportRunUnavailable(""); + setSearchParams(withImportRunSearch(searchParams, null), { replace: true }); + } + + async function reloadImportRun() { + if (!importRun?.id && !requestedImportRunId) return; + setImportRunLoading(true); + setImportRunUnavailable(""); + try { + const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId); + retainImportRun(run); + setSelectedBookId(run.address_book_id); + setSelectedImportProfileId(run.profile_id); + } catch (err) { + setImportRun(null); + setImportRunUnavailable( + unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined) + ); + } finally { + setImportRunLoading(false); + } + } + async function saveImportProfile() { if (!selectedBook) return; setSaving(true); @@ -2315,7 +2407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setSelectedImportProfileId(profile.id); setCreatingImportProfile(false); setEditingImportProfileId(""); - setImportRun(null); + clearRetainedImportRun(); setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`); } catch (err) { setError(errorMessage(err)); @@ -2328,7 +2420,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setEditingImportProfileId(""); setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM); setCreatingImportProfile(true); - setImportRun(null); + clearRetainedImportRun(); } function editSelectedImportProfile() { @@ -2352,7 +2444,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) field_mappings: { ...config.field_mappings } }); setCreatingImportProfile(true); - setImportRun(null); + clearRetainedImportRun(); } function cancelImportProfileEditor() { @@ -2383,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) filename: importFile.name, content_base64: content }); - setImportRun(run); + retainImportRun(run); setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`); } catch (err) { setError(errorMessage(err)); @@ -2399,7 +2491,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setNotice(""); try { const run = await applyAddressImport(settings, importRun); - setImportRun(run); + retainImportRun(run); setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`); await refreshBooks(); await refreshContacts(selectedBook.id, query); @@ -2410,6 +2502,26 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) } } + async function rollbackTabularImport() { + if (!importRun) return; + setSaving(true); + setError(""); + setNotice(""); + try { + const run = await rollbackAddressImport(settings, importRun, importRollbackReason.trim()); + retainImportRun(run); + setImportRollbackOpen(false); + setImportRollbackReason(""); + setNotice("Rolled back the persisted import run."); + await refreshBooks(); + await refreshContacts(run.address_book_id, query); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + function openCardDavDialog() { setCardDavForm(EMPTY_CARDDAV_FORM); setCardDavDiscovery([]); @@ -3662,13 +3774,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setImportOpen(false)} + onClose={closeImportDialog} closeDisabled={saving} className="address-import-dialog" footerClassName="button-row compact-actions" footer={ <> - + {importMode === "vcard" && } {importMode === "tabular" && creatingImportProfile && @@ -3677,6 +3789,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) } {importMode === "tabular" && !creatingImportProfile && importRun && } + {importMode === "tabular" && importLifecycle?.canRollback && + } }> @@ -3686,7 +3800,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) ariaLabel="Contact import format" options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]} value={importMode} - onChange={(mode) => { setImportMode(mode); setImportRun(null); }} + onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }} /> {importMode === "vcard" && void submitVcardImport(event)}> @@ -3703,12 +3817,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) } {importMode === "tabular" &&
+ {(requestedImportRunId || importRun) && +
+
+ Persisted import run + {importRun?.id ?? requestedImportRunId} +
+ {importRun && importLifecycle && + <> + +

{importLifecycle.guidance}

+ } + +
} + {importRunUnavailable && + {importRunUnavailable}} @@ -3784,11 +3915,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { setImportFile(event.target.files?.[0] ?? null); setImportRun(null); }} + onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }} /> {importRun &&
+

+ {importRun.source_filename} · plan {importRun.plan_hash.slice(0, 12)}… · updated {formatDateTime(importRun.updated_at)} +

{(["create", "update", "unchanged", "ignored", "conflict", "errors"] as const).map((key) =>
{importRun.statistics[key] ?? 0}{key}
)} @@ -3812,6 +3946,34 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
+ setImportRollbackOpen(false)} + closeDisabled={saving} + footerClassName="button-row compact-actions" + footer={ + <> + + + + }> + +

+ Rollback is accepted only while every affected contact still matches the evidence recorded for plan {importRun?.plan_hash.slice(0, 12) ?? "-"}…. +

+ +