feat(imports): resume persisted address runs
This commit is contained in:
@@ -142,11 +142,13 @@ dist
|
|||||||
.policy-test-build/
|
.policy-test-build/
|
||||||
.template-preview-test-build/
|
.template-preview-test-build/
|
||||||
.import-test-build/
|
.import-test-build/
|
||||||
|
.import-run-test-build/
|
||||||
webui/.component-test-build/
|
webui/.component-test-build/
|
||||||
webui/.module-test-build/
|
webui/.module-test-build/
|
||||||
webui/.policy-test-build/
|
webui/.policy-test-build/
|
||||||
webui/.template-preview-test-build/
|
webui/.template-preview-test-build/
|
||||||
webui/.import-test-build/
|
webui/.import-test-build/
|
||||||
|
webui/.import-run-test-build/
|
||||||
|
|
||||||
# ---> Python
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|||||||
@@ -207,6 +207,14 @@ matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's
|
|||||||
responsibility; Files and Datasources are optional origins, not prerequisites
|
responsibility; Files and Datasources are optional origins, not prerequisites
|
||||||
for direct upload.
|
for direct upload.
|
||||||
|
|
||||||
|
Persisted import runs can be resumed through `/address-book?import_run=<id>`.
|
||||||
|
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, Deduplication, And Recovery
|
||||||
|
|
||||||
Quality is evidence about a concrete contact point, separate from communication
|
Quality is evidence about a concrete contact point, separate from communication
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ class AddressImportCommitRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AddressImportRollbackRequest(BaseModel):
|
class AddressImportRollbackRequest(BaseModel):
|
||||||
|
expected_plan_hash: str = Field(min_length=64, max_length=64)
|
||||||
reason: str = Field(min_length=3, max_length=2000)
|
reason: str = Field(min_length=3, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ def rollback_address_import(
|
|||||||
payload: AddressImportRollbackRequest,
|
payload: AddressImportRollbackRequest,
|
||||||
) -> AddressImportRun:
|
) -> AddressImportRun:
|
||||||
run = get_import_run(session, principal, run_id)
|
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":
|
if run.status == "rolled_back":
|
||||||
return run
|
return run
|
||||||
if run.status != "applied":
|
if run.status != "applied":
|
||||||
|
|||||||
@@ -446,8 +446,11 @@ manifest = ModuleManifest(
|
|||||||
"CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, "
|
"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. "
|
"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, "
|
"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, "
|
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run "
|
||||||
"macros, and legacy workbook formats are never executed or imported."
|
"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",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from govoplan_addresses.backend.import_schemas import (
|
|||||||
from govoplan_addresses.backend.imports import (
|
from govoplan_addresses.backend.imports import (
|
||||||
apply_address_import,
|
apply_address_import,
|
||||||
create_import_profile,
|
create_import_profile,
|
||||||
|
get_import_run,
|
||||||
import_run_payload,
|
import_run_payload,
|
||||||
preview_address_import,
|
preview_address_import,
|
||||||
rollback_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:
|
def encoded(value: str) -> str:
|
||||||
return base64.b64encode(value.encode()).decode()
|
return base64.b64encode(value.encode()).decode()
|
||||||
|
|
||||||
@@ -121,11 +128,69 @@ class AddressTabularImportTests(unittest.TestCase):
|
|||||||
self.session,
|
self.session,
|
||||||
self.principal,
|
self.principal,
|
||||||
run.id,
|
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("rolled_back", rolled_back.status)
|
||||||
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
|
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:
|
def test_duplicate_keys_and_changed_targets_block_apply(self) -> None:
|
||||||
duplicate = preview_address_import(
|
duplicate = preview_address_import(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,8 @@
|
|||||||
"./styles/addresses.css": "./src/styles/addresses.css"
|
"./styles/addresses.css": "./src/styles/addresses.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
|||||||
@@ -1100,17 +1100,21 @@ export function previewAddressImport(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAddressImportRun(settings: ApiSettings, runId: string): Promise<AddressImportRun> {
|
||||||
|
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(runId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise<AddressImportRun> {
|
export function applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise<AddressImportRun> {
|
||||||
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${run.id}/apply`, {
|
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/apply`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ expected_plan_hash: run.plan_hash })
|
body: JSON.stringify({ expected_plan_hash: run.plan_hash })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rollbackAddressImport(settings: ApiSettings, runId: string, reason: string): Promise<AddressImportRun> {
|
export function rollbackAddressImport(settings: ApiSettings, run: AddressImportRun, reason: string): Promise<AddressImportRun> {
|
||||||
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${runId}/rollback`, {
|
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/rollback`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ reason })
|
body: JSON.stringify({ expected_plan_hash: run.plan_hash, reason })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MetricGrid } from "@govoplan/core-webui";
|
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 { 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 { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
|
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
|
||||||
ApiError,
|
ApiError,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
endContactChannelRule,
|
endContactChannelRule,
|
||||||
exportAddressBookVcards,
|
exportAddressBookVcards,
|
||||||
exportContactVcard,
|
exportContactVcard,
|
||||||
|
getAddressImportRun,
|
||||||
importAddressBookVcards,
|
importAddressBookVcards,
|
||||||
applyAddressImport,
|
applyAddressImport,
|
||||||
getAddressQualitySummary,
|
getAddressQualitySummary,
|
||||||
@@ -71,6 +73,7 @@ import {
|
|||||||
listContactProvenance,
|
listContactProvenance,
|
||||||
previewAddressSyncSource,
|
previewAddressSyncSource,
|
||||||
previewAddressImport,
|
previewAddressImport,
|
||||||
|
rollbackAddressImport,
|
||||||
mergeContacts,
|
mergeContacts,
|
||||||
recoverContactMerge,
|
recoverContactMerge,
|
||||||
restoreAddressBook,
|
restoreAddressBook,
|
||||||
@@ -114,6 +117,12 @@ import {
|
|||||||
ADDRESSES_DOCUMENTATION,
|
ADDRESSES_DOCUMENTATION,
|
||||||
ADDRESSES_I18N
|
ADDRESSES_I18N
|
||||||
} from "./interfacePatterns";
|
} from "./interfacePatterns";
|
||||||
|
import {
|
||||||
|
importRunIdFromSearch,
|
||||||
|
importRunLifecycle,
|
||||||
|
unavailableImportRunMessage,
|
||||||
|
withImportRunSearch
|
||||||
|
} from "./importRunState";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
@@ -922,6 +931,8 @@ function formKey(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
|
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const requestedImportRunId = importRunIdFromSearch(searchParams);
|
||||||
const [books, setBooks] = useState<AddressBook[]>([]);
|
const [books, setBooks] = useState<AddressBook[]>([]);
|
||||||
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
||||||
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
||||||
@@ -980,6 +991,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const [importProfileForm, setImportProfileForm] = useState<ImportProfileFormState>(EMPTY_IMPORT_PROFILE_FORM);
|
const [importProfileForm, setImportProfileForm] = useState<ImportProfileFormState>(EMPTY_IMPORT_PROFILE_FORM);
|
||||||
const [importFile, setImportFile] = useState<File | null>(null);
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
const [importRun, setImportRun] = useState<AddressImportRun | null>(null);
|
const [importRun, setImportRun] = useState<AddressImportRun | null>(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 [cardDavOpen, setCardDavOpen] = useState(false);
|
||||||
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
|
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
|
||||||
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
|
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
|
||||||
@@ -1077,13 +1092,41 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
setImportProfiles(profiles);
|
setImportProfiles(profiles);
|
||||||
setSelectedImportProfileId((current) => current || profiles[0]?.id || "");
|
setSelectedImportProfileId((current) => current || profiles[0]?.id || "");
|
||||||
setCreatingImportProfile(profiles.length === 0);
|
setCreatingImportProfile(profiles.length === 0 && !requestedImportRunId);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (active) setError(errorMessage(err));
|
if (active) setError(errorMessage(err));
|
||||||
});
|
});
|
||||||
return () => { active = false; };
|
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(() => {
|
useEffect(() => {
|
||||||
if (auth.groups_loaded || !onAuthChange) return;
|
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]);
|
}, [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 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 selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
|
||||||
const selectedBookSyncSources = useMemo(
|
const selectedBookSyncSources = useMemo(
|
||||||
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
|
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
|
||||||
@@ -1261,7 +1305,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
[saving, savingReason],
|
[saving, savingReason],
|
||||||
[!importRun, "Preview the import first."],
|
[!importRun, "Preview the import first."],
|
||||||
[!importRun?.can_apply, "Resolve all import diagnostics before applying."],
|
[!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(
|
const connectLdapReason = disabledReason(
|
||||||
[!selectedBook, "Select an address book before connecting LDAP."],
|
[!selectedBook, "Select an address book before connecting LDAP."],
|
||||||
@@ -2263,8 +2313,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openImportDialog() {
|
function openImportDialog() {
|
||||||
|
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
||||||
setImportMode("vcard");
|
setImportMode("vcard");
|
||||||
setImportRun(null);
|
setImportRun(null);
|
||||||
|
setImportRunUnavailable("");
|
||||||
setImportFile(null);
|
setImportFile(null);
|
||||||
setCreatingImportProfile(false);
|
setCreatingImportProfile(false);
|
||||||
setEditingImportProfileId("");
|
setEditingImportProfileId("");
|
||||||
@@ -2272,6 +2324,46 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
setImportOpen(true);
|
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() {
|
async function saveImportProfile() {
|
||||||
if (!selectedBook) return;
|
if (!selectedBook) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -2315,7 +2407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
setSelectedImportProfileId(profile.id);
|
setSelectedImportProfileId(profile.id);
|
||||||
setCreatingImportProfile(false);
|
setCreatingImportProfile(false);
|
||||||
setEditingImportProfileId("");
|
setEditingImportProfileId("");
|
||||||
setImportRun(null);
|
clearRetainedImportRun();
|
||||||
setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`);
|
setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
@@ -2328,7 +2420,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
setEditingImportProfileId("");
|
setEditingImportProfileId("");
|
||||||
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
|
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
|
||||||
setCreatingImportProfile(true);
|
setCreatingImportProfile(true);
|
||||||
setImportRun(null);
|
clearRetainedImportRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editSelectedImportProfile() {
|
function editSelectedImportProfile() {
|
||||||
@@ -2352,7 +2444,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
field_mappings: { ...config.field_mappings }
|
field_mappings: { ...config.field_mappings }
|
||||||
});
|
});
|
||||||
setCreatingImportProfile(true);
|
setCreatingImportProfile(true);
|
||||||
setImportRun(null);
|
clearRetainedImportRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelImportProfileEditor() {
|
function cancelImportProfileEditor() {
|
||||||
@@ -2383,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
filename: importFile.name,
|
filename: importFile.name,
|
||||||
content_base64: content
|
content_base64: content
|
||||||
});
|
});
|
||||||
setImportRun(run);
|
retainImportRun(run);
|
||||||
setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`);
|
setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
@@ -2399,7 +2491,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
setNotice("");
|
setNotice("");
|
||||||
try {
|
try {
|
||||||
const run = await applyAddressImport(settings, importRun);
|
const run = await applyAddressImport(settings, importRun);
|
||||||
setImportRun(run);
|
retainImportRun(run);
|
||||||
setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`);
|
setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`);
|
||||||
await refreshBooks();
|
await refreshBooks();
|
||||||
await refreshContacts(selectedBook.id, query);
|
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() {
|
function openCardDavDialog() {
|
||||||
setCardDavForm(EMPTY_CARDDAV_FORM);
|
setCardDavForm(EMPTY_CARDDAV_FORM);
|
||||||
setCardDavDiscovery([]);
|
setCardDavDiscovery([]);
|
||||||
@@ -3662,13 +3774,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
title="Import contacts"
|
title="Import contacts"
|
||||||
onClose={() => setImportOpen(false)}
|
onClose={closeImportDialog}
|
||||||
closeDisabled={saving}
|
closeDisabled={saving}
|
||||||
className="address-import-dialog"
|
className="address-import-dialog"
|
||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
<Button type="button" onClick={closeImportDialog} disabledReason={dialogCancelReason}>Close</Button>
|
||||||
{importMode === "vcard" &&
|
{importMode === "vcard" &&
|
||||||
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>}
|
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>}
|
||||||
{importMode === "tabular" && creatingImportProfile &&
|
{importMode === "tabular" && creatingImportProfile &&
|
||||||
@@ -3677,6 +3789,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<Button type="button" onClick={() => void previewTabularImport()} disabledReason={tabularPreviewReason}><Search size={16} /> Preview</Button>}
|
<Button type="button" onClick={() => void previewTabularImport()} disabledReason={tabularPreviewReason}><Search size={16} /> Preview</Button>}
|
||||||
{importMode === "tabular" && !creatingImportProfile && importRun &&
|
{importMode === "tabular" && !creatingImportProfile && importRun &&
|
||||||
<Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>}
|
<Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>}
|
||||||
|
{importMode === "tabular" && importLifecycle?.canRollback &&
|
||||||
|
<Button type="button" variant="danger" onClick={() => setImportRollbackOpen(true)} disabledReason={savingReason}><RotateCcw size={16} /> Roll back</Button>}
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<DialogSection className="address-dialog-form">
|
<DialogSection className="address-dialog-form">
|
||||||
@@ -3686,7 +3800,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
ariaLabel="Contact import format"
|
ariaLabel="Contact import format"
|
||||||
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
|
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
|
||||||
value={importMode}
|
value={importMode}
|
||||||
onChange={(mode) => { setImportMode(mode); setImportRun(null); }}
|
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
|
||||||
/>
|
/>
|
||||||
{importMode === "vcard" &&
|
{importMode === "vcard" &&
|
||||||
<DialogForm id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
|
<DialogForm id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
|
||||||
@@ -3703,12 +3817,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</DialogForm>}
|
</DialogForm>}
|
||||||
{importMode === "tabular" &&
|
{importMode === "tabular" &&
|
||||||
<div className="address-import-workspace">
|
<div className="address-import-workspace">
|
||||||
|
{(requestedImportRunId || importRun) &&
|
||||||
|
<div className="address-import-run-state">
|
||||||
|
<div>
|
||||||
|
<strong>Persisted import run</strong>
|
||||||
|
<span className="muted block">{importRun?.id ?? requestedImportRunId}</span>
|
||||||
|
</div>
|
||||||
|
{importRun && importLifecycle &&
|
||||||
|
<>
|
||||||
|
<StatusBadge status={importLifecycle.tone} label={importLifecycle.label} />
|
||||||
|
<p className="muted">{importLifecycle.guidance}</p>
|
||||||
|
</>}
|
||||||
|
<Button type="button" onClick={() => void reloadImportRun()} disabledReason={importRunLoading ? "The import run is loading." : undefined}>
|
||||||
|
<RefreshCw size={15} /> {importRunLoading ? "Loading…" : "Reload run"}
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
{importRunUnavailable &&
|
||||||
|
<DismissibleAlert tone="warning" resetKey={importRunUnavailable}>{importRunUnavailable}</DismissibleAlert>}
|
||||||
<FormGrid columns={2} collapseAt="standard" className="">
|
<FormGrid columns={2} collapseAt="standard" className="">
|
||||||
<FormField label="Mapping profile">
|
<FormField label="Mapping profile">
|
||||||
<select
|
<select
|
||||||
value={selectedImportProfileId}
|
value={selectedImportProfileId}
|
||||||
disabled={creatingImportProfile}
|
disabled={creatingImportProfile}
|
||||||
onChange={(event) => { setSelectedImportProfileId(event.target.value); setImportRun(null); }}>
|
onChange={(event) => { setSelectedImportProfileId(event.target.value); clearRetainedImportRun(); }}>
|
||||||
<option value="">Select a saved mapping</option>
|
<option value="">Select a saved mapping</option>
|
||||||
{importProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} · {profile.source_format.toUpperCase()} · v{profile.version}</option>)}
|
{importProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} · {profile.source_format.toUpperCase()} · v{profile.version}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -3784,11 +3915,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); setImportRun(null); }}
|
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{importRun &&
|
{importRun &&
|
||||||
<div className="address-import-preview">
|
<div className="address-import-preview">
|
||||||
|
<p className="muted small-text">
|
||||||
|
{importRun.source_filename} · plan {importRun.plan_hash.slice(0, 12)}… · updated {formatDateTime(importRun.updated_at)}
|
||||||
|
</p>
|
||||||
<div className="address-sync-plan-grid">
|
<div className="address-sync-plan-grid">
|
||||||
{(["create", "update", "unchanged", "ignored", "conflict", "errors"] as const).map((key) =>
|
{(["create", "update", "unchanged", "ignored", "conflict", "errors"] as const).map((key) =>
|
||||||
<div key={key}><strong>{importRun.statistics[key] ?? 0}</strong><small>{key}</small></div>)}
|
<div key={key}><strong>{importRun.statistics[key] ?? 0}</strong><small>{key}</small></div>)}
|
||||||
@@ -3812,6 +3946,34 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</DialogSection>
|
</DialogSection>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={importRollbackOpen}
|
||||||
|
title="Roll back contact import"
|
||||||
|
onClose={() => setImportRollbackOpen(false)}
|
||||||
|
closeDisabled={saving}
|
||||||
|
footerClassName="button-row compact-actions"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={() => setImportRollbackOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||||
|
<Button type="button" variant="danger" onClick={() => void rollbackTabularImport()} disabledReason={tabularRollbackReason}><RotateCcw size={16} /> Roll back import</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<DialogSection className="address-dialog-form">
|
||||||
|
<p className="muted">
|
||||||
|
Rollback is accepted only while every affected contact still matches the evidence recorded for plan {importRun?.plan_hash.slice(0, 12) ?? "-"}….
|
||||||
|
</p>
|
||||||
|
<FormField label="Reason">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={importRollbackReason}
|
||||||
|
onChange={(event) => setImportRollbackReason(event.target.value)}
|
||||||
|
placeholder="Why should this import be rolled back?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</DialogSection>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={cardDavOpen}
|
open={cardDavOpen}
|
||||||
title="Connect CardDAV"
|
title="Connect CardDAV"
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export type ImportRunLifecycle = {
|
||||||
|
label: string;
|
||||||
|
tone: "info" | "success" | "warning" | "inactive";
|
||||||
|
canApply: boolean;
|
||||||
|
canRollback: boolean;
|
||||||
|
guidance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function importRunIdFromSearch(search: URLSearchParams): string {
|
||||||
|
return search.get("import_run")?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withImportRunSearch(search: URLSearchParams, runId?: string | null): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(search);
|
||||||
|
const normalized = runId?.trim();
|
||||||
|
if (normalized) next.set("import_run", normalized);
|
||||||
|
else next.delete("import_run");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importRunLifecycle(status: string): ImportRunLifecycle {
|
||||||
|
if (status === "previewed") {
|
||||||
|
return {
|
||||||
|
label: "Ready for review",
|
||||||
|
tone: "info",
|
||||||
|
canApply: true,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "Review the persisted effects and diagnostics before applying this plan."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === "applied") {
|
||||||
|
return {
|
||||||
|
label: "Applied",
|
||||||
|
tone: "success",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: true,
|
||||||
|
guidance: "The plan has already been applied. Rollback remains guarded by its recorded plan hash and contact evidence."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === "rolled_back") {
|
||||||
|
return {
|
||||||
|
label: "Rolled back",
|
||||||
|
tone: "inactive",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "This run was rolled back and cannot be applied again. Create a new preview to import the source again."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === "expired") {
|
||||||
|
return {
|
||||||
|
label: "Expired",
|
||||||
|
tone: "warning",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "This preview is no longer actionable. Create a new preview from the original source."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: status || "Unavailable",
|
||||||
|
tone: "warning",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "This run is not actionable in its current lifecycle state."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unavailableImportRunMessage(status?: number): string {
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
return "This import run is missing, expired, or not available to your tenant. No source data was loaded.";
|
||||||
|
}
|
||||||
|
return "The persisted import run could not be loaded. Reload it after the service becomes available.";
|
||||||
|
}
|
||||||
@@ -163,6 +163,21 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-import-run-state {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px 14px;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.4fr) auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-run-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.address-import-profile-actions {
|
.address-import-profile-actions {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -811,6 +826,7 @@
|
|||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.address-book-workspace,
|
.address-book-workspace,
|
||||||
|
.address-import-run-state,
|
||||||
.address-form-row-email,
|
.address-form-row-email,
|
||||||
.address-form-row-phone,
|
.address-form-row-phone,
|
||||||
.address-form-row-postal {
|
.address-form-row-postal {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
importRunIdFromSearch,
|
||||||
|
importRunLifecycle,
|
||||||
|
unavailableImportRunMessage,
|
||||||
|
withImportRunSearch
|
||||||
|
} from "../src/features/addressbook/importRunState";
|
||||||
|
|
||||||
|
const initial = new URLSearchParams("q=Ada&import_run=run-123");
|
||||||
|
assert.equal(importRunIdFromSearch(initial), "run-123");
|
||||||
|
assert.equal(withImportRunSearch(initial, "run-456").toString(), "q=Ada&import_run=run-456");
|
||||||
|
assert.equal(withImportRunSearch(initial, null).toString(), "q=Ada");
|
||||||
|
|
||||||
|
assert.equal(importRunLifecycle("previewed").canApply, true);
|
||||||
|
assert.equal(importRunLifecycle("previewed").canRollback, false);
|
||||||
|
assert.equal(importRunLifecycle("applied").canApply, false);
|
||||||
|
assert.equal(importRunLifecycle("applied").canRollback, true);
|
||||||
|
assert.equal(importRunLifecycle("rolled_back").canRollback, false);
|
||||||
|
assert.equal(importRunLifecycle("expired").label, "Expired");
|
||||||
|
assert.match(unavailableImportRunMessage(404), /missing, expired, or not available to your tenant/);
|
||||||
|
|
||||||
|
console.log("Address import-run deep-link and lifecycle state tests passed.");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"typeRoots": ["../../govoplan-core/webui/node_modules/@types"],
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": ".import-run-test-build"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/features/addressbook/importRunState.ts",
|
||||||
|
"tests/import-run-state.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user