initial commit after split
This commit is contained in:
273
webui/src/features/campaigns/utils/attachments.ts
Normal file
273
webui/src/features/campaigns/utils/attachments.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { FileSpace } from "../../../api/files";
|
||||
import { asArray, asRecord, isRecord } from "./campaignView";
|
||||
import { getBool, getText } from "./draftEditor";
|
||||
|
||||
export type AttachmentRule = Record<string, unknown>;
|
||||
|
||||
export type AttachmentZipPasswordScope = "local" | "global";
|
||||
|
||||
export type AttachmentZipArchive = {
|
||||
id: string;
|
||||
name: string;
|
||||
standard: boolean;
|
||||
password_enabled: boolean;
|
||||
password_field: string;
|
||||
password_scope: AttachmentZipPasswordScope;
|
||||
method: "aes" | "zip_standard";
|
||||
// Read-only compatibility values retained when normalizing older campaigns.
|
||||
password_mode?: "none" | "direct" | "field" | "template";
|
||||
password?: string;
|
||||
password_template?: string;
|
||||
};
|
||||
|
||||
export type AttachmentZipCollection = {
|
||||
enabled: boolean;
|
||||
archives: AttachmentZipArchive[];
|
||||
};
|
||||
|
||||
export function createAttachmentZipArchive(name = "attachments.zip", standard = false): AttachmentZipArchive {
|
||||
return {
|
||||
id: `zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name,
|
||||
standard,
|
||||
password_enabled: false,
|
||||
password_field: "",
|
||||
password_scope: "local",
|
||||
method: "aes"
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipCollection {
|
||||
const zip = asRecord(value);
|
||||
const rawArchives = asArray(zip.archives).filter(isRecord);
|
||||
if (rawArchives.length > 0 || Object.prototype.hasOwnProperty.call(zip, "archives")) {
|
||||
return {
|
||||
enabled: getBool(zip, "enabled"),
|
||||
archives: rawArchives.map((archive, index) => {
|
||||
const legacyMode = getText(archive, "password_mode") as AttachmentZipArchive["password_mode"];
|
||||
return {
|
||||
id: getText(archive, "id", `zip-${index + 1}`),
|
||||
name: getText(archive, "name", getText(archive, "filename_template", "attachments.zip")),
|
||||
standard: getBool(archive, "standard"),
|
||||
password_enabled: getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode || "")),
|
||||
password_field: getText(archive, "password_field"),
|
||||
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
|
||||
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
|
||||
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// Upgrade the first, single-archive implementation in memory.
|
||||
const legacyEnabled = getBool(zip, "enabled");
|
||||
const legacyName = getText(zip, "filename_template");
|
||||
const legacyMode = getText(zip, "password_mode", getText(zip, "password_template") ? "template" : "none") as AttachmentZipArchive["password_mode"];
|
||||
const meaningful = legacyEnabled || Boolean(legacyName || getText(zip, "password") || getText(zip, "password_field") || getText(zip, "password_template"));
|
||||
if (!meaningful) return { enabled: false, archives: [] };
|
||||
return {
|
||||
enabled: legacyEnabled,
|
||||
archives: [{
|
||||
id: "default",
|
||||
name: legacyName || "attachments.zip",
|
||||
standard: true,
|
||||
password_enabled: ["direct", "field", "template"].includes(legacyMode || ""),
|
||||
password_field: getText(zip, "password_field"),
|
||||
password_scope: "local",
|
||||
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
|
||||
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
|
||||
const zip = asRecord(rule.zip);
|
||||
const archiveId = getText(zip, "archive_id");
|
||||
if (archiveId) return archiveId;
|
||||
const legacyMode = getText(zip, "mode");
|
||||
if (legacyMode === "exclude") return "exclude";
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
export type ManagedAttachmentSource = {
|
||||
ownerType: "user" | "group";
|
||||
ownerId: string;
|
||||
};
|
||||
|
||||
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
|
||||
|
||||
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
|
||||
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
|
||||
}
|
||||
|
||||
export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentSource | null {
|
||||
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
|
||||
const [, ownerType, ...ownerIdParts] = value.split(":");
|
||||
const ownerId = ownerIdParts.join(":").trim();
|
||||
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
|
||||
return { ownerType, ownerId };
|
||||
}
|
||||
|
||||
export type AttachmentBasePath = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
source?: string;
|
||||
allow_individual?: boolean;
|
||||
unsent_warning?: boolean;
|
||||
};
|
||||
|
||||
export type AttachmentSummary = {
|
||||
direct: number;
|
||||
rules: number;
|
||||
};
|
||||
|
||||
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
|
||||
{ label: "Campaign attachments", path: "attachments" },
|
||||
{ label: "Campaign root", path: "." },
|
||||
{ label: "Shared group files", path: "group/shared" },
|
||||
{ label: "Tenant templates", path: "tenant/templates" },
|
||||
{ label: "Personal upload area", path: "user/uploads" }
|
||||
];
|
||||
|
||||
|
||||
|
||||
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
|
||||
return {
|
||||
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name,
|
||||
path,
|
||||
allow_individual: false,
|
||||
unsent_warning: false
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
include_subdirs: false,
|
||||
zip: { archive_id: "inherit" }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>, fallbackAllowIndividual = false): AttachmentBasePath[] {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value.filter(isRecord).map((basePath, index) => ({
|
||||
id: getText(basePath, "id", `base-path-${index + 1}`),
|
||||
name: getText(basePath, "name", `Base path ${index + 1}`),
|
||||
source: getText(basePath, "source"),
|
||||
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
||||
allow_individual: getBool(basePath, "allow_individual"),
|
||||
unsent_warning: getBool(basePath, "unsent_warning")
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
id: "base-path-campaign",
|
||||
name: "Campaign files",
|
||||
path: getText(attachments, "base_path", "."),
|
||||
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
|
||||
unsent_warning: false
|
||||
}];
|
||||
}
|
||||
|
||||
export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
||||
return paths.length > 0 ? paths : [createAttachmentBasePath("Campaign files", ".")];
|
||||
}
|
||||
|
||||
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
||||
return paths.filter((basePath) => basePath.allow_individual);
|
||||
}
|
||||
|
||||
export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: AttachmentBasePath): boolean {
|
||||
const basePathId = getText(rule, "base_path_id");
|
||||
if (basePathId) return basePathId === basePath.id;
|
||||
return getText(rule, "base_dir") === basePath.path;
|
||||
}
|
||||
|
||||
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
|
||||
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
|
||||
.length;
|
||||
}
|
||||
|
||||
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
|
||||
const entries = asRecord(entriesValue);
|
||||
const inline = asArray(entries.inline).map(asRecord).map((entry) => ({
|
||||
...entry,
|
||||
attachments: normalizeAttachmentRules(entry.attachments).filter((rule) => !attachmentRuleUsesBasePath(rule, basePath))
|
||||
}));
|
||||
return { ...entries, inline };
|
||||
}
|
||||
|
||||
|
||||
export function nextAttachmentLabel(rules: AttachmentRule[]): string {
|
||||
let highest = 0;
|
||||
for (const rule of rules) {
|
||||
const match = /^Attachment\s+(\d+)$/i.exec(getText(rule, "label").trim());
|
||||
if (match) highest = Math.max(highest, Number(match[1]));
|
||||
}
|
||||
highest = Math.max(highest, rules.length);
|
||||
return `Attachment ${highest + 1}`;
|
||||
}
|
||||
|
||||
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
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"),
|
||||
required: getBool(rule, "required", true),
|
||||
missing_behavior: getText(rule, "missing_behavior", "ask"),
|
||||
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
|
||||
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
|
||||
}));
|
||||
}
|
||||
|
||||
export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSummary {
|
||||
return rules.reduce<AttachmentSummary>((summary, rule) => {
|
||||
if (isDirectAttachmentRule(rule)) {
|
||||
summary.direct += 1;
|
||||
} else {
|
||||
summary.rules += 1;
|
||||
}
|
||||
return summary;
|
||||
}, { direct: 0, rules: 0 });
|
||||
}
|
||||
|
||||
export function countIndividualAttachmentRules(entriesValue: unknown): number {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => asArray(entry.attachments))
|
||||
.length;
|
||||
}
|
||||
|
||||
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
const explicitType = getText(rule, "type");
|
||||
if (explicitType === "direct") return true;
|
||||
if (explicitType === "pattern") return false;
|
||||
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
|
||||
if (!fileFilter) return false;
|
||||
return !/[{}*?\[\]]/.test(fileFilter);
|
||||
}
|
||||
232
webui/src/features/campaigns/utils/campaignView.ts
Normal file
232
webui/src/features/campaigns/utils/campaignView.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { CampaignListItem } from "../../../types";
|
||||
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
|
||||
export type CampaignWorkspaceData = {
|
||||
campaign: CampaignListItem | null;
|
||||
versions: CampaignVersionListItem[];
|
||||
currentVersion: CampaignVersionDetail | null;
|
||||
summary: CampaignSummary | null;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return version?.raw_json ?? version?.campaign_json ?? {};
|
||||
}
|
||||
|
||||
export function getCampaignSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).campaign);
|
||||
}
|
||||
|
||||
export function getRecipientsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).recipients);
|
||||
}
|
||||
|
||||
export function getTemplateSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).template);
|
||||
}
|
||||
|
||||
export function getDeliverySection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).delivery);
|
||||
}
|
||||
|
||||
export function getEntriesSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).entries);
|
||||
}
|
||||
|
||||
export function getAttachmentsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).attachments);
|
||||
}
|
||||
|
||||
export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
||||
return asArray(getCampaignJson(version).fields);
|
||||
}
|
||||
|
||||
export function userLockState(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null
|
||||
): "temporary" | "permanent" | null {
|
||||
if (!version) return null;
|
||||
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
|
||||
return version.user_lock_state;
|
||||
}
|
||||
// Backwards compatibility for snapshots created before explicit user locks.
|
||||
return version.published_at ? "permanent" : null;
|
||||
}
|
||||
|
||||
export function isTemporaryUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
return userLockState(version) === "temporary";
|
||||
}
|
||||
|
||||
export function isPermanentUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
return userLockState(version) === "permanent";
|
||||
}
|
||||
|
||||
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
return userLockState(version) !== null;
|
||||
}
|
||||
|
||||
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
return [
|
||||
"queued",
|
||||
"sending",
|
||||
"sent",
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"failed_partial",
|
||||
"partially_sent",
|
||||
"archived",
|
||||
"cancelled"
|
||||
].includes((version.workflow_state ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version?.locked_at || isUserLockedVersion(version)) return false;
|
||||
const validation = asRecord(version.validation_summary);
|
||||
return validation.ok === true;
|
||||
}
|
||||
|
||||
export function canUnlockValidationVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
if (isUserLockedVersion(version)) return false;
|
||||
if (!isVersionReadyForDelivery(version)) return false;
|
||||
if (isFinalLockedVersion(version)) return false;
|
||||
return ["approved", "built"].includes(version.workflow_state ?? "");
|
||||
}
|
||||
|
||||
export function isHistoricalCampaignVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
return Boolean(version && currentVersionId && version.id !== currentVersionId);
|
||||
}
|
||||
|
||||
export function isAuditLockedVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
if (!version) return false;
|
||||
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
|
||||
if (version.locked_at || isUserLockedVersion(version)) return true;
|
||||
return isFinalLockedVersion(version);
|
||||
}
|
||||
|
||||
export function versionLockReason(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): string {
|
||||
if (!version) return "No campaign version is loaded.";
|
||||
if (isHistoricalCampaignVersion(version, currentVersionId)) {
|
||||
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
|
||||
}
|
||||
if (isTemporaryUserLockedVersion(version)) {
|
||||
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
|
||||
}
|
||||
if (isPermanentUserLockedVersion(version)) {
|
||||
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
|
||||
}
|
||||
if (canUnlockValidationVersion(version)) {
|
||||
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
|
||||
}
|
||||
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
|
||||
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
|
||||
return "Editable working version.";
|
||||
}
|
||||
|
||||
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "—";
|
||||
const flow = version.current_flow || "manual";
|
||||
const step = version.current_step || "not set";
|
||||
return `${humanize(flow)} / ${humanize(step)}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
export function humanize(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
export function summaryValue(summary: Record<string, unknown> | null | undefined, keys: string[]): string | number {
|
||||
if (!summary) return "—";
|
||||
for (const key of keys) {
|
||||
const value = summary[key];
|
||||
if (typeof value === "number" || typeof value === "string") return value;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function getString(record: Record<string, unknown>, key: string, fallback = "—"): string {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
|
||||
let current: unknown = record;
|
||||
for (const part of path) {
|
||||
if (!isRecord(current)) return fallback;
|
||||
current = current[part];
|
||||
}
|
||||
if (typeof current === "string" && current.trim()) return current;
|
||||
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
||||
if (Array.isArray(current)) return current.length ? current.join(", ") : fallback;
|
||||
if (isRecord(current)) return stringifyPreview(current, 120);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function stringifyPreview(value: unknown, maxLength = 220): string {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
}
|
||||
|
||||
export function cloneCampaignJsonForCopy(
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string
|
||||
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
|
||||
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
|
||||
const baseName = String(campaignSection.name || campaign?.name || "Campaign");
|
||||
const description = String(campaignSection.description || campaign?.description || "");
|
||||
const externalId = `${baseId}-copy-${stamp}`.replace(/[^a-zA-Z0-9_.-]+/g, "-").toLowerCase();
|
||||
const name = `${baseName} (copy)`;
|
||||
|
||||
rawJson.campaign = {
|
||||
...campaignSection,
|
||||
id: externalId,
|
||||
name,
|
||||
description
|
||||
};
|
||||
|
||||
return { externalId, name, description, rawJson };
|
||||
}
|
||||
|
||||
export function timestampSlug(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||||
}
|
||||
110
webui/src/features/campaigns/utils/draftEditor.ts
Normal file
110
webui/src/features/campaigns/utils/draftEditor.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
|
||||
|
||||
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
export function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value ?? {})) as T;
|
||||
}
|
||||
|
||||
export function ensureCampaignDraft(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
const raw = cloneJson(getCampaignJson(version));
|
||||
raw.version = typeof raw.version === "string" ? raw.version : "1";
|
||||
raw.campaign = {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
mode: "draft",
|
||||
...asRecord(raw.campaign)
|
||||
};
|
||||
raw.fields = Array.isArray(raw.fields) ? raw.fields : [];
|
||||
raw.global_values = isRecord(raw.global_values) ? raw.global_values : {};
|
||||
raw.server = isRecord(raw.server) ? raw.server : {};
|
||||
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
|
||||
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
|
||||
raw.attachments = {
|
||||
base_path: ".",
|
||||
allow_individual: false,
|
||||
send_without_attachments: true,
|
||||
global: [],
|
||||
missing_behavior: "ask",
|
||||
ambiguous_behavior: "ask",
|
||||
...asRecord(raw.attachments)
|
||||
};
|
||||
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
|
||||
raw.validation_policy = {
|
||||
unsent_attachment_files: "warn",
|
||||
...asRecord(raw.validation_policy)
|
||||
};
|
||||
raw.delivery = isRecord(raw.delivery) ? raw.delivery : {};
|
||||
raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true };
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function updateNested(
|
||||
draft: Record<string, unknown>,
|
||||
path: string[],
|
||||
value: unknown
|
||||
): Record<string, unknown> {
|
||||
const next = cloneJson(draft);
|
||||
let current: Record<string, unknown> = next;
|
||||
path.forEach((segment, index) => {
|
||||
if (index === path.length - 1) {
|
||||
current[segment] = value;
|
||||
return;
|
||||
}
|
||||
const existing = current[segment];
|
||||
if (!isRecord(existing)) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseJsonTextarea<T>(text: string, fallback: T): { value: T; error: string } {
|
||||
if (!text.trim()) return { value: fallback, error: "" };
|
||||
try {
|
||||
return { value: JSON.parse(text) as T, error: "" };
|
||||
} catch (error) {
|
||||
return { value: fallback, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
export function getBool(record: Record<string, unknown>, key: string, fallback = false): boolean {
|
||||
const value = record[key];
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function getNumber(record: Record<string, unknown>, key: string, fallback = 0): number {
|
||||
const value = record[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
export function getText(record: Record<string, unknown>, key: string, fallback = ""): string {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function downloadJson(filename: string, data: Record<string, unknown>) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function safeFileStem(value?: string | null): string {
|
||||
const stem = (value || "campaign").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return stem || "campaign";
|
||||
}
|
||||
56
webui/src/features/campaigns/utils/fieldDefinitions.ts
Normal file
56
webui/src/features/campaigns/utils/fieldDefinitions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { asArray, asRecord } from "./campaignView";
|
||||
import { getBool, getText } from "./draftEditor";
|
||||
|
||||
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
|
||||
export type CampaignFieldType = typeof fieldTypeOptions[number];
|
||||
|
||||
export type CampaignFieldDefinition = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: CampaignFieldType;
|
||||
required: boolean;
|
||||
can_override: boolean;
|
||||
};
|
||||
|
||||
export function normalizeFieldType(value: string | undefined | null): CampaignFieldType {
|
||||
const normalized = String(value || "string").trim();
|
||||
return fieldTypeOptions.includes(normalized as CampaignFieldType) ? normalized as CampaignFieldType : "string";
|
||||
}
|
||||
|
||||
export function getDraftFields(draft: Record<string, unknown> | null | undefined): CampaignFieldDefinition[] {
|
||||
return asArray(draft?.fields)
|
||||
.map(asRecord)
|
||||
.map((field) => ({
|
||||
name: getText(field, "name") || getText(field, "id"),
|
||||
label: getText(field, "label"),
|
||||
type: normalizeFieldType(getText(field, "type", "string")),
|
||||
required: getBool(field, "required"),
|
||||
can_override: getBool(field, "can_override", true)
|
||||
}))
|
||||
.filter((field) => Boolean(field.name));
|
||||
}
|
||||
|
||||
export function humanizeFieldName(value: string): string {
|
||||
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
export function valueToInputText(value: unknown, fieldType: CampaignFieldType): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function inputValueToFieldValue(fieldType: CampaignFieldType, value: string): unknown {
|
||||
if (value === "") return "";
|
||||
if (fieldType === "integer") {
|
||||
const numberValue = Number.parseInt(value, 10);
|
||||
return Number.isFinite(numberValue) ? numberValue : value;
|
||||
}
|
||||
if (fieldType === "double") {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
256
webui/src/features/campaigns/utils/templatePlaceholders.ts
Normal file
256
webui/src/features/campaigns/utils/templatePlaceholders.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { asArray, asRecord } from "./campaignView";
|
||||
import { getBool } from "./draftEditor";
|
||||
import { addressesFromValue, type MailboxAddress } from "../../../utils/emailAddresses";
|
||||
|
||||
export type TemplateNamespace = "global" | "local";
|
||||
|
||||
export const RECIPIENT_ADDRESS_FIELD_IDS = ["from", "to", "reply_to", "cc", "bcc"] as const;
|
||||
|
||||
export function recipientAddressTemplateFieldOptions(): Array<{ name: string; label: string }> {
|
||||
return RECIPIENT_ADDRESS_FIELD_IDS.flatMap((name) => [
|
||||
{ name, label: name },
|
||||
{ name: `all_${name}`, label: `all_${name}` }
|
||||
]);
|
||||
}
|
||||
|
||||
export function isRecipientAddressPlaceholderName(name: string): boolean {
|
||||
const field = "(?:from|to|reply_to|cc|bcc)";
|
||||
return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name)
|
||||
|| new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name)
|
||||
|| new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name);
|
||||
}
|
||||
|
||||
export type TemplatePlaceholder = {
|
||||
raw: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
validNamespace: boolean;
|
||||
display: string;
|
||||
};
|
||||
|
||||
export type UndefinedPlaceholder = TemplatePlaceholder & {
|
||||
reason: "missing-field" | "invalid-namespace";
|
||||
};
|
||||
|
||||
export function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
|
||||
const placeholders = new Map<string, TemplatePlaceholder>();
|
||||
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
|
||||
for (const pattern of patterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text))) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || placeholders.has(raw)) continue;
|
||||
placeholders.set(raw, parseTemplatePlaceholder(raw));
|
||||
}
|
||||
}
|
||||
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
|
||||
}
|
||||
|
||||
export function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
|
||||
const cleaned = normalizeTemplatePlaceholderKey(raw);
|
||||
const separator = cleaned.indexOf(":");
|
||||
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
|
||||
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
|
||||
const validNamespace = namespace === "global" || namespace === "local";
|
||||
return {
|
||||
raw,
|
||||
namespace,
|
||||
name,
|
||||
validNamespace,
|
||||
display: validNamespace ? `${namespace}:${name}` : raw
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTemplatePlaceholderKey(raw: string): string {
|
||||
return raw.trim()
|
||||
.replace(/^fields\./, "local:")
|
||||
.replace(/^local\./, "local:")
|
||||
.replace(/^global\./, "global:")
|
||||
.replace(/^local::/, "local:")
|
||||
.replace(/^global::/, "global:");
|
||||
}
|
||||
|
||||
export function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
for (const item of items) {
|
||||
const key = item.raw;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildUndefinedPlaceholders(
|
||||
placeholders: TemplatePlaceholder[],
|
||||
availableNames: Set<string>,
|
||||
availableByNamespace?: Partial<Record<TemplateNamespace, Set<string>>>
|
||||
): UndefinedPlaceholder[] {
|
||||
return uniquePlaceholders(placeholders
|
||||
.filter((field) => {
|
||||
if (!field.validNamespace) return true;
|
||||
const namesForNamespace = availableByNamespace?.[field.namespace as TemplateNamespace];
|
||||
if ((namesForNamespace ?? availableNames).has(field.name)) return false;
|
||||
return !(field.namespace === "local" && isRecipientAddressPlaceholderName(field.name));
|
||||
})
|
||||
.map((field): UndefinedPlaceholder => ({
|
||||
...field,
|
||||
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
||||
})));
|
||||
}
|
||||
|
||||
export function buildTemplatePreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
|
||||
const context: Record<string, string> = {};
|
||||
const globalValues = asRecord(draft?.global_values);
|
||||
const entryFields = asRecord(entry.fields);
|
||||
const overridePolicy = fieldOverridePolicy(draft);
|
||||
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
const name = String(field.name || field.id || "").trim();
|
||||
if (!name) continue;
|
||||
addPreviewContextValue(context, name, "global", "");
|
||||
addPreviewContextValue(context, name, "local", "");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(globalValues)) {
|
||||
addPreviewContextValue(context, key, "global", value);
|
||||
addPreviewContextValue(context, key, "local", value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(entryFields)) {
|
||||
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
|
||||
addPreviewContextValue(context, key, "local", value);
|
||||
}
|
||||
}
|
||||
if (entry.name) addPreviewContextValue(context, "name", "local", entry.name);
|
||||
if (entry.email) addPreviewContextValue(context, "email", "local", entry.email);
|
||||
addRecipientAddressPreviewValues(context, effectivePreviewAddresses(draft, entry));
|
||||
return context;
|
||||
}
|
||||
|
||||
function effectivePreviewAddresses(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, MailboxAddress[]> {
|
||||
const recipients = asRecord(draft?.recipients);
|
||||
const defaults = asRecord(asRecord(draft?.entries).defaults);
|
||||
const result: Record<string, MailboxAddress[]> = {};
|
||||
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
|
||||
const globalAddresses = addressesFromValue(recipients[field]);
|
||||
const localAddresses = addressesFromValue(entry[field]);
|
||||
const allowIndividual = getBool(recipients, `allow_individual_${field}`, field === "to");
|
||||
const defaultMerge = field !== "from";
|
||||
const merge = mergeFlag(entry, defaults, field, defaultMerge);
|
||||
if (!allowIndividual) {
|
||||
result[field] = dedupeAddressOrder(globalAddresses);
|
||||
} else if (localAddresses.length === 0) {
|
||||
result[field] = dedupeAddressOrder(globalAddresses);
|
||||
} else {
|
||||
result[field] = dedupeAddressOrder(merge ? [...globalAddresses, ...localAddresses] : localAddresses);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dedupeAddressOrder(addresses: MailboxAddress[]): MailboxAddress[] {
|
||||
const seen = new Set<string>();
|
||||
return addresses.filter((address) => {
|
||||
const key = address.email.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeFlag(entry: Record<string, unknown>, defaults: Record<string, unknown>, field: string, fallback: boolean): boolean {
|
||||
const mergeKey = `merge_${field}`;
|
||||
const legacyKey = `combine_${field}`;
|
||||
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
|
||||
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
|
||||
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
|
||||
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function addRecipientAddressPreviewValues(context: Record<string, string>, addresses: Record<string, MailboxAddress[]>) {
|
||||
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
|
||||
const values = addresses[field] ?? [];
|
||||
const formatted = values.map(formatPreviewAddress);
|
||||
const emails = values.map((address) => address.email);
|
||||
const names = values.map((address) => address.name ?? "");
|
||||
addPreviewContextValue(context, field, "local", formatted[0] ?? "");
|
||||
addPreviewContextValue(context, `${field}.email`, "local", emails[0] ?? "");
|
||||
addPreviewContextValue(context, `${field}.name`, "local", names[0] ?? "");
|
||||
addPreviewContextValue(context, `all_${field}`, "local", formatted.join(", "));
|
||||
addPreviewContextValue(context, `all_${field}.email`, "local", emails.join(", "));
|
||||
addPreviewContextValue(context, `all_${field}.name`, "local", names.filter(Boolean).join(", "));
|
||||
values.forEach((address, index) => {
|
||||
const oneBased = index + 1;
|
||||
addPreviewContextValue(context, `${field}[${oneBased}]`, "local", formatted[index]);
|
||||
addPreviewContextValue(context, `${field}[${oneBased}].email`, "local", address.email);
|
||||
addPreviewContextValue(context, `${field}[${oneBased}].name`, "local", address.name ?? "");
|
||||
addPreviewContextValue(context, `${field}.${index}.email`, "local", address.email);
|
||||
addPreviewContextValue(context, `${field}.${index}.name`, "local", address.name ?? "");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatPreviewAddress(address: MailboxAddress): string {
|
||||
return address.name ? `${address.name} <${address.email}>` : address.email;
|
||||
}
|
||||
|
||||
export function renderTemplatePreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
|
||||
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
|
||||
}
|
||||
|
||||
export function valueToPreview(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function removePlaceholderFromText(text: string, raw: string): string {
|
||||
if (!text) return text;
|
||||
const escaped = escapeRegExp(raw.trim());
|
||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
||||
}
|
||||
|
||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||
const policy = new Map<string, boolean>();
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
const name = String(field.name || field.id || "").trim();
|
||||
if (!name) continue;
|
||||
policy.set(name, getBool(field, "can_override", true));
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
|
||||
if (!policy.has(name)) return true;
|
||||
return policy.get(name) !== false;
|
||||
}
|
||||
|
||||
function addPreviewContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
|
||||
const text = valueToPreview(value);
|
||||
context[key] = text;
|
||||
context[`${namespace}:${key}`] = text;
|
||||
context[`${namespace}::${key}`] = text;
|
||||
}
|
||||
|
||||
function hasPreviewOverrideValue(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
const key = normalizeTemplatePlaceholderKey(raw);
|
||||
const value = context[key];
|
||||
if (value !== undefined) return value;
|
||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user