diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts index cdefd66..f039873 100644 --- a/src/api/campaigns.ts +++ b/src/api/campaigns.ts @@ -131,6 +131,51 @@ export type CampaignSendNowPayload = { enqueue_imap_task?: boolean; }; + +export type CampaignAttachmentPreviewFile = { + id: string; + version_id?: string; + blob_id?: string; + display_path: string; + filename: string; + owner_type: string; + owner_id: string; + checksum_sha256?: string; + size_bytes?: number; + content_type?: string | null; +}; + +export type CampaignAttachmentPreviewRule = { + source: "global" | "entry"; + entry_index: number; + entry_id?: string | null; + index: number; + attachment_id?: string | null; + label?: string | null; + required: boolean; + pattern: string; + base_path_name?: string | null; + base_path?: string | null; + status: "ok" | "missing" | "ambiguous"; + behavior?: string | null; + matches: CampaignAttachmentPreviewFile[]; + match_count: number; + issues: Record[]; +}; + +export type CampaignAttachmentPreviewResponse = { + campaign_id: string; + version_id: string; + shared_file_count: number; + rules: CampaignAttachmentPreviewRule[]; + unused_shared_files: CampaignAttachmentPreviewFile[]; +}; + +export type CampaignAttachmentPreviewPayload = { + include_unmatched?: boolean; + campaign_json?: Record; +}; + export type CampaignMockSendPayload = { version_id?: string | null; send?: boolean; @@ -309,6 +354,20 @@ export async function buildVersion( }); } + +export function previewCampaignAttachments( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignAttachmentPreviewPayload = {} +): Promise { + return apiFetch( + settings, + `/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + export async function getCampaignSummary(settings: ApiSettings, campaignId: string): Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/summary`); } diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index a1f988a..f0de35c 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -75,7 +75,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings function addGlobalAttachmentRule() { if (locked) return; - patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules))]); + patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules), basePaths[0]?.id ?? "")]); } diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index e77b2f6..1fd85e8 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ApiSettings } from "../../types"; +import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns"; import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; @@ -13,7 +14,6 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { cloneJson, getBool, getText } from "./utils/draftEditor"; -import { buildEffectiveAttachmentPreviews, type EffectiveAttachmentPreview } from "./utils/attachments"; import { humanizeFieldName } from "./utils/fieldDefinitions"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; @@ -27,6 +27,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A const [previewOpen, setPreviewOpen] = useState(false); const [previewIndex, setPreviewIndex] = useState(0); const [undefinedDialog, setUndefinedDialog] = useState(null); + const [attachmentPreviewRules, setAttachmentPreviewRules] = useState([]); + const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false); + const [attachmentPreviewError, setAttachmentPreviewError] = useState(""); const subjectRef = useRef(null); const textRef = useRef(null); const htmlRef = useRef(null); @@ -64,13 +67,50 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields); const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields); const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields); - const previewAttachments = useMemo(() => buildEffectiveAttachmentPreviews(displayDraft, previewEntry), [displayDraft, previewEntry]); + const selectedPreviewEntryIndex = Math.min(previewIndex, previewEntries.length - 1) + 1; + const previewAttachmentRules = useMemo( + () => attachmentPreviewRules.filter((rule) => rule.entry_index === selectedPreviewEntryIndex), + [attachmentPreviewRules, selectedPreviewEntryIndex] + ); + const previewAttachments = useMemo( + () => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError), + [attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules] + ); useEffect(() => { if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1)); }, [previewIndex, previewEntries.length]); + useEffect(() => { + if (!previewOpen || !version?.id || !draft) return; + let cancelled = false; + setAttachmentPreviewLoading(true); + setAttachmentPreviewError(""); + const handle = window.setTimeout(() => { + void previewCampaignAttachments(settings, campaignId, version.id, { + include_unmatched: false, + campaign_json: displayDraft + }) + .then((response) => { + if (!cancelled) setAttachmentPreviewRules(response.rules); + }) + .catch((reason: unknown) => { + if (!cancelled) { + setAttachmentPreviewRules([]); + setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason)); + } + }) + .finally(() => { + if (!cancelled) setAttachmentPreviewLoading(false); + }); + }, 120); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]); + function patchTemplateText(target: EditorTarget, value: string) { patch(["template", target], value); @@ -242,7 +282,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A html={previewHtml} recipientLabel={inlineEntries.length > 0 ? recipientLabel(previewEntry, Math.min(previewIndex, previewEntries.length - 1)) : "Global preview"} recipientNote={inlineEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : "No inline recipients are available yet."} - attachments={mapEffectiveAttachmentsToPreviewBoxes(previewAttachments)} + attachments={previewAttachments} navigation={{ index: Math.min(previewIndex, previewEntries.length - 1), total: previewEntries.length, @@ -320,31 +360,44 @@ function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceho ); } -function mapEffectiveAttachmentsToPreviewBoxes(attachments: EffectiveAttachmentPreview[]): MessagePreviewAttachment[] { - return attachments.flatMap((attachment) => { +function mapResolvedAttachmentsToPreviewBoxes( + rules: CampaignAttachmentPreviewRule[], + loading: boolean, + error: string +): MessagePreviewAttachment[] { + if (loading) { + return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }]; + } + if (error) { + return [{ filename: "Attachment preview unavailable", detail: error }]; + } + return rules.flatMap((rule) => { const detailParts = [ - attachment.scope === "global" ? "Global" : "Recipient", - attachment.label, - attachment.required ? "required" : "optional", - attachment.includeSubdirs ? "subdirs" : "" + rule.source === "global" ? "Global" : "Recipient", + rule.label, + rule.required ? "required" : "optional", + rule.pattern ].filter(Boolean); const detail = detailParts.join(" · "); - if (attachment.matches.length > 0) { - return attachment.matches.map((match) => ({ - filename: match, - label: attachment.label, - detail + if (rule.matches.length > 0) { + return rule.matches.map((match) => ({ + filename: match.filename || match.display_path, + label: rule.label, + detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`, + contentType: match.content_type, + sizeBytes: match.size_bytes })); } return [{ - filename: attachment.fileFilter || "No matched file", - label: attachment.label, - detail: `${detail}${detail ? " · " : ""}${attachment.basePathName || attachment.basePath || "attachment source"}` + filename: `No file matched ${rule.pattern || "attachment pattern"}`, + label: rule.label, + detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}` }]; }); } + function recipientLabel(entry: Record, index: number): string { const name = valueToPreview(entry.name).trim(); const email = valueToPreview(entry.email).trim(); diff --git a/src/features/campaigns/components/AttachmentRulesOverlay.tsx b/src/features/campaigns/components/AttachmentRulesOverlay.tsx index 01f0902..dc87547 100644 --- a/src/features/campaigns/components/AttachmentRulesOverlay.tsx +++ b/src/features/campaigns/components/AttachmentRulesOverlay.tsx @@ -61,7 +61,7 @@ export default function AttachmentRulesOverlay({ } function addOverlayRule() { - onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules))]); + onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules), basePaths[0]?.id ?? "")]); } const dialog = open ? createPortal( @@ -110,7 +110,7 @@ export function AttachmentRulesTable({ function addRule() { onChange([ ...tableProps.rules, - createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules)) + createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "") ]); } @@ -158,7 +158,8 @@ export function AttachmentRulesDataGrid({ } const rule = rules[ruleIndex] ?? {}; const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? ""); - const basePath = basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null; + const currentBasePathId = getText(rule, "base_path_id"); + const basePath = basePaths.find((item) => item.id === currentBasePathId) ?? basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null; setFileChooser({ ruleIndex, basePath }); } @@ -166,6 +167,7 @@ export function AttachmentRulesDataGrid({ if (!fileChooser) return; const currentRule = rules[fileChooser.ruleIndex] ?? {}; patchRule(fileChooser.ruleIndex, { + base_path_id: fileChooser.basePath?.id ?? "", base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."), file_filter: selection.fileFilter, type: selection.selectionType === "file" ? "direct" : "pattern", @@ -223,13 +225,22 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a sortable: true, filterable: true, render: (rule, index) => { - const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? ""); + const currentBasePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ""); + const currentBasePathId = getText(rule, "base_path_id"); + const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ?? basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? basePaths[0]; return basePaths.length > 0 ? ( - { + const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value); + if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path }); + }} + > + {basePaths.map((basePath) => )} ) : ( - + ); }, value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "") diff --git a/src/features/campaigns/utils/attachments.ts b/src/features/campaigns/utils/attachments.ts index ca9a589..cbde960 100644 --- a/src/features/campaigns/utils/attachments.ts +++ b/src/features/campaigns/utils/attachments.ts @@ -1,7 +1,6 @@ import type { FileSpace } from "../../../api/files"; import { asArray, asRecord, isRecord } from "./campaignView"; import { getBool, getText } from "./draftEditor"; -import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders"; export type AttachmentRule = Record; @@ -38,17 +37,6 @@ export type AttachmentSummary = { rules: number; }; -export type EffectiveAttachmentPreview = { - scope: "global" | "recipient"; - label: string; - basePathName: string; - basePath: string; - fileFilter: string; - required: boolean; - includeSubdirs: boolean; - matches: string[]; -}; - export type MockAttachmentPathOption = Partial & { label: string; }; @@ -61,13 +49,7 @@ export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [ { label: "Personal upload area", path: "user/uploads" } ]; -export const mockAttachmentFiles = [ - "welcome.pdf", - "terms-and-conditions.pdf", - "invoice_{{local:invoice_number}}.pdf", - "{{local:recipient_id}}/certificate.pdf", - "attachments/{{local:email}}/*.pdf" -]; + export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath { return { @@ -79,10 +61,11 @@ export function createAttachmentBasePath(name = "New attachment source", path = }; } -export function createAttachmentRule(baseDir = "", label = ""): AttachmentRule { +export function createAttachmentRule(baseDir = "", label = "", basePathId = ""): AttachmentRule { return { id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`, label, + base_path_id: basePathId || undefined, base_dir: baseDir, file_filter: "", required: true, @@ -136,6 +119,7 @@ export function normalizeAttachmentRules(value: unknown): AttachmentRule[] { return value.filter(isRecord).map((rule) => ({ id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`), label: getText(rule, "label"), + base_path_id: getText(rule, "base_path_id"), base_dir: getText(rule, "base_dir", ""), file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"), include_subdirs: getBool(rule, "include_subdirs"), @@ -173,67 +157,3 @@ export function isDirectAttachmentRule(rule: AttachmentRule): boolean { if (!fileFilter) return false; return !/[{}*?\[\]]/.test(fileFilter); } - - -export function buildEffectiveAttachmentPreviews(draft: Record | null, entry: Record): EffectiveAttachmentPreview[] { - const attachments = asRecord(draft?.attachments); - const basePaths = normalizeAttachmentBasePaths(attachments.base_paths, attachments); - const context = buildTemplatePreviewContext(draft, entry); - const includeGlobals = getBool(entry, "combine_attachments", true); - const globalRules = includeGlobals ? normalizeAttachmentRules(attachments.global) : []; - const entryRules = normalizeAttachmentRules(entry.attachments); - const individualPaths = basePaths.filter((basePath) => basePath.allow_individual); - const legacyIndividualAllowed = getBool(attachments, "allow_individual", individualPaths.length === 0); - - const items: EffectiveAttachmentPreview[] = []; - for (const rule of globalRules) { - items.push(ruleToAttachmentPreview("global", rule, basePaths, context)); - } - for (const rule of entryRules) { - const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? "."); - const allowed = individualPaths.length > 0 - ? individualPaths.some((basePath) => basePath.path === basePathValue) - : legacyIndividualAllowed; - if (allowed) items.push(ruleToAttachmentPreview("recipient", rule, basePaths, context)); - } - return items; -} - -function ruleToAttachmentPreview( - scope: EffectiveAttachmentPreview["scope"], - rule: AttachmentRule, - basePaths: AttachmentBasePath[], - context: Record -): EffectiveAttachmentPreview { - const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? "."); - const basePath = basePaths.find((item) => item.path === basePathValue); - const fileFilterTemplate = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"); - const fileFilter = renderTemplatePreviewText(fileFilterTemplate, context, true); - return { - scope, - label: getText(rule, "label") || (scope === "global" ? "Global attachment" : "Recipient attachment"), - basePathName: basePath?.name || basePathValue || "Campaign files", - basePath: basePath?.path || basePathValue || ".", - fileFilter, - required: getBool(rule, "required", true), - includeSubdirs: getBool(rule, "include_subdirs"), - matches: previewFileMatches(fileFilter) - }; -} - -function previewFileMatches(fileFilter: string): string[] { - const value = fileFilter.trim(); - if (!value) return []; - if (!/[{}*?\[\]]/.test(value)) { - return mockAttachmentFiles.filter((file) => file === value || file.endsWith(`/${value}`)); - } - const pattern = globLikePatternToRegExp(value); - return mockAttachmentFiles.filter((file) => pattern.test(file)); -} - -function globLikePatternToRegExp(value: string): RegExp { - const escaped = value.replace(/[.+^${}()|\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - return new RegExp(`^${escaped}$`); -}