diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index c5dfb55..99267ec 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -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("all"); - const [sourceQuery, setSourceQuery] = useState(""); - const [mode, setMode] = useState("append"); - const [snapshot, setSnapshot] = useState(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 ( - - - - - }> - -
-
- -
- setSourceQuery(event.target.value)} - /> - -
- -
-
- {filteredSources.map((source) => - - )} - {sources.length > 0 && filteredSources.length === 0 && -
No address sources match the current filter.
- } -
- - - -
- - {error && {error}} - {loading && Loading address recipients...} - {!loading && sources.length === 0 && No address sources are available to this campaign. Create an address book or address list in the addresses module first.} - - {snapshot && - <> -
-
Source
{snapshot.source_label}
-
Type
{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}
-
Scope
{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}
-
Recipients
{snapshot.recipients.length}
-
Revision
{snapshot.source_revision}
-
- - {snapshot.recipients.length > 20 &&

{snapshot.recipients.length - 20} more recipients will be imported.

} - - } -
); -} - -type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number]; - -function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) { - const columns: DataGridColumn[] = [ - { 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 `${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[]): 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(); - 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 | undefined): Record { - const fields: Record = {}; - 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 { - 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"; diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index 2f57d1f..e8d2b70 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -1,17 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BarChart3, Check, - ChevronDown, FlaskConical, - Link2, - LockKeyhole, PackageCheck, Search, Send, - ShieldCheck, - X, - type LucideIcon } from + ShieldCheck } from "lucide-react"; import type { ApiSettings } from "../../types"; import { @@ -34,25 +29,22 @@ import { sendCampaignNow, updateCampaignReviewState, validateVersion, - type CampaignAttachmentPreviewFile, type CampaignAttachmentPreviewResponse, type CampaignDeliveryOptions, type CampaignJobsQuery, type CampaignJobsResponse, - type CampaignSummary, - type CampaignVersionDetail } from + type CampaignSummary } from "../../api/campaigns"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { Button, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui"; -import { Dialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui"; -import { InlineHelp, i18nMessage } from "@govoplan/core-webui"; +import { i18nMessage } from "@govoplan/core-webui"; import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; @@ -71,53 +63,41 @@ import { stringifyPreview } from "./utils/campaignView"; import { deliveryModeLabel } from "./utils/deliveryMode"; -import { getBool, getText } from "./utils/draftEditor"; -import { attachmentPreviewLinkableFiles, attachmentPreviewMatchedFiles } from "./utils/attachmentPreview"; +import { getText } from "./utils/draftEditor"; +import { attachmentPreviewLinkableFiles } from "./utils/attachmentPreview"; import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas"; -import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders"; - -type FlowState = -"complete" | -"warning" | -"danger" | -"active" | -"locked" | -"running" | -"partial" | -"stale" | -"pending"; - -type FlowStageDefinition = { - id: string; - title: string; - shortTitle: string; - description: string; - icon: LucideIcon; - state: FlowState; - connectorState?: FlowState; - stateLabel: string; - lockReason?: string; -}; - -type DeliverabilityPreflightItem = { - label: string; - detail: string; - state: "ready" | "warning" | "blocked" | "info"; -}; +import AttachmentLinkingPreview from "./review/AttachmentLinkingPreview"; +import DeliverabilityPreflight, { + type DeliverabilityPreflightItem +} from "./review/DeliverabilityPreflight"; +import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay"; +import BuiltMessagePreview from "./review/BuiltMessagePreview"; +import { + WorkflowFact, + WorkflowNavigation, + WorkflowStage, + stageConnectorState, + stateLabel, + type FlowStageDefinition, + type FlowState +} from "./review/WorkflowNavigation"; +import { + builtMessageKey, + filterAndSortBuiltMessageRows, + findBuiltMessageIndex, + messageNeedsExplicitReview, + reviewQueryEquals, + sameBuiltMessage, + storedMessageReviewState +} from "./review/builtMessageQuery"; +import { + countResolvedAttachments, + formatAddressList, + numberFrom +} from "./review/reviewFormatters"; type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | ""; -const stateColors: Record = { - complete: "var(--green)", - warning: "var(--amber)", - danger: "var(--red)", - active: "var(--blue)", - locked: "var(--line-dark)", - running: "var(--blue)", - partial: "var(--review-flow-partial)", - stale: "var(--review-flow-partial)", - pending: "var(--muted)" -}; const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [ { value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" }, @@ -1778,409 +1758,6 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings } -function WorkflowNavigation({ stages, onSelect }: {stages: FlowStageDefinition[];onSelect: (id: string) => void;}) { - return ( - ); - -} - -function stageConnectorState(stage: FlowStageDefinition): FlowState { - return stage.connectorState ?? stage.state; -} - -function WorkflowStage({ - stage, - nextState, - nextConnectorState, - children - - - - -}: {stage: FlowStageDefinition;nextState?: FlowState;nextConnectorState?: FlowState;children: ReactNode;}) { - const Icon = stage.icon; - const locked = stage.state === "locked"; - const [collapsed, setCollapsed] = useState(false); - const title = i18nMessage(stage.title); - const description = i18nMessage(stage.description); - const stateText = i18nMessage(stage.stateLabel); - const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : ""; - const connectorState = stageConnectorState(stage); - const style = { - "--review-stage-color": stateColors[stage.state], - "--review-stage-line-color": stateColors[connectorState], - "--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState] - } as CSSProperties; - - return ( -
-
); - -} - -function AttachmentLinkingPreview({ - preview, - loading, - error, - linking, - disabled, - onRefresh, - onLink -}: { - preview: CampaignAttachmentPreviewResponse | null; - loading: boolean; - error: string; - linking: boolean; - disabled: boolean; - onRefresh: () => void; - onLink: () => void; -}) { - const [detailsOpen, setDetailsOpen] = useState(false); - const matchedFiles = attachmentPreviewMatchedFiles(preview); - const linkableFiles = attachmentPreviewLinkableFiles(preview); - const linkedCount = preview?.linked_file_count ?? matchedFiles.filter((file) => file.linked_to_campaign !== false).length; - const matchedCount = preview?.matched_file_count ?? matchedFiles.length; - const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length; - return ( - <> -
-
-
-

i18n:govoplan-campaign.attachment_file_links.0be74fd1

-

i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af

-
-
- - -
-
-
- 0 ? - : - loading ? "..." : matchedCount || "—"} /> - - - -
- {error &&

{error}

} - {!error && unlinkedCount > 0 && -

i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998

- } - {!error && !loading && matchedFiles.length === 0 && -

i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c

- } - {matchedFiles.length > 0 && - - } -
- setDetailsOpen(false)} - footer={}> -

i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5

- -
- ); -} - -function AttachmentLinkingFileList({ files, compact = false }: {files: CampaignAttachmentPreviewFile[];compact?: boolean;}) { - return ( -
    - {files.map((file, index) => { - const linked = file.linked_to_campaign !== false; - const Icon = linked ? Link2 : X; - return ( -
  • -
  • - ); - })} -
); -} - -function DeliverabilityPreflight({ items }: {items: DeliverabilityPreflightItem[];}) { - return ( -
-
-

Deliverability preflight

- Operator checks before the first live send. -
-
- {items.map((item) => -
-
- {item.label} - {item.detail} -
- -
- )} -
-
); -} - -function BuiltMessagePreview({ - campaignJson, - entries, - rows, - index, - canStartSingleMessageSend, - singleMessageSendBusy, - onSelect, - onSendSingle, - onClose - - - - - - - -}: {campaignJson: Record;entries: Record[];rows: Record[];index: number;canStartSingleMessageSend: boolean;singleMessageSendBusy: boolean;onSelect: (index: number) => void;onSendSingle: (index: number) => void;onClose: () => void;}) { - const row = rows[index] ?? {}; - const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1); - const entry = entries[entryIndex] ?? {}; - const template = asRecord(campaignJson.template); - const context = buildTemplatePreviewContext(campaignJson, entry); - const ignoreEmptyFields = getBool(asRecord(campaignJson.validation_policy), "ignore_empty_fields", false); - const html = renderTemplatePreviewText(getText(template, "html"), context, ignoreEmptyFields); - const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields); - const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "i18n:govoplan-campaign.no_subject.7b4e8035"); - const issues = asArray(row.issues).map(asRecord); - const resolvedRecipients = asRecord(row.resolved_recipients); - const singleSendDisabledReason = singleMessageSendDisabledReason(row, canStartSingleMessageSend); - - return ( - 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "i18n:govoplan-campaign.issue.73781a12")).join(" · ")}` : "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"} - metaItems={builtMessageMetaItems(row)} - attachments={builtMessageAttachments(row)} - navigation={{ - index, - total: rows.length, - onFirst: () => onSelect(0), - onPrevious: () => onSelect(Math.max(0, index - 1)), - onNext: () => onSelect(Math.min(rows.length - 1, index + 1)), - onLast: () => onSelect(rows.length - 1) - }} - actions={ - - } - onClose={onClose} />); - - -} - -function singleMessageSendDisabledReason(row: Record, canStartSingleMessageSend: boolean): string { - if (!canStartSingleMessageSend) { - return "Validate, build, and complete the required review/mock gate before sending individual messages."; - } - const jobId = String(row.id ?? ""); - if (!jobId) return "This preview row has no delivery job id."; - const buildStatus = String(row.build_status ?? ""); - if (buildStatus !== "built") return "This message has not been built."; - const validationStatus = String(row.validation_status ?? ""); - if (["blocked", "excluded", "inactive"].includes(validationStatus)) { - return `This message is ${humanize(validationStatus)}.`; - } - const sendStatus = String(row.send_status ?? "not_queued"); - const queueStatus = String(row.queue_status ?? "draft"); - if (["smtp_accepted", "sent"].includes(sendStatus)) return "This message was already accepted by SMTP."; - if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) return `This message is in delivery state ${humanize(sendStatus)}.`; - if (["failed_temporary", "failed_permanent"].includes(sendStatus)) return "Use the explicit retry action for failed messages."; - if (sendStatus === "queued" && queueStatus === "queued") return ""; - if (["not_queued", "cancelled"].includes(sendStatus) && ["draft", "cancelled"].includes(queueStatus)) return ""; - return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`; -} - -function DeliveryJobDetailOverlay({ detail, onClose }: {detail: Record;onClose: () => void;}) { - const job = asRecord(detail.job); - const attempts = asRecord(detail.attempts); - const smtpAttempts = asArray(attempts.smtp).map(asRecord); - const imapAttempts = asArray(attempts.imap).map(asRecord); - const issues = asArray(job.issues).map(asRecord); - - return ( - i18n:govoplan-campaign.close.bbfa773e}> - -
-
i18n:govoplan-campaign.recipient.90343260
{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}
-
i18n:govoplan-campaign.subject.8d183dbd
{String(job.subject ?? "-")}
-
i18n:govoplan-campaign.smtp_status.6211cb2b
{humanize(String(job.send_status ?? "-"))}
-
i18n:govoplan-campaign.imap_status.dbc2e430
{humanize(String(job.imap_status ?? "-"))}
- {job.last_error ?
i18n:govoplan-campaign.last_error.5e4df866
{String(job.last_error)}
: null} -
- {issues.length > 0 && } - - -
); - -} - -function AttemptList({ title, rows, emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75" }: {title: string;rows: Record[];emptyText?: string;}) { - return ( -
-

{title}

- {rows.length === 0 ?

{emptyText}

: -
- {rows.map((row, index) => -
-
{String(row.status ?? row.severity ?? row.code ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: index + 1 }))}
-
- {String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")} - {row.started_at || row.created_at ? · {formatDateTime(String(row.started_at ?? row.created_at))} : null} - {row.finished_at || row.updated_at ? → {formatDateTime(String(row.finished_at ?? row.updated_at))} : null} -
-
- )} -
- } -
); - -} - -function WorkflowFact({ label, value }: {label: string;value: ReactNode;}) { - return ( -
- {label} - {value} -
); - -} - function builtMessageColumns( openMessage: (row: Record) => void, reviewedKeys: Set) @@ -2205,144 +1782,6 @@ reviewedKeys: Set) } -type ReviewFilterType = "text" | "integer" | "list"; -type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte"; - -function filterAndSortBuiltMessageRows( -rows: Record[], -query: DataGridQueryState, -reviewedKeys: Set) -: Record[] { - const filters = query.filters ?? {}; - const filtered = rows.filter((row, rowIndex) => - Object.entries(filters).every(([columnId, filterValue]) => { - if (!isBuiltMessageQueryColumn(columnId)) return true; - if (!filterValue.trim()) return true; - return matchesReviewFilter( - builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys), - filterValue, - builtMessageFilterType(columnId) - ); - }) - ); - if (!query.sort) return filtered; - const { columnId, direction } = query.sort; - if (!isBuiltMessageQueryColumn(columnId)) return filtered; - return [...filtered].sort((left, right) => { - const leftIndex = rows.indexOf(left); - const rightIndex = rows.indexOf(right); - const result = compareReviewValues( - builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys), - builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys) - ); - return direction === "desc" ? -result : result; - }); -} - -function isBuiltMessageQueryColumn(columnId: string): boolean { - return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId); -} - -function builtMessageColumnValue( -columnId: string, -row: Record, -index: number, -reviewedKeys: Set) -: unknown { - switch (columnId) { - case "number":return Number(row.entry_index ?? index + 1); - case "recipient":return formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—"); - case "subject":return String(row.subject ?? "—"); - case "validation":return String(row.validation_status ?? "unknown"); - case "attachments":return Number(row.attachment_count ?? countResolvedAttachments(row.attachments)); - case "reviewed":return row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no"; - default:return ""; - } -} - -function builtMessageFilterType(columnId: string): ReviewFilterType { - if (columnId === "number" || columnId === "attachments") return "integer"; - if (columnId === "validation" || columnId === "reviewed") return "list"; - return "text"; -} - -function matchesReviewFilter(value: unknown, filterValue: string, filterType: ReviewFilterType): boolean { - if (!filterValue.trim()) return true; - if (filterType === "list") { - const selected = parseReviewListFilter(filterValue); - return selected.includes(stringifyReviewCell(value)); - } - if (filterType === "integer") { - const parsed = parseReviewTypedFilter(filterValue); - if (!parsed.value.trim()) return true; - const actual = parseReviewNumber(value); - const expected = Number(parsed.value); - if (!Number.isFinite(actual) || !Number.isFinite(expected)) return false; - return compareReviewByOperator(actual, expected, parsed.operator); - } - return stringifyReviewCell(value).toLowerCase().includes(filterValue.trim().toLowerCase()); -} - -function parseReviewListFilter(value: string): string[] { - if (value.startsWith("list:")) { - try { - const parsed = JSON.parse(value.slice(5)); - return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; - } catch { - return []; - } - } - return value.split(",").map((item) => item.trim()).filter(Boolean); -} - -function parseReviewTypedFilter(value: string): {operator: ReviewFilterOperator;value: string;} { - if (!value.includes(":")) return { operator: "eq", value }; - const [operator, ...parts] = value.split(":"); - if (operator === "contains" || operator === "eq" || operator === "gt" || operator === "gte" || operator === "lt" || operator === "lte") { - return { operator, value: parts.join(":") }; - } - return { operator: "eq", value }; -} - -function parseReviewNumber(value: unknown): number { - if (typeof value === "number") return value; - const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", "."); - if (!text.trim()) return NaN; - return Number(text); -} - -function compareReviewByOperator(actual: number, expected: number, operator: ReviewFilterOperator): boolean { - if (operator === "gt") return actual > expected; - if (operator === "gte") return actual >= expected; - if (operator === "lt") return actual < expected; - if (operator === "lte") return actual <= expected; - return actual === expected; -} - -function compareReviewValues(left: unknown, right: unknown): number { - if (typeof left === "number" && typeof right === "number") return left - right; - return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, { numeric: true, sensitivity: "base" }); -} - -function stringifyReviewCell(value: unknown): string { - if (value === null || value === undefined) return ""; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); - if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", "); - return ""; -} - -function reviewQueryEquals(left: DataGridQueryState, right: DataGridQueryState): boolean { - if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false; - if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false; - const leftFilters = left.filters ?? {}; - const rightFilters = right.filters ?? {}; - const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]); - for (const key of keys) { - if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false; - } - return true; -} - function mockSendResultColumns(): DataGridColumn>[] { const options: DataGridListOption[] = [ { value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" }, @@ -2411,84 +1850,6 @@ function mockMailboxColumns(openMessage: (id: string) => Promise): DataGri } -function builtMessageMetaItems(row: Record) { - const recipients = asRecord(row.resolved_recipients); - return [ - { label: "i18n:govoplan-campaign.from.3f66052a", value: formatSingleAddress(recipients.from) || "—" }, - { label: "i18n:govoplan-campaign.to.ae79ea1e", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") }, - { label: "i18n:govoplan-campaign.cc.c5a976de", value: formatAddressList(recipients.cc) || null }, - { label: "i18n:govoplan-campaign.bcc.4c0145a3", value: formatAddressList(recipients.bcc) || null }, - { label: "i18n:govoplan-campaign.validation.dd74d182", value: String(row.validation_status ?? "—") }, - { label: "i18n:govoplan-campaign.mime_size.c8b9d519", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" }]; - -} - -function builtMessageAttachments(row: Record): CampaignMessagePreviewAttachment[] { - return asArray(row.attachments).flatMap((value, index) => { - const attachment = asRecord(value); - const zipProtection = zipProtectionFromBuiltAttachment(attachment); - const managedMatches = asArray(attachment.managed_matches).map(asRecord); - if (managedMatches.length > 0) { - return managedMatches.map((match, matchIndex) => ({ - filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`), - detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""), - contentType: stringOrUndefined(match.content_type), - sizeBytes: numberOrUndefined(match.size_bytes), - archiveGroup: stringOrUndefined(attachment.zip_filename), - archiveLabel: stringOrUndefined(attachment.zip_filename), - protected: zipProtection.protected, - protectionNote: zipProtection.note - })); - } - const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim())); - if (matches.length > 0) { - return matches.map((match) => ({ - filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`), - detail: match, - contentType: stringOrUndefined(attachment.content_type), - sizeBytes: numberOrUndefined(attachment.size_bytes), - archiveGroup: stringOrUndefined(attachment.zip_filename), - archiveLabel: stringOrUndefined(attachment.zip_filename), - protected: zipProtection.protected, - protectionNote: zipProtection.note - })); - } - return [{ - filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`), - detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""), - contentType: stringOrUndefined(attachment.content_type), - sizeBytes: numberOrUndefined(attachment.size_bytes), - archiveGroup: null, - archiveLabel: null, - protected: false, - protectionNote: null - }]; - }); -} - -function zipProtectionFromBuiltAttachment(attachment: Record): {protected: boolean;note: string | null;} { - const zipFilename = stringOrUndefined(attachment.zip_filename); - if (!zipFilename) return { protected: false, note: null }; - const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim(); - const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode)))); - if (!protectedArchive) return { protected: false, note: null }; - const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field); - const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local"); - const scope = rawScope === "global" ? "global" : "local"; - const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6"; - const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : ""; - return { - protected: true, - note: source ? - i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) : - i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method }) - }; -} - -function humanizeScope(scope: string): string { - return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d"; -} - function mockMessageMetaItems(message: MockMailboxMessage) { return [ { label: "i18n:govoplan-campaign.from.3f66052a", value: message.from_header || message.envelope_from || "—" }, @@ -2510,102 +1871,6 @@ function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePre })); } -function countResolvedAttachments(value: unknown): number { - const archives = new Set(); - let directCount = 0; - for (const item of asArray(value)) { - const attachment = asRecord(item); - const zipFilename = String(attachment.zip_filename ?? "").trim(); - const managedCount = asArray(attachment.managed_matches).length; - const matchCount = asArray(attachment.matches).length; - if (zipFilename && (managedCount > 0 || matchCount > 0)) { - archives.add(zipFilename); - continue; - } - if (managedCount > 0) { - directCount += managedCount; - continue; - } - directCount += matchCount; - } - return directCount + archives.size; -} - -function messageNeedsExplicitReview(row: Record): boolean { - return String(row.validation_status ?? "").toLowerCase() === "needs_review"; -} - -function storedMessageReviewState(version: CampaignVersionDetail | null): { - buildToken: string; - inspectionComplete: boolean; - reviewedMessageKeys: string[]; -} { - const build = asRecord(version?.build_summary); - const buildToken = String(build.build_token ?? build.built_at ?? ""); - const review = asRecord(asRecord(version?.editor_state).review_send); - if (!buildToken || String(review.build_token ?? "") !== buildToken) { - return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] }; - } - return { - buildToken, - inspectionComplete: review.inspection_complete === true, - reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string") - }; -} - -function builtMessageKey(row: Record, index: number): string { - return String(row.entry_id ?? row.entry_index ?? index); -} - -function findBuiltMessageIndex(rows: Record[], row: Record): number { - const id = String(row.id ?? "").trim(); - if (id) { - const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id); - if (index >= 0) return index; - } - const reviewKey = String(row.review_key ?? "").trim(); - if (reviewKey) { - const index = rows.findIndex((candidate) => String(candidate.review_key ?? "").trim() === reviewKey); - if (index >= 0) return index; - } - const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim(); - if (entryKey) { - const index = rows.findIndex((candidate) => String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey); - if (index >= 0) return index; - } - return rows.indexOf(row); -} - -function sameBuiltMessage( -left: Record, -leftIndex: number, -right: Record, -rightIndex: number) -: boolean { - const leftId = String(left.id ?? "").trim(); - const rightId = String(right.id ?? "").trim(); - if (leftId && rightId) return leftId === rightId; - const leftReviewKey = String(left.review_key ?? "").trim(); - const rightReviewKey = String(right.review_key ?? "").trim(); - if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey; - const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim(); - const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim(); - if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey; - return left === right || leftIndex === rightIndex; -} - -function formatAddressList(value: unknown): string { - return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", "); -} - -function formatSingleAddress(value: unknown): string { - const address = asRecord(value); - const email = String(address.email ?? "").trim(); - const name = String(address.name ?? "").trim(); - if (name && email) return `${name} <${email}>`; - return email || name; -} - function synchronousSendReason(option: Record): string { const configuredMessage = String(option.message ?? "").trim(); if (configuredMessage) return configuredMessage; @@ -2631,37 +1896,3 @@ function deliveryPolicySourceLabel(source: string): string { if (source === "deployment_ceiling") return "i18n:govoplan-campaign.deployment_ceiling.abbec75b"; return "i18n:govoplan-campaign.not_available.d1a17af1"; } - -function numberFrom(record: Record, keys: string[]): number { - for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); - } - return 0; -} - -function numberOrUndefined(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); - return undefined; -} - -function stringOrUndefined(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - return value.trim() || undefined; -} - -function stateLabel(state: FlowState): string { - switch (state) { - case "complete":return "i18n:govoplan-campaign.passed.271d60f4"; - case "warning":return "i18n:govoplan-campaign.warnings.1430f976"; - case "danger":return "i18n:govoplan-campaign.blocked.99613c74"; - case "active":return "i18n:govoplan-campaign.available.7c62a142"; - case "locked":return "i18n:govoplan-campaign.locked.a798882f"; - case "running":return "i18n:govoplan-campaign.running.73989d9c"; - case "partial":return "i18n:govoplan-campaign.partial.65de2e2a"; - case "stale":return "i18n:govoplan-campaign.stale.189cc40c"; - default:return humanize(state); - } -} diff --git a/webui/src/features/campaigns/recipients/AddressSourceImportDialog.tsx b/webui/src/features/campaigns/recipients/AddressSourceImportDialog.tsx new file mode 100644 index 0000000..6c699d2 --- /dev/null +++ b/webui/src/features/campaigns/recipients/AddressSourceImportDialog.tsx @@ -0,0 +1,404 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Button, + DataGrid, + Dialog, + DismissibleAlert, + FormField, + SegmentedControl, + useGuardedNavigate, + type DataGridColumn +} from "@govoplan/core-webui"; + +import type { ApiSettings } from "../../../types"; +import { + snapshotCampaignRecipientAddressSource, + type CampaignRecipientAddressSource, + type CampaignRecipientAddressSourceSnapshot +} from "../../../api/campaigns"; +import type { RecipientImportMode } from "../utils/bulkImport"; +import { asRecord } from "../utils/campaignView"; +import { fieldValueToString } from "../utils/addressSourceImport"; + +type AddressSourceFilter = "all" | "books" | "lists"; + +export default function AddressSourceImportDialog({ + settings, + campaignId, + sources, + initialSourceId = "", + onCancel, + onImport +}: { + settings: ApiSettings; + campaignId: string; + sources: CampaignRecipientAddressSource[]; + initialSourceId?: string; + onCancel: () => void; + onImport: ( + snapshot: CampaignRecipientAddressSourceSnapshot, + mode: RecipientImportMode + ) => void; +}) { + const navigate = useGuardedNavigate(); + const [selectedSourceId, setSelectedSourceId] = useState( + initialSourceId || sources[0]?.source_id || "" + ); + const [sourceFilter, setSourceFilter] = useState("all"); + const [sourceQuery, setSourceQuery] = useState(""); + const [mode, setMode] = useState("append"); + const [snapshot, setSnapshot] = + useState(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 ( + + + + + } + > +
+
+ +
+ setSourceQuery(event.target.value)} + /> + +
+ +
+
+ {filteredSources.map((source) => ( + + ))} + {sources.length > 0 && filteredSources.length === 0 && ( +
+ No address sources match the current filter. +
+ )} +
+ + + +
+ + {error && ( + + {error} + + )} + {loading && ( + + Loading address recipients... + + )} + {!loading && sources.length === 0 && ( + + No address sources are available to this campaign. Create an address book or + address list in the addresses module first. + + )} + + {snapshot && ( + <> +
+
+
Source
+
{snapshot.source_label}
+
+
+
Type
+
+ {selectedSource + ? addressSourceTypeLabel(selectedSource) + : "Address source"} +
+
+
+
Scope
+
+ {selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"} +
+
+
+
Recipients
+
{snapshot.recipients.length}
+
+
+
Revision
+
{snapshot.source_revision}
+
+
+ + {snapshot.recipients.length > 20 && ( +

+ {snapshot.recipients.length - 20} more recipients will be imported. +

+ )} + + )} +
+ ); +} + +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)}...`; +} + +type AddressSourceSnapshotRecipient = + CampaignRecipientAddressSourceSnapshot["recipients"][number]; + +function AddressSourceRecipientPreviewGrid({ + recipients +}: { + recipients: AddressSourceSnapshotRecipient[]; +}) { + const columns: DataGridColumn[] = [ + { + 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 ( + `${recipient.contact_id}-${recipient.email}`} + /> + ); +} diff --git a/webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx b/webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx new file mode 100644 index 0000000..e4a146b --- /dev/null +++ b/webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx @@ -0,0 +1,191 @@ +import { useState } from "react"; +import { Link2, X } from "lucide-react"; +import { Button, Dialog, i18nMessage } from "@govoplan/core-webui"; + +import type { + CampaignAttachmentPreviewFile, + CampaignAttachmentPreviewResponse +} from "../../../api/campaigns"; +import { + attachmentPreviewLinkableFiles, + attachmentPreviewMatchedFiles +} from "../utils/attachmentPreview"; +import { WorkflowFact } from "./WorkflowNavigation"; + +export default function AttachmentLinkingPreview({ + preview, + loading, + error, + linking, + disabled, + onRefresh, + onLink +}: { + preview: CampaignAttachmentPreviewResponse | null; + loading: boolean; + error: string; + linking: boolean; + disabled: boolean; + onRefresh: () => void; + onLink: () => void; +}) { + const [detailsOpen, setDetailsOpen] = useState(false); + const matchedFiles = attachmentPreviewMatchedFiles(preview); + const linkableFiles = attachmentPreviewLinkableFiles(preview); + const linkedCount = + preview?.linked_file_count ?? + matchedFiles.filter((file) => file.linked_to_campaign !== false).length; + const matchedCount = preview?.matched_file_count ?? matchedFiles.length; + const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length; + + return ( + <> +
+
+
+

i18n:govoplan-campaign.attachment_file_links.0be74fd1

+

+ i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af +

+
+
+ + +
+
+
+ 0 ? ( + + ) : loading ? ( + "..." + ) : ( + matchedCount || "—" + ) + } + /> + + + +
+ {error &&

{error}

} + {!error && unlinkedCount > 0 && ( +

+ i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998 +

+ )} + {!error && !loading && matchedFiles.length === 0 && ( +

+ i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c +

+ )} + {matchedFiles.length > 0 && } +
+ setDetailsOpen(false)} + footer={ + + } + > +

+ i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5 +

+ +
+ + ); +} + +function AttachmentLinkingFileList({ + files, + compact = false +}: { + files: CampaignAttachmentPreviewFile[]; + compact?: boolean; +}) { + return ( +
    + {files.map((file, index) => { + const linked = file.linked_to_campaign !== false; + const Icon = linked ? Link2 : X; + return ( +
  • +
  • + ); + })} +
+ ); +} diff --git a/webui/src/features/campaigns/review/BuiltMessagePreview.tsx b/webui/src/features/campaigns/review/BuiltMessagePreview.tsx new file mode 100644 index 0000000..2a47e77 --- /dev/null +++ b/webui/src/features/campaigns/review/BuiltMessagePreview.tsx @@ -0,0 +1,312 @@ +import { Button, i18nMessage } from "@govoplan/core-webui"; + +import CampaignMessagePreviewOverlay, { + type CampaignMessagePreviewAttachment +} from "../components/MessagePreviewOverlay"; +import { asArray, asRecord, humanize } from "../utils/campaignView"; +import { getBool, getText } from "../utils/draftEditor"; +import { + buildTemplatePreviewContext, + renderTemplatePreviewText +} from "../utils/templatePlaceholders"; +import { + formatAddressList, + formatSingleAddress, + numberFrom, + numberOrUndefined, + stringOrUndefined +} from "./reviewFormatters"; + +export default function BuiltMessagePreview({ + campaignJson, + entries, + rows, + index, + canStartSingleMessageSend, + singleMessageSendBusy, + onSelect, + onSendSingle, + onClose +}: { + campaignJson: Record; + entries: Record[]; + rows: Record[]; + index: number; + canStartSingleMessageSend: boolean; + singleMessageSendBusy: boolean; + onSelect: (index: number) => void; + onSendSingle: (index: number) => void; + onClose: () => void; +}) { + const row = rows[index] ?? {}; + const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1); + const entry = entries[entryIndex] ?? {}; + const template = asRecord(campaignJson.template); + const context = buildTemplatePreviewContext(campaignJson, entry); + const ignoreEmptyFields = getBool( + asRecord(campaignJson.validation_policy), + "ignore_empty_fields", + false + ); + const html = renderTemplatePreviewText( + getText(template, "html"), + context, + ignoreEmptyFields + ); + const text = renderTemplatePreviewText( + getText(template, "text"), + context, + ignoreEmptyFields + ); + const subject = String( + row.subject || + renderTemplatePreviewText( + getText(template, "subject"), + context, + ignoreEmptyFields + ) || + "i18n:govoplan-campaign.no_subject.7b4e8035" + ); + const issues = asArray(row.issues).map(asRecord); + const resolvedRecipients = asRecord(row.resolved_recipients); + const singleSendDisabledReason = singleMessageSendDisabledReason( + row, + canStartSingleMessageSend + ); + + return ( + 0 + ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues + .map((issue) => + String( + issue.message ?? + issue.code ?? + "i18n:govoplan-campaign.issue.73781a12" + ) + ) + .join(" · ")}` + : "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6" + } + metaItems={builtMessageMetaItems(row)} + attachments={builtMessageAttachments(row)} + navigation={{ + index, + total: rows.length, + onFirst: () => onSelect(0), + onPrevious: () => onSelect(Math.max(0, index - 1)), + onNext: () => onSelect(Math.min(rows.length - 1, index + 1)), + onLast: () => onSelect(rows.length - 1) + }} + actions={ + + } + onClose={onClose} + /> + ); +} + +function singleMessageSendDisabledReason( + row: Record, + canStartSingleMessageSend: boolean +): string { + if (!canStartSingleMessageSend) { + return "Validate, build, and complete the required review/mock gate before sending individual messages."; + } + const jobId = String(row.id ?? ""); + if (!jobId) return "This preview row has no delivery job id."; + const buildStatus = String(row.build_status ?? ""); + if (buildStatus !== "built") return "This message has not been built."; + const validationStatus = String(row.validation_status ?? ""); + if (["blocked", "excluded", "inactive"].includes(validationStatus)) { + return `This message is ${humanize(validationStatus)}.`; + } + const sendStatus = String(row.send_status ?? "not_queued"); + const queueStatus = String(row.queue_status ?? "draft"); + if (["smtp_accepted", "sent"].includes(sendStatus)) { + return "This message was already accepted by SMTP."; + } + if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) { + return `This message is in delivery state ${humanize(sendStatus)}.`; + } + if (["failed_temporary", "failed_permanent"].includes(sendStatus)) { + return "Use the explicit retry action for failed messages."; + } + if (sendStatus === "queued" && queueStatus === "queued") return ""; + if ( + ["not_queued", "cancelled"].includes(sendStatus) && + ["draft", "cancelled"].includes(queueStatus) + ) { + return ""; + } + return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`; +} + +function builtMessageMetaItems(row: Record) { + const recipients = asRecord(row.resolved_recipients); + return [ + { + label: "i18n:govoplan-campaign.from.3f66052a", + value: formatSingleAddress(recipients.from) || "—" + }, + { + label: "i18n:govoplan-campaign.to.ae79ea1e", + value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") + }, + { + label: "i18n:govoplan-campaign.cc.c5a976de", + value: formatAddressList(recipients.cc) || null + }, + { + label: "i18n:govoplan-campaign.bcc.4c0145a3", + value: formatAddressList(recipients.bcc) || null + }, + { + label: "i18n:govoplan-campaign.validation.dd74d182", + value: String(row.validation_status ?? "—") + }, + { + label: "i18n:govoplan-campaign.mime_size.c8b9d519", + value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" + } + ]; +} + +function builtMessageAttachments( + row: Record +): CampaignMessagePreviewAttachment[] { + return asArray(row.attachments).flatMap((value, index) => { + const attachment = asRecord(value); + const zipProtection = zipProtectionFromBuiltAttachment(attachment); + const managedMatches = asArray(attachment.managed_matches).map(asRecord); + if (managedMatches.length > 0) { + return managedMatches.map((match, matchIndex) => ({ + filename: String( + match.filename ?? + match.display_path ?? + `Attachment ${index + 1}.${matchIndex + 1}` + ), + detail: String( + match.display_path ?? match.relative_path ?? attachment.label ?? "" + ), + contentType: stringOrUndefined(match.content_type), + sizeBytes: numberOrUndefined(match.size_bytes), + archiveGroup: stringOrUndefined(attachment.zip_filename), + archiveLabel: stringOrUndefined(attachment.zip_filename), + protected: zipProtection.protected, + protectionNote: zipProtection.note + })); + } + const matches = asArray(attachment.matches).filter( + (match): match is string => typeof match === "string" && Boolean(match.trim()) + ); + if (matches.length > 0) { + return matches.map((match) => ({ + filename: + match.split(/[\\/]/).pop() || + String(attachment.label ?? `Attachment ${index + 1}`), + detail: match, + contentType: stringOrUndefined(attachment.content_type), + sizeBytes: numberOrUndefined(attachment.size_bytes), + archiveGroup: stringOrUndefined(attachment.zip_filename), + archiveLabel: stringOrUndefined(attachment.zip_filename), + protected: zipProtection.protected, + protectionNote: zipProtection.note + })); + } + return [{ + filename: String( + attachment.filename ?? + attachment.filename_used ?? + attachment.display_path ?? + attachment.label ?? + `Attachment ${index + 1}` + ), + detail: String( + attachment.display_path ?? + attachment.source_path ?? + attachment.file_filter ?? + "" + ), + contentType: stringOrUndefined(attachment.content_type), + sizeBytes: numberOrUndefined(attachment.size_bytes), + archiveGroup: null, + archiveLabel: null, + protected: false, + protectionNote: null + }]; + }); +} + +function zipProtectionFromBuiltAttachment( + attachment: Record +): { protected: boolean; note: string | null } { + const zipFilename = stringOrUndefined(attachment.zip_filename); + if (!zipFilename) return { protected: false, note: null }; + const legacyMode = String( + attachment.password_mode ?? attachment.zip_password_mode ?? "" + ).trim(); + const protectedArchive = getBool( + attachment, + "password_enabled", + getBool( + attachment, + "zip_password_protected", + getBool( + attachment, + "zip_protected", + ["direct", "field", "template"].includes(legacyMode) + ) + ) + ); + if (!protectedArchive) return { protected: false, note: null }; + const field = + stringOrUndefined(attachment.password_field) ?? + stringOrUndefined(attachment.zip_password_field); + const rawScope = String( + attachment.password_scope ?? attachment.zip_password_scope ?? "local" + ); + const scope = rawScope === "global" ? "global" : "local"; + const method = + String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" + ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" + : "i18n:govoplan-campaign.aes.41f215a6"; + const source = field + ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { + value0: humanizeScope(scope), + value1: field + }) + : ""; + return { + protected: true, + note: source + ? i18nMessage("i18n:govoplan-campaign.value_encryption_value", { + value0: source, + value1: method + }) + : i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method }) + }; +} + +function humanizeScope(scope: string): string { + return scope === "global" + ? "i18n:govoplan-campaign.global.5f1184f7" + : "i18n:govoplan-campaign.local.dc99d54d"; +} diff --git a/webui/src/features/campaigns/review/DeliverabilityPreflight.tsx b/webui/src/features/campaigns/review/DeliverabilityPreflight.tsx new file mode 100644 index 0000000..b4a7d99 --- /dev/null +++ b/webui/src/features/campaigns/review/DeliverabilityPreflight.tsx @@ -0,0 +1,50 @@ +import { StatusBadge } from "@govoplan/core-webui"; + +import { humanize } from "../utils/campaignView"; + +export type DeliverabilityPreflightItem = { + label: string; + detail: string; + state: "ready" | "warning" | "blocked" | "info"; +}; + +export default function DeliverabilityPreflight({ + items +}: { + items: DeliverabilityPreflightItem[]; +}) { + return ( +
+
+

Deliverability preflight

+ Operator checks before the first live send. +
+
+ {items.map((item) => ( +
+
+ {item.label} + {item.detail} +
+ +
+ ))} +
+
+ ); +} diff --git a/webui/src/features/campaigns/review/DeliveryJobDetailOverlay.tsx b/webui/src/features/campaigns/review/DeliveryJobDetailOverlay.tsx new file mode 100644 index 0000000..6e5de23 --- /dev/null +++ b/webui/src/features/campaigns/review/DeliveryJobDetailOverlay.tsx @@ -0,0 +1,136 @@ +import { Button, Dialog, i18nMessage } from "@govoplan/core-webui"; + +import { asArray, asRecord, formatDateTime, humanize } from "../utils/campaignView"; +import { formatAddressList } from "./reviewFormatters"; + +export default function DeliveryJobDetailOverlay({ + detail, + onClose +}: { + detail: Record; + onClose: () => void; +}) { + const job = asRecord(detail.job); + const attempts = asRecord(detail.attempts); + const smtpAttempts = asArray(attempts.smtp).map(asRecord); + const imapAttempts = asArray(attempts.imap).map(asRecord); + const issues = asArray(job.issues).map(asRecord); + + return ( + + i18n:govoplan-campaign.close.bbfa773e + + } + > +
+
+
i18n:govoplan-campaign.recipient.90343260
+
+ {formatAddressList(asRecord(job.resolved_recipients).to) || + String(job.recipient_email ?? "-")} +
+
+
+
i18n:govoplan-campaign.subject.8d183dbd
+
{String(job.subject ?? "-")}
+
+
+
i18n:govoplan-campaign.smtp_status.6211cb2b
+
{humanize(String(job.send_status ?? "-"))}
+
+
+
i18n:govoplan-campaign.imap_status.dbc2e430
+
{humanize(String(job.imap_status ?? "-"))}
+
+ {job.last_error ? ( +
+
i18n:govoplan-campaign.last_error.5e4df866
+
{String(job.last_error)}
+
+ ) : null} +
+ {issues.length > 0 && ( + + )} + + +
+ ); +} + +function AttemptList({ + title, + rows, + emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75" +}: { + title: string; + rows: Record[]; + emptyText?: string; +}) { + return ( +
+

{title}

+ {rows.length === 0 ? ( +

{emptyText}

+ ) : ( +
+ {rows.map((row, index) => ( +
+
+ {String( + row.status ?? + row.severity ?? + row.code ?? + i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { + value0: index + 1 + }) + )} +
+
+ + {String( + row.message ?? + row.error_message ?? + row.smtp_response ?? + row.folder ?? + row.path ?? + "-" + )} + + {row.started_at || row.created_at ? ( + + {" · "} + {formatDateTime(String(row.started_at ?? row.created_at))} + + ) : null} + {row.finished_at || row.updated_at ? ( + + {" → "} + {formatDateTime(String(row.finished_at ?? row.updated_at))} + + ) : null} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/webui/src/features/campaigns/review/WorkflowNavigation.tsx b/webui/src/features/campaigns/review/WorkflowNavigation.tsx new file mode 100644 index 0000000..859c305 --- /dev/null +++ b/webui/src/features/campaigns/review/WorkflowNavigation.tsx @@ -0,0 +1,229 @@ +import { useState, type CSSProperties, type ReactNode } from "react"; +import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react"; +import { InlineHelp, i18nMessage } from "@govoplan/core-webui"; + +import { humanize } from "../utils/campaignView"; + +export type FlowState = + | "complete" + | "warning" + | "danger" + | "active" + | "locked" + | "running" + | "partial" + | "stale" + | "pending"; + +export type FlowStageDefinition = { + id: string; + title: string; + shortTitle: string; + description: string; + icon: LucideIcon; + state: FlowState; + connectorState?: FlowState; + stateLabel: string; + lockReason?: string; +}; + +const stateColors: Record = { + complete: "var(--green)", + warning: "var(--amber)", + danger: "var(--red)", + active: "var(--blue)", + locked: "var(--line-dark)", + running: "var(--blue)", + partial: "var(--review-flow-partial)", + stale: "var(--review-flow-partial)", + pending: "var(--muted)" +}; + +export function WorkflowNavigation({ + stages, + onSelect +}: { + stages: FlowStageDefinition[]; + onSelect: (id: string) => void; +}) { + return ( + + ); +} + +export function stageConnectorState(stage: FlowStageDefinition): FlowState { + return stage.connectorState ?? stage.state; +} + +export function WorkflowStage({ + stage, + nextState, + nextConnectorState, + children +}: { + stage: FlowStageDefinition; + nextState?: FlowState; + nextConnectorState?: FlowState; + children: ReactNode; +}) { + const Icon = stage.icon; + const locked = stage.state === "locked"; + const [collapsed, setCollapsed] = useState(false); + const title = i18nMessage(stage.title); + const description = i18nMessage(stage.description); + const stateText = i18nMessage(stage.stateLabel); + const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : ""; + const connectorState = stageConnectorState(stage); + const style = { + "--review-stage-color": stateColors[stage.state], + "--review-stage-line-color": stateColors[connectorState], + "--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState] + } as CSSProperties; + + return ( +
+
+ ); +} + +export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function stateLabel(state: FlowState): string { + switch (state) { + case "complete": + return "i18n:govoplan-campaign.passed.271d60f4"; + case "warning": + return "i18n:govoplan-campaign.warnings.1430f976"; + case "danger": + return "i18n:govoplan-campaign.blocked.99613c74"; + case "active": + return "i18n:govoplan-campaign.available.7c62a142"; + case "locked": + return "i18n:govoplan-campaign.locked.a798882f"; + case "running": + return "i18n:govoplan-campaign.running.73989d9c"; + case "partial": + return "i18n:govoplan-campaign.partial.65de2e2a"; + case "stale": + return "i18n:govoplan-campaign.stale.189cc40c"; + default: + return humanize(state); + } +} diff --git a/webui/src/features/campaigns/review/builtMessageQuery.ts b/webui/src/features/campaigns/review/builtMessageQuery.ts new file mode 100644 index 0000000..9c5a297 --- /dev/null +++ b/webui/src/features/campaigns/review/builtMessageQuery.ts @@ -0,0 +1,258 @@ +import type { DataGridQueryState } from "@govoplan/core-webui"; + +import type { CampaignVersionDetail } from "../../../api/campaigns"; +import { asArray, asRecord } from "../utils/campaignView"; +import { countResolvedAttachments, formatAddressList } from "./reviewFormatters"; + +type ReviewFilterType = "text" | "integer" | "list"; +type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte"; + +export function filterAndSortBuiltMessageRows( + rows: Record[], + query: DataGridQueryState, + reviewedKeys: Set +): Record[] { + const filters = query.filters ?? {}; + const filtered = rows.filter((row, rowIndex) => + Object.entries(filters).every(([columnId, filterValue]) => { + if (!isBuiltMessageQueryColumn(columnId)) return true; + if (!filterValue.trim()) return true; + return matchesReviewFilter( + builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys), + filterValue, + builtMessageFilterType(columnId) + ); + }) + ); + if (!query.sort) return filtered; + const { columnId, direction } = query.sort; + if (!isBuiltMessageQueryColumn(columnId)) return filtered; + return [...filtered].sort((left, right) => { + const leftIndex = rows.indexOf(left); + const rightIndex = rows.indexOf(right); + const result = compareReviewValues( + builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys), + builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys) + ); + return direction === "desc" ? -result : result; + }); +} + +export function reviewQueryEquals( + left: DataGridQueryState, + right: DataGridQueryState +): boolean { + if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false; + if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false; + const leftFilters = left.filters ?? {}; + const rightFilters = right.filters ?? {}; + const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]); + for (const key of keys) { + if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false; + } + return true; +} + +export function messageNeedsExplicitReview(row: Record): boolean { + return String(row.validation_status ?? "").toLowerCase() === "needs_review"; +} + +export function storedMessageReviewState(version: CampaignVersionDetail | null): { + buildToken: string; + inspectionComplete: boolean; + reviewedMessageKeys: string[]; +} { + const build = asRecord(version?.build_summary); + const buildToken = String(build.build_token ?? build.built_at ?? ""); + const review = asRecord(asRecord(version?.editor_state).review_send); + if (!buildToken || String(review.build_token ?? "") !== buildToken) { + return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] }; + } + return { + buildToken, + inspectionComplete: review.inspection_complete === true, + reviewedMessageKeys: asArray(review.reviewed_message_keys).filter( + (value): value is string => typeof value === "string" + ) + }; +} + +export function builtMessageKey(row: Record, index: number): string { + return String(row.entry_id ?? row.entry_index ?? index); +} + +export function findBuiltMessageIndex( + rows: Record[], + row: Record +): number { + const id = String(row.id ?? "").trim(); + if (id) { + const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id); + if (index >= 0) return index; + } + const reviewKey = String(row.review_key ?? "").trim(); + if (reviewKey) { + const index = rows.findIndex( + (candidate) => String(candidate.review_key ?? "").trim() === reviewKey + ); + if (index >= 0) return index; + } + const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim(); + if (entryKey) { + const index = rows.findIndex( + (candidate) => + String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey + ); + if (index >= 0) return index; + } + return rows.indexOf(row); +} + +export function sameBuiltMessage( + left: Record, + leftIndex: number, + right: Record, + rightIndex: number +): boolean { + const leftId = String(left.id ?? "").trim(); + const rightId = String(right.id ?? "").trim(); + if (leftId && rightId) return leftId === rightId; + const leftReviewKey = String(left.review_key ?? "").trim(); + const rightReviewKey = String(right.review_key ?? "").trim(); + if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey; + const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim(); + const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim(); + if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey; + return left === right || leftIndex === rightIndex; +} + +function isBuiltMessageQueryColumn(columnId: string): boolean { + return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId); +} + +function builtMessageColumnValue( + columnId: string, + row: Record, + index: number, + reviewedKeys: Set +): unknown { + switch (columnId) { + case "number": + return Number(row.entry_index ?? index + 1); + case "recipient": + return ( + formatAddressList(asRecord(row.resolved_recipients).to) || + String(row.recipient_email ?? "—") + ); + case "subject": + return String(row.subject ?? "—"); + case "validation": + return String(row.validation_status ?? "unknown"); + case "attachments": + return Number(row.attachment_count ?? countResolvedAttachments(row.attachments)); + case "reviewed": + return row.reviewed === true || + reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) + ? "yes" + : "no"; + default: + return ""; + } +} + +function builtMessageFilterType(columnId: string): ReviewFilterType { + if (["number", "attachments"].includes(columnId)) return "integer"; + if (["validation", "reviewed"].includes(columnId)) return "list"; + return "text"; +} + +function matchesReviewFilter( + value: unknown, + filterValue: string, + filterType: ReviewFilterType +): boolean { + if (!filterValue.trim()) return true; + if (filterType === "list") { + const selected = parseReviewListFilter(filterValue); + return selected.includes(stringifyReviewCell(value)); + } + if (filterType === "integer") { + const parsed = parseReviewTypedFilter(filterValue); + if (!parsed.value.trim()) return true; + const actual = parseReviewNumber(value); + const expected = Number(parsed.value); + if (!Number.isFinite(expected) || !Number.isFinite(actual)) return false; + return compareReviewByOperator(actual, expected, parsed.operator); + } + return stringifyReviewCell(value) + .toLowerCase() + .includes(filterValue.trim().toLowerCase()); +} + +function parseReviewListFilter(value: string): string[] { + if (value.startsWith("list:")) { + try { + const parsed = JSON.parse(value.slice(5)); + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } + } + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +function parseReviewTypedFilter( + value: string +): { operator: ReviewFilterOperator; value: string } { + if (!value.includes(":")) return { operator: "eq", value }; + const [operator, ...parts] = value.split(":"); + if ( + operator === "contains" || + operator === "eq" || + operator === "gt" || + operator === "gte" || + operator === "lt" || + operator === "lte" + ) { + return { operator, value: parts.join(":") }; + } + return { operator: "eq", value }; +} + +function parseReviewNumber(value: unknown): number { + if (typeof value === "number") return value; + const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", "."); + if (!text.trim()) return Number.NaN; + return Number(text); +} + +function compareReviewByOperator( + actual: number, + expected: number, + operator: ReviewFilterOperator +): boolean { + if (operator === "gt") return actual > expected; + if (operator === "gte") return actual >= expected; + if (operator === "lt") return actual < expected; + if (operator === "lte") return actual <= expected; + return actual === expected; +} + +function compareReviewValues(left: unknown, right: unknown): number { + if (typeof left === "number" && typeof right === "number") return left - right; + return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, { + numeric: true, + sensitivity: "base" + }); +} + +function stringifyReviewCell(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", "); + return ""; +} diff --git a/webui/src/features/campaigns/review/reviewFormatters.ts b/webui/src/features/campaigns/review/reviewFormatters.ts new file mode 100644 index 0000000..5c5d3ca --- /dev/null +++ b/webui/src/features/campaigns/review/reviewFormatters.ts @@ -0,0 +1,66 @@ +import { asArray, asRecord } from "../utils/campaignView"; + +export function formatAddressList(value: unknown): string { + return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", "); +} + +export function formatSingleAddress(value: unknown): string { + const address = asRecord(value); + const email = String(address.email ?? "").trim(); + const name = String(address.name ?? "").trim(); + if (name && email) return `${name} <${email}>`; + return email || name; +} + +export function countResolvedAttachments(value: unknown): number { + const archives = new Set(); + let directCount = 0; + for (const item of asArray(value)) { + const attachment = asRecord(item); + const zipFilename = String(attachment.zip_filename ?? "").trim(); + const managedCount = asArray(attachment.managed_matches).length; + const matchCount = asArray(attachment.matches).length; + if (zipFilename && (managedCount > 0 || matchCount > 0)) { + archives.add(zipFilename); + continue; + } + if (managedCount > 0) { + directCount += managedCount; + continue; + } + directCount += matchCount; + } + return directCount + archives.size; +} + +export function numberFrom(record: Record, keys: string[]): number { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if ( + typeof value === "string" && + value.trim() && + Number.isFinite(Number(value)) + ) { + return Number(value); + } + } + return 0; +} + +export function numberOrUndefined(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if ( + typeof value === "string" && + value.trim() && + Number.isFinite(Number(value)) + ) { + return Number(value); + } + return undefined; +} + +export function stringOrUndefined(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return value.trim() || undefined; +} diff --git a/webui/src/features/campaigns/utils/addressSourceImport.ts b/webui/src/features/campaigns/utils/addressSourceImport.ts new file mode 100644 index 0000000..45878f4 --- /dev/null +++ b/webui/src/features/campaigns/utils/addressSourceImport.ts @@ -0,0 +1,168 @@ +import type { CampaignRecipientAddressSourceSnapshot } from "../../../api/campaigns"; +import type { + ImportedRecipientRow, + RecipientImportMode, + RecipientImportPreview, + RecipientImportProvenance, + RecipientImportTable +} from "./bulkImport"; + +export function buildAddressSourceImportPreview( + snapshot: CampaignRecipientAddressSourceSnapshot, + existingEntries: Record[] +): 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(); + 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 + }; +} + +export 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: [] + }; +} + +export 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 stringFieldsFromAddressSource( + value: Record | undefined +): Record { + const fields: Record = {}; + for (const [key, rawValue] of Object.entries(value ?? {})) { + const fieldValue = fieldValueToString(rawValue); + if (fieldValue) fields[key] = fieldValue; + } + return fields; +} + +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 { + 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; +} diff --git a/webui/tests/review-preview-ui.test.ts b/webui/tests/review-preview-ui.test.ts index b2af89d..8d8b353 100644 --- a/webui/tests/review-preview-ui.test.ts +++ b/webui/tests/review-preview-ui.test.ts @@ -57,13 +57,16 @@ const explicitLinkablePreview = { }; assert(attachmentPreviewLinkableFiles(explicitLinkablePreview).length === 2, "explicit linkable candidates are deduplicated without a display cap"); -const reviewSource = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8"); +const reviewSource = [ + readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8"), + readFileSync("src/features/campaigns/review/AttachmentLinkingPreview.tsx", "utf8") +].join("\n"); assert(!reviewSource.includes("attachmentPreviewMatchedFiles(preview).slice("), "review attachment links must not use an arbitrary display slice"); assert(reviewSource.includes(""), "compact review exposes the complete bounded attachment list"); assert(reviewSource.includes('aria-haspopup="dialog"'), "the matched count advertises its attachment detail dialog"); assert(reviewSource.includes("tabIndex={0}"), "bounded attachment lists are keyboard-focusable scroll regions"); -assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30"'), "dynamic attachment dialog title uses the i18n message contract"); -assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951"'), "dynamic attachment count aria label uses the i18n message contract"); +assert(/i18nMessage\(\s*"i18n:govoplan-campaign\.attachment_file_links_value\.ce230e30"/.test(reviewSource), "dynamic attachment dialog title uses the i18n message contract"); +assert(/i18nMessage\(\s*"i18n:govoplan-campaign\.view_all_value_matched_attachment_file_links\.81e53951"/.test(reviewSource), "dynamic attachment count aria label uses the i18n message contract"); assert(!reviewSource.includes(">Attachment file links<"), "attachment review heading is not raw English"); assert(!reviewSource.includes('label="Matched"'), "attachment review facts are not raw English"); assert(!reviewSource.includes("`Link ${unlinkedCount}"), "attachment link actions are not assembled from raw English");