refactor(campaign): split review and recipient boundaries

This commit is contained in:
2026-07-30 01:01:26 +02:00
parent 23b9a531d5
commit 961d5d1130
12 changed files with 1860 additions and 1179 deletions
@@ -63,6 +63,10 @@ import {
type RecipientMappingProfile,
type RecipientMappingProfileMatch } from
"./utils/bulkImport";
import {
buildAddressSourceImportPreview,
createAddressSourceImportProvenance
} from "./utils/addressSourceImport";
import {
bulkLinkFilesToCampaign,
type ImportFileLinkCapability,
@@ -77,6 +81,7 @@ import {
type MailboxAddress } from
"@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
const recipientHeaderRows = [
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
@@ -85,7 +90,6 @@ const;
type RecipientAddressKey = "to" | "cc" | "bcc";
type AddressFieldKey = RecipientAddressKey | "from" | "reply_to";
type AddressSourceFilter = "all" | "books" | "lists";
type HeaderAddressEditorState = {
title: string;
columns: EntryAddressColumn[];
@@ -941,242 +945,6 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
}
type AddressSourceImportDialogProps = {
settings: ApiSettings;
campaignId: string;
sources: CampaignRecipientAddressSource[];
initialSourceId?: string;
onCancel: () => void;
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
};
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
}
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
if (addressSourceType(source) === "list") return "Address list";
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
return "Address book";
}
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
const scopeType = String(provenance.scope_type ?? "").trim();
const scopeId = String(provenance.scope_id ?? "").trim();
if (!scopeType) return "Unknown scope";
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
}
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
return [
source.source_label,
source.source_kind,
source.source_id,
addressSourceTypeLabel(source),
addressSourceScopeLabel(source),
provenance.address_book_id,
provenance.address_list_id,
provenance.scope_type,
provenance.scope_id,
provenance.tenant_id
].map((value) => String(value ?? "").toLowerCase()).join(" ");
}
function shortSourceRevision(value: string): string {
if (!value) return "no revision";
if (value.length <= 24) return value;
return `${value.slice(0, 19)}...`;
}
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
const navigate = useGuardedNavigate();
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
const [sourceQuery, setSourceQuery] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const sourceCounts = useMemo(() => {
return sources.reduce(
(counts, source) => {
counts[addressSourceType(source)] += 1;
return counts;
},
{ book: 0, list: 0 }
);
}, [sources]);
const filteredSources = useMemo(() => {
const query = sourceQuery.trim().toLowerCase();
return sources.filter((source) => {
const type = addressSourceType(source);
if (sourceFilter === "books" && type !== "book") return false;
if (sourceFilter === "lists" && type !== "list") return false;
if (!query) return true;
return addressSourceSearchText(source).includes(query);
});
}, [sourceFilter, sourceQuery, sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
[selectedSourceId, sources]
);
useEffect(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
}, [filteredSources, selectedSourceId]);
useEffect(() => {
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
setSourceFilter("all");
setSourceQuery("");
setSelectedSourceId(initialSourceId);
}
}, [initialSourceId, sources]);
useEffect(() => {
if (!selectedSourceId) {
setSnapshot(null);
return;
}
let cancelled = false;
setLoading(true);
setError("");
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
then((nextSnapshot) => {
if (!cancelled) setSnapshot(nextSnapshot);
}).
catch((err) => {
if (cancelled) return;
setSnapshot(null);
setError(err instanceof Error ? err.message : String(err));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function openAddressBookModule() {
onCancel();
navigate("/address-book");
}
return (
<Dialog
open
title="Import recipients from addresses"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={loading}
closeOnBackdrop={!loading}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
Import recipients
</Button>
</>
}>
<div className="address-source-import-controls">
<div>
<SegmentedControl
ariaLabel="Address source type"
value={sourceFilter}
onChange={setSourceFilter}
size="content"
width="inline"
disabled={loading || sources.length === 0}
options={[
{ id: "all", label: `All (${sources.length})` },
{ id: "books", label: `Books (${sourceCounts.book})` },
{ id: "lists", label: `Lists (${sourceCounts.list})` }
]}
/>
</div>
<input
type="search"
value={sourceQuery}
disabled={loading || sources.length === 0}
placeholder="Search address sources"
aria-label="Search address sources"
onChange={(event) => setSourceQuery(event.target.value)}
/>
<Button type="button" onClick={openAddressBookModule}>
Manage address books
</Button>
</div>
<div className="campaign-header-grid recipient-import-upload-grid">
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
{filteredSources.map((source) =>
<button
type="button"
key={source.source_id}
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
disabled={loading}
role="radio"
aria-checked={source.source_id === selectedSourceId}
onClick={() => setSelectedSourceId(source.source_id)}>
<span className="address-source-option-main">
<strong>{source.source_label}</strong>
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
</span>
<span className="address-source-option-meta">
<span>{source.recipient_count} recipients</span>
<code>{shortSourceRevision(source.source_revision)}</code>
</span>
</button>
)}
{sources.length > 0 && filteredSources.length === 0 &&
<div className="empty-state compact-empty">No address sources match the current filter.</div>
}
</div>
<FormField label="Import mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
</select>
</FormField>
</div>
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
{snapshot &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl>
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</>
}
</Dialog>);
}
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
];
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
}
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
@@ -1950,141 +1718,6 @@ function formatImportBytes(value?: number | null): string {
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
const tableRows = [
headers,
...snapshot.recipients.map((recipient) => headers.map((header) => {
if (header === "display_name") return recipient.display_name;
if (header === "email") return recipient.email;
return fieldValueToString((recipient.fields ?? {})[header]);
}))];
const table: RecipientImportTable = {
delimiter: ",",
headers,
headerRows: [headers],
rows: tableRows,
dataRows: tableRows.slice(1),
firstDataRowNumber: 2
};
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
const fieldNamesToCreate = new Set<string>();
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
const email = recipient.email.trim();
const name = recipient.display_name.trim();
const fields = stringFieldsFromAddressSource(recipient.fields);
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
const id = uniqueImportId(
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
usedIds
);
const issues: string[] = [];
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
return {
rowNumber: table.firstDataRowNumber + index,
id,
name,
email,
active: true,
addresses: {
from: [],
to: email ? [{ name, email }] : [],
cc: [],
bcc: [],
reply_to: []
},
fields,
patterns: [],
issues
};
});
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: 0
};
}
function createAddressSourceImportProvenance(
snapshot: CampaignRecipientAddressSourceSnapshot,
preview: RecipientImportPreview,
mode: RecipientImportMode)
: RecipientImportProvenance {
const now = new Date().toISOString();
return {
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
imported_at: now,
mode,
source_type: "addresses",
source_id: snapshot.source_id,
source_label: snapshot.source_label,
source_revision: snapshot.source_revision,
source_provenance: snapshot.provenance,
filename: null,
sheet_name: null,
encoding: null,
delimiter: null,
header_rows: 0,
quoted: null,
value_separators: null,
rows_total: preview.rows.length,
valid_rows: preview.validCount,
invalid_rows: preview.invalidCount,
imported_rows: preview.validCount,
field_names_created: preview.fieldNamesToCreate.slice(),
attachment_patterns: 0,
mapping: []
};
}
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
const fields: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value ?? {})) {
const fieldValue = fieldValueToString(rawValue);
if (fieldValue) fields[key] = fieldValue;
}
return fields;
}
function fieldValueToString(value: unknown): string {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value).trim();
}
function idFragmentFromEmail(value: string): string {
return safeImportIdFragment(value.split("@")[0] ?? "");
}
function safeImportIdFragment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
}
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
const base = safeImportIdFragment(preferred) || "recipient";
let candidate = base;
let counter = 2;
while (usedIds.has(candidate)) {
candidate = `${base}-${counter}`;
counter += 1;
}
usedIds.add(candidate);
return candidate;
}
function suggestImportFieldName(value: string): string {
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned || "field";