export const MAX_CAMPAIGN_STEPS = 20; export const MAX_WAIT_HOURS = 2160; export const DEFAULT_CONNECTION_WAIT_HOURS = 720; export const MAX_LINKEDIN_MESSAGE_CHARS = 3000; export const MAX_CONNECTION_NOTE_CHARS = 300; export const MAX_INMAIL_SUBJECT_CHARS = 200; export const MAX_INMAIL_BODY_CHARS = 1900; export const MAX_LIKE_POST_AGE_DAYS = 365; export const MAX_LIKE_POST_COUNT = 3; export const MIN_WITHDRAW_AFTER_HOURS = 7 * 24; export const MIN_ACCEPTANCE_WAIT_HOURS = 24; export const MAX_ACCEPTANCE_WAIT_HOURS = 90 * 24; export const LINKEDIN_REACTIONS = [ "like", "celebrate", "support", "love", "insightful", "funny", ] as const; export const STEP_TYPES = [ "wait", "email", "linkedin_connection", "linkedin_message", "linkedin_visit", "linkedin_like_post", "linkedin_inmail", "condition", "wait_for_connection", ] as const; export type StepType = (typeof STEP_TYPES)[number]; /** Workflow steps that send nothing and continue on a Yes or a No path. */ export const BRANCH_STEP_TYPES = ["condition", "wait_for_connection"] as const; export const WORKFLOW_END = "end"; export const CONDITION_CHECKS = [ "linkedin_connected", "has_email", "opened_last_email", "field_value", ] as const; export const CONDITION_OPERATORS = [ "is", "is_not", "is_empty", "is_not_empty", "contains", ] as const; export function isBranchStep(type: unknown): boolean { return (BRANCH_STEP_TYPES as readonly unknown[]).includes(type); } export const STEP_KEYS = [ "type", "waitHours", "emailMailboxAccountId", "emailSubject", "emailHtmlBody", "emailTextBody", "emailReplyToPrevious", "onlyIf", "linkedinAccountId", "linkedinMessageBody", "linkedinMessageMode", "connectionTimeoutHours", "likeReaction", "likeMaxPostAgeDays", "likeSkipPreviousWait", "likeSkipNextWait", "likeCount", "inmailSubject", "withdrawAfterHours", "key", "next", "yesNext", "noNext", "check", "conditionField", "conditionOperator", "conditionValue", ] as const; /** A workflow step's id and where each of its exits leads. */ export const PATH_KEYS = ["key", "next", "yesNext", "noNext"] as const; /** Fixed once a campaign launches: its paths and what each If / else checks. */ export const FROZEN_AFTER_LAUNCH = [ ...PATH_KEYS, "check", "conditionField", "conditionOperator", ] as const; /** Steps with an id, a path or an If / else / acceptance wait make a workflow. */ export function hasWorkflowShape(step: unknown): boolean { if (!step || typeof step !== "object") return false; const row = step as Record; return ( PATH_KEYS.some((key) => row[key] !== undefined) || isBranchStep(row.type) ); } const FIELDS_BY_TYPE: Record = { wait: ["waitHours"], email: [ "emailMailboxAccountId", "emailSubject", "emailHtmlBody", "emailTextBody", "emailReplyToPrevious", "onlyIf", ], linkedin_visit: ["linkedinAccountId"], linkedin_connection: [ "linkedinAccountId", "linkedinMessageBody", "withdrawAfterHours", ], linkedin_message: [ "linkedinAccountId", "linkedinMessageBody", "linkedinMessageMode", "connectionTimeoutHours", ], linkedin_like_post: [ "linkedinAccountId", "onlyIf", "likeReaction", "likeMaxPostAgeDays", "likeSkipPreviousWait", "likeSkipNextWait", "likeCount", ], linkedin_inmail: [ "linkedinAccountId", "linkedinMessageBody", "inmailSubject", ], condition: [ "linkedinAccountId", "check", "conditionField", "conditionOperator", "conditionValue", ], wait_for_connection: ["linkedinAccountId", "connectionTimeoutHours"], }; const TEXT_FIELDS = [ "emailMailboxAccountId", "emailSubject", "emailHtmlBody", "emailTextBody", "linkedinAccountId", "linkedinMessageBody", "inmailSubject", "conditionField", "conditionValue", ] as const; const TEMPLATE_FIELDS = [ "emailSubject", "emailHtmlBody", "emailTextBody", "linkedinMessageBody", "inmailSubject", ] as const; const ONLY_IF = ["always", "connected", "not_connected"]; const MESSAGE_MODES = ["skip_if_not_connected", "wait_until_connected"]; export type StepCheckOptions = { allowSingleBraces?: boolean; singleBraceOverride?: string; /** * The steps form a workflow (paths, If / else, Wait for them to accept). * Unset, steps with a key, a path or a logic type make one. */ workflow?: boolean; }; export type StepIssue = { step: number; field?: string; message: string }; export type StepCheck = { errors: StepIssue[]; warnings: string[] }; function holdsNothing(field: string, value: unknown): boolean { if (value === undefined) return true; if (field === "emailReplyToPrevious") return value === false; if (field === "onlyIf") return value === "always"; if (field === "withdrawAfterHours") return value === null; if (typeof value === "string") return !value.trim(); return false; } export function singleBraceTokens(text: string): string[] { const outside = text.replace(/\{\{[^{}]*\}\}|\[\[[^[\]]*\]\]/g, " "); return [ ...new Set( [...outside.matchAll(/\{\s*([A-Za-z_][A-Za-z0-9_ .-]{0,60}?)\s*\}/g)].map( (match) => match[0], ), ), ]; } function trimmedText(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } export function hasHtmlAndTextBody(step: unknown): boolean { if (!step || typeof step !== "object") return false; const row = step as Record; return ( row.type === "email" && trimmedText(row.emailHtmlBody) !== "" && trimmedText(row.emailTextBody) !== "" ); } export function staleHtmlSteps( next: readonly unknown[], stored: readonly unknown[], ): number[] { const storedEmails = stored.filter(hasHtmlAndTextBody) as Array< Record >; return next.flatMap((step, index) => { if (!hasHtmlAndTextBody(step)) return []; const row = step as Record; const html = trimmedText(row.emailHtmlBody); const text = trimmedText(row.emailTextBody); const sameHtml = storedEmails.filter( (candidate) => trimmedText(candidate.emailHtmlBody) === html, ); return sameHtml.length > 0 && sameHtml.every( (candidate) => trimmedText(candidate.emailTextBody) !== text, ) ? [index] : []; }); } export function staleHtmlWarning(index: number): string { return `Step ${index + 1}: "emailTextBody" was edited but "emailHtmlBody" is still the copy from before, and the HTML is what gets sent, so the old HTML was dropped and the email goes out as the new text.`; } function hasEmptyMergeToken(text: string): boolean { return /\{\{\s*\}\}|\[\[\s*\]\]/.test(text); } function listSteps(numbers: number[]): string { if (numbers.length <= 1) return `Step ${numbers[0]}`; return `Steps ${numbers.slice(0, -1).join(", ")} and ${numbers.at(-1)}`; } type Fail = (message: string, field?: string) => void; function checkStepFieldShapes( row: Record, stepType: StepType, label: string, fail: Fail, ): void { for (const field of TEXT_FIELDS) if (row[field] !== undefined && typeof row[field] !== "string") fail(`${label}: "${field}" must be text.`, field); if ( row.emailReplyToPrevious !== undefined && typeof row.emailReplyToPrevious !== "boolean" ) fail(`${label}: "emailReplyToPrevious" must be true or false.`); if ( row.onlyIf !== undefined && (typeof row.onlyIf !== "string" || !ONLY_IF.includes(row.onlyIf)) ) fail( `${label}: "onlyIf" must be always, connected or not_connected.`, "onlyIf", ); if ( row.linkedinMessageMode !== undefined && (typeof row.linkedinMessageMode !== "string" || !MESSAGE_MODES.includes(row.linkedinMessageMode)) ) fail( `${label}: "linkedinMessageMode" must be skip_if_not_connected or wait_until_connected.`, "linkedinMessageMode", ); if (row.waitHours !== undefined && typeof row.waitHours !== "number") fail(`${label}: "waitHours" must be a number of hours.`, "waitHours"); if ( row.likeReaction !== undefined && (typeof row.likeReaction !== "string" || !(LINKEDIN_REACTIONS as readonly string[]).includes(row.likeReaction)) ) fail( `${label}: "likeReaction" must be one of: ${LINKEDIN_REACTIONS.join(", ")}.`, "likeReaction", ); if ( row.likeMaxPostAgeDays !== undefined && (typeof row.likeMaxPostAgeDays !== "number" || !Number.isInteger(row.likeMaxPostAgeDays) || row.likeMaxPostAgeDays < 1 || row.likeMaxPostAgeDays > MAX_LIKE_POST_AGE_DAYS) ) fail( `${label}: "likeMaxPostAgeDays" must be a whole number from 1 to ${MAX_LIKE_POST_AGE_DAYS}.`, "likeMaxPostAgeDays", ); if ( row.likeCount !== undefined && (typeof row.likeCount !== "number" || !Number.isInteger(row.likeCount) || row.likeCount < 1 || row.likeCount > MAX_LIKE_POST_COUNT) ) fail( `${label}: "likeCount" must be a whole number from 1 to ${MAX_LIKE_POST_COUNT}.`, "likeCount", ); if ( row.likeSkipPreviousWait !== undefined && typeof row.likeSkipPreviousWait !== "boolean" ) fail( `${label}: "likeSkipPreviousWait" must be true or false.`, "likeSkipPreviousWait", ); if ( row.likeSkipNextWait !== undefined && typeof row.likeSkipNextWait !== "boolean" ) fail( `${label}: "likeSkipNextWait" must be true or false.`, "likeSkipNextWait", ); if ( row.withdrawAfterHours !== undefined && row.withdrawAfterHours !== null && (typeof row.withdrawAfterHours !== "number" || !Number.isFinite(row.withdrawAfterHours) || row.withdrawAfterHours < MIN_WITHDRAW_AFTER_HOURS || row.withdrawAfterHours > MAX_WAIT_HOURS) ) fail( `${label}: "withdrawAfterHours" must be ${MIN_WITHDRAW_AFTER_HOURS}-${MAX_WAIT_HOURS} hours (7-90 days), or null to turn withdrawal off.`, "withdrawAfterHours", ); checkLogicFieldShapes(row, label, fail); const kind = stepType === "email" ? "an email" : `a ${stepType}`; const allowed = FIELDS_BY_TYPE[stepType]; const dropped = (STEP_KEYS as readonly string[]).filter( (key) => key !== "type" && !(PATH_KEYS as readonly string[]).includes(key) && !allowed.includes(key) && !holdsNothing(key, row[key]), ); if (stepType === "condition") dropped.push(...conditionDrops(row)); for (const field of dropped) { if (stepType === "condition" && allowed.includes(field)) fail(conditionDropMessage(row, field, label), field); else if (field === "onlyIf") fail( `${label}: "onlyIf" only works on email or linkedin_like_post steps; ${kind} step would drop it. InMail already skips connected people.`, field, ); else if (field === "linkedinMessageBody" && stepType === "linkedin_visit") fail( `${label}: ${kind} step sends no text, so "linkedinMessageBody" would be dropped. Use linkedin_connection for an invite note or linkedin_message for a message.`, field, ); else fail( `${label}: "${field}" does not apply to ${kind} step and would be dropped.`, field, ); } } const CHECK_NAMES: Record = { linkedin_connected: "whether they are connected on LinkedIn", has_email: "whether they have an email address", opened_last_email: "whether they opened the last email", }; function checkLogicFieldShapes( row: Record, label: string, fail: Fail, ): void { for (const field of PATH_KEYS) { const value = row[field]; if (value !== undefined && (typeof value !== "string" || !value.trim())) fail( field === "key" ? `${label}: "key" must be a short text id, like "invite".` : `${label}: "${field}" must be another step's "key", or "${WORKFLOW_END}" to stop there.`, field, ); } if ( row.check !== undefined && !(CONDITION_CHECKS as readonly unknown[]).includes(row.check) ) fail( `${label}: "check" must be one of: ${CONDITION_CHECKS.join(", ")}.`, "check", ); if ( row.conditionOperator !== undefined && !(CONDITION_OPERATORS as readonly unknown[]).includes(row.conditionOperator) ) fail( `${label}: "conditionOperator" must be one of: ${CONDITION_OPERATORS.join(", ")}.`, "conditionOperator", ); } function operatorTakesValue(operator: unknown): boolean { return operator !== "is_empty" && operator !== "is_not_empty"; } /** Condition fields the If / else's own check would drop. */ function conditionDrops(row: Record): string[] { const drops: string[] = []; // An edit that leaves "check" out keeps the stored one, unknown here. if (row.check === undefined) return drops; if ( row.check !== "linkedin_connected" && !holdsNothing("linkedinAccountId", row.linkedinAccountId) ) drops.push("linkedinAccountId"); for (const field of ["conditionField", "conditionOperator"] as const) if (row.check !== "field_value" && !holdsNothing(field, row[field])) drops.push(field); if ( !holdsNothing("conditionValue", row.conditionValue) && (row.check !== "field_value" || !operatorTakesValue(row.conditionOperator)) ) drops.push("conditionValue"); return drops; } function conditionDropMessage( row: Record, field: string, label: string, ): string { if (field === "linkedinAccountId") return `${label}: "linkedinAccountId" is only kept on an If / else that checks "linkedin_connected", so it would be dropped.`; if (field === "conditionValue" && row.check === "field_value") return `${label}: "conditionValue" would be dropped, because "${String(row.conditionOperator)}" compares against nothing.`; const checked = typeof row.check === "string" && CHECK_NAMES[row.check] ? ` This one checks ${CHECK_NAMES[row.check]}.` : ""; return `${label}: "${field}" is only kept when "check" is "field_value", so it would be dropped.${checked}`; } function checkTemplateTokens( row: Record, label: string, fail: Fail, options: StepCheckOptions, ): void { const override = options.singleBraceOverride ?? '"allowSingleBraces": true'; for (const field of TEMPLATE_FIELDS) { const text = row[field]; if (typeof text !== "string") continue; if (hasEmptyMergeToken(text)) fail( `${label}: "${field}" has an empty merge token ({{ }} or [[ ]]).`, field, ); if (options.allowSingleBraces) continue; const tokens = singleBraceTokens(text); if (tokens.length) fail( `${label}: "${field}" has ${tokens.join(", ")}. Single braces are sent exactly as typed; merge tokens use double braces: ${tokens .map((token) => `{${token}}`) .join(", ")}. If the braces are meant literally, pass ${override}.`, field, ); } } function checkWaitHours(hours: unknown, label: string, fail: Fail): void { if (typeof hours !== "number" || !(hours > 0)) fail(`${label} is a wait step and needs "waitHours" above 0.`, "waitHours"); else if (!Number.isFinite(hours) || hours > MAX_WAIT_HOURS) fail( `${label}: "waitHours" must be at most ${MAX_WAIT_HOURS} (90 days).`, "waitHours", ); } function checkConnectionNote(note: string, label: string, fail: Fail): void { if (note.length > MAX_CONNECTION_NOTE_CHARS) fail( `${label}: the connection note is ${note.length} characters; LinkedIn allows ${MAX_CONNECTION_NOTE_CHARS}.`, "linkedinMessageBody", ); } function checkMessageBody( body: string, label: string, fail: Fail, type: "linkedin_message" | "linkedin_inmail" = "linkedin_message", ): void { const limit = type === "linkedin_inmail" ? MAX_INMAIL_BODY_CHARS : MAX_LINKEDIN_MESSAGE_CHARS; const name = type === "linkedin_inmail" ? "InMail messages" : "LinkedIn messages"; if (!body) fail( `${label} is a ${type} step and needs "linkedinMessageBody".`, "linkedinMessageBody", ); else if (body.length > limit) fail( `${label}: "linkedinMessageBody" is ${body.length} characters; ${name} are limited to ${limit}.`, "linkedinMessageBody", ); } function checkInmailSubject(subject: string, label: string, fail: Fail): void { if (subject.length > MAX_INMAIL_SUBJECT_CHARS) fail( `${label}: "inmailSubject" is ${subject.length} characters; InMail subjects are limited to ${MAX_INMAIL_SUBJECT_CHARS}.`, "inmailSubject", ); } function checkTimeoutRange(timeout: unknown, label: string, fail: Fail): void { if ( typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 1 || timeout > MAX_WAIT_HOURS ) fail( `${label}: "connectionTimeoutHours" must be 1-${MAX_WAIT_HOURS} hours.`, "connectionTimeoutHours", ); } function checkCondition( row: Record, label: string, fail: Fail, ): void { if (row.check === undefined) { fail( `${label} is an If / else (condition) step and needs "check": ${CONDITION_CHECKS.join(", ")}.`, "check", ); return; } if (row.check !== "field_value") return; if (!trimmedText(row.conditionField)) fail( `${label} checks a lead field and needs "conditionField", the field's name (like "Job Title").`, "conditionField", ); if (row.conditionOperator === undefined) fail( `${label} checks a lead field and needs "conditionOperator": ${CONDITION_OPERATORS.join(", ")}.`, "conditionOperator", ); else if ( operatorTakesValue(row.conditionOperator) && !trimmedText(row.conditionValue) ) fail( `${label}: "conditionOperator": "${String(row.conditionOperator)}" needs "conditionValue", the text to compare the field with.`, "conditionValue", ); } function checkAcceptanceWait( timeout: unknown, label: string, fail: Fail, ): void { if (timeout === undefined) return; if ( typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < MIN_ACCEPTANCE_WAIT_HOURS || timeout > MAX_ACCEPTANCE_WAIT_HOURS ) fail( `${label}: "connectionTimeoutHours" must be ${MIN_ACCEPTANCE_WAIT_HOURS}-${MAX_ACCEPTANCE_WAIT_HOURS} hours (1-90 days) of waiting for them to accept.`, "connectionTimeoutHours", ); } const BRANCH_NAMES: Record = { condition: { name: "If / else", yes: "Yes", no: "No" }, wait_for_connection: { name: "Wait for them to accept", yes: "Accepted", no: "Not accepted", }, }; /** * The paths a workflow's steps declare, step by step: unique keys, targets * that exist, and the right exits for each kind of step. Whether every step * is reachable and every path moves forward is checked when the steps save. */ function checkWorkflowPaths( steps: readonly unknown[], errors: StepIssue[], ): void { const rows = steps.map((value) => value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null, ); const firstWithKey = new Map(); rows.forEach((row, index) => { const key = row?.key; if (typeof key === "string" && key && !firstWithKey.has(key)) firstWithKey.set(key, index); }); rows.forEach((row, index) => { if (!row) return; const step = index + 1; const fail: Fail = (message, field) => errors.push({ step, ...(field ? { field } : {}), message }); const branch = BRANCH_NAMES[String(row.type)]; const label = branch ? `Step ${step} (${branch.name})` : `Step ${step}`; const key = typeof row.key === "string" ? row.key : undefined; if (key === WORKFLOW_END) fail( `${label}: "key" can't be "${WORKFLOW_END}", because "${WORKFLOW_END}" marks where a path stops.`, "key", ); else if (key && firstWithKey.get(key) !== index) fail( `Steps ${(firstWithKey.get(key) ?? 0) + 1} and ${step} share the key "${key}". Give each step its own key.`, "key", ); if (branch) { for (const [field, exit] of [ ["yesNext", branch.yes], ["noNext", branch.no], ] as const) if (row[field] === undefined) fail( `${label} needs "${field}", where the "${exit}" path goes: another step's "key", or "${WORKFLOW_END}".`, field, ); if (row.next !== undefined) fail( `${label} continues on "yesNext" and "noNext", so it can't also have "next".`, "next", ); } else { for (const field of ["yesNext", "noNext"] as const) if (row[field] !== undefined) fail( `${label}: only condition and wait_for_connection steps branch, so "${field}" doesn't apply. Give it a single "next".`, field, ); } const exits = branch ? (["yesNext", "noNext"] as const) : (["next"] as const); for (const field of exits) { const target = row[field]; if (typeof target !== "string" || !target || target === WORKFLOW_END) continue; if (target === key) fail(`${label}: "${field}" points back at the step itself.`, field); else if (!firstWithKey.has(target)) fail( `${label}: "${field}" points at "${target}", which is not the "key" of any step. Use another step's "key", or "${WORKFLOW_END}" to stop there.`, field, ); } }); } function stepRow( value: unknown, label: string, fail: Fail, ): { row: Record; stepType: StepType } | null { if (!value || typeof value !== "object" || Array.isArray(value)) { fail(`${label} must be an object with a "type".`); return null; } const row = value as Record; const type = row.type; if ( typeof type !== "string" || !(STEP_TYPES as readonly string[]).includes(type) ) { fail( `${label} has type ${JSON.stringify(type)}. Use one of: ${STEP_TYPES.join(", ")}.`, "type", ); return null; } return { row, stepType: type as StepType }; } export function checkCampaignSteps( steps: readonly unknown[], options: StepCheckOptions = {}, ): StepCheck { const errors: StepIssue[] = []; const warnings: string[] = []; if (steps.length > MAX_CAMPAIGN_STEPS) errors.push({ step: 0, message: `A campaign can have at most ${MAX_CAMPAIGN_STEPS} steps (got ${steps.length}).`, }); const shaped = steps.some(hasWorkflowShape); const workflow = options.workflow ?? shaped; let emailSeen = false; let connectSeen = false; const waitingMessages: number[] = []; const unwaitedMessages: number[] = []; steps.forEach((value, index) => { const step = index + 1; const label = `Step ${step}`; const fail: Fail = (message, field) => errors.push({ step, ...(field ? { field } : {}), message }); const parsed = stepRow(value, label, fail); if (!parsed) return; const { row, stepType } = parsed; const unknown = Object.keys(row).filter( (key) => !(STEP_KEYS as readonly string[]).includes(key), ); if (unknown.length) fail( `${label} has keys the engine does not know: ${unknown.join(", ")}. Allowed: ${STEP_KEYS.join(", ")}.`, ); checkStepFieldShapes(row, stepType, label, fail); if (stepType === "wait") checkWaitHours(row.waitHours, label, fail); if (stepType === "email") { const reply = row.emailReplyToPrevious === true; if ( reply && workflow && !steps.some( (other, at) => at !== index && (other as { type?: unknown })?.type === "email", ) ) fail( `${label}: "emailReplyToPrevious" needs an email step before it on every path, and this workflow has no other email. Give this email its own "emailSubject".`, "emailReplyToPrevious", ); else if (reply && !emailSeen && !workflow) fail( `${label}: "emailReplyToPrevious" needs an earlier email step to reply to, so the first email would lose it. Give this email its own "emailSubject".`, "emailReplyToPrevious", ); const subject = trimmedText(row.emailSubject); if (!subject && !reply) fail( `${label} is an email step and needs "emailSubject"${emailSeen ? ' (or "emailReplyToPrevious": true to reply in the same thread)' : ""}.`, "emailSubject", ); if (!trimmedText(row.emailHtmlBody) && !trimmedText(row.emailTextBody)) fail( `${label} is an email step and needs "emailTextBody" or "emailHtmlBody".`, "emailTextBody", ); emailSeen = true; } if (stepType === "linkedin_connection") { checkConnectionNote(trimmedText(row.linkedinMessageBody), label, fail); connectSeen = true; } if (stepType === "linkedin_inmail") { checkMessageBody( trimmedText(row.linkedinMessageBody), label, fail, stepType, ); checkInmailSubject(trimmedText(row.inmailSubject), label, fail); } if (stepType === "linkedin_message") { checkMessageBody(trimmedText(row.linkedinMessageBody), label, fail); // A workflow waits for the accept with its own wait_for_connection step. const waitsByDefault = connectSeen && !workflow; const mode = row.linkedinMessageMode ?? (waitsByDefault ? "wait_until_connected" : "skip_if_not_connected"); const timeout = row.connectionTimeoutHours; if (timeout !== undefined) { if (mode !== "wait_until_connected") fail( `${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected"${waitsByDefault ? "" : workflow ? " (in a workflow, a wait_for_connection step waits for the accept instead)" : " (the default only after a linkedin_connection step)"}, so it would be dropped.`, "connectionTimeoutHours", ); else checkTimeoutRange(timeout, label, fail); } if ( waitsByDefault && mode === "wait_until_connected" && (timeout ?? DEFAULT_CONNECTION_WAIT_HOURS) === DEFAULT_CONNECTION_WAIT_HOURS ) waitingMessages.push(step); if ( workflow && !shaped && connectSeen && row.linkedinMessageMode === undefined ) unwaitedMessages.push(step); } if (stepType === "condition") checkCondition(row, label, fail); if (stepType === "wait_for_connection") checkAcceptanceWait(row.connectionTimeoutHours, label, fail); checkTemplateTokens(row, label, fail, options); }); if (workflow) checkWorkflowPaths(steps, errors); if (unwaitedMessages.length > 0) warnings.push( `${listSteps(unwaitedMessages)} ${unwaitedMessages.length === 1 ? "is a LinkedIn message" : "are LinkedIn messages"} after the connection request, and this campaign is a workflow, so ${unwaitedMessages.length === 1 ? "it doesn't" : "they don't"} wait for the accept: anyone who hasn't accepted by then is skipped. To wait, edit the workflow dench campaign steps get prints, or add a wait_for_connection step after the request with "yesNext" leading to the message. To skip on purpose, set "linkedinMessageMode": "skip_if_not_connected".`, ); if (waitingMessages.length >= 2) warnings.push( `${listSteps(waitingMessages)} are LinkedIn messages after the connection request. Each waits up to ${DEFAULT_CONNECTION_WAIT_HOURS / 24} days for the accept by default, so someone who never accepts holds the sequence for up to ${(waitingMessages.length * DEFAULT_CONNECTION_WAIT_HOURS) / 24} days. Give the later ones "linkedinMessageMode": "skip_if_not_connected" or a shorter "connectionTimeoutHours".`, ); return { errors, warnings }; } export function stepToInput( step: Record, ): Record { const input: Record = {}; for (const key of STEP_KEYS) { if ( step[key] !== undefined && (step[key] !== null || key === "withdrawAfterHours") ) input[key] = step[key]; } return input; } export function checkStepEdits( edits: readonly unknown[], options: StepCheckOptions = {}, ): StepCheck { const errors: StepIssue[] = []; edits.forEach((value, index) => { const step = index + 1; const label = `Edit ${step}`; const fail: Fail = (message, field) => errors.push({ step, ...(field ? { field } : {}), message }); const parsed = stepRow(value, label, fail); if (!parsed) return; const { row, stepType } = parsed; checkStepFieldShapes(row, stepType, label, fail); if (stepType === "wait" && row.waitHours !== undefined) checkWaitHours(row.waitHours, label, fail); if (stepType === "linkedin_connection") checkConnectionNote(trimmedText(row.linkedinMessageBody), label, fail); if (stepType === "linkedin_inmail") { if (row.linkedinMessageBody !== undefined) checkMessageBody( trimmedText(row.linkedinMessageBody), label, fail, stepType, ); checkInmailSubject(trimmedText(row.inmailSubject), label, fail); } if (stepType === "linkedin_message") { if (row.linkedinMessageBody !== undefined) checkMessageBody(trimmedText(row.linkedinMessageBody), label, fail); if (row.connectionTimeoutHours !== undefined) { checkTimeoutRange(row.connectionTimeoutHours, label, fail); if (row.linkedinMessageMode === "skip_if_not_connected") fail( `${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected".`, "connectionTimeoutHours", ); } } if (stepType === "wait_for_connection") checkAcceptanceWait(row.connectionTimeoutHours, label, fail); checkTemplateTokens(row, label, fail, options); }); return { errors, warnings: [] }; } const KEEP_WHEN_OMITTED = [ "emailMailboxAccountId", "linkedinAccountId", "linkedinMessageMode", "connectionTimeoutHours", "likeReaction", "likeMaxPostAgeDays", "likeSkipPreviousWait", "likeSkipNextWait", "likeCount", "withdrawAfterHours", ...FROZEN_AFTER_LAUNCH, "conditionValue", ] as const; const CLEARED_VALUES: Record = { emailSubject: "", emailHtmlBody: "", emailTextBody: "", linkedinMessageBody: "", emailReplyToPrevious: false, onlyIf: "always", inmailSubject: "", withdrawAfterHours: null, }; const TYPE_NAMES: Record = { wait: "a wait", email: "an email", linkedin_visit: "a profile visit", linkedin_connection: "a connection request", linkedin_message: "a LinkedIn message", linkedin_like_post: "a post reaction", linkedin_inmail: "an InMail", condition: "an If / else", wait_for_connection: "a Wait for them to accept", }; const STEP_NAMES: Record = { condition: "If / else", wait_for_connection: "Wait for them to accept", }; /** Why a launched campaign's file can't change these paths and checks. */ function frozenFieldProblems( stored: readonly StoredStep[], edits: ReadonlyMap>, campaign: string, ): string[] { const problems: string[] = []; let paths = false; let checks = false; for (const [index, fields] of [...edits.entries()].sort( ([a], [b]) => a - b, )) { const before = stepToInput(stored[index]); const changed = FROZEN_AFTER_LAUNCH.filter( (field) => fields[field] !== undefined && fields[field] !== before[field], ); if (!changed.length) continue; const name = STEP_NAMES[stored[index].type]; const label = `Step ${index + 1}${name ? ` (${name})` : ""}`; const list = changed.map((field) => `"${field}"`).join(", "); const isPath = (field: string) => (PATH_KEYS as readonly string[]).includes(field); if (changed.some(isPath)) paths = true; if (!changed.every(isPath)) checks = true; problems.push( `${label}: ${list} can't change after launch (the file has ${changed .map( (field) => `${field} ${JSON.stringify(fields[field])} instead of ${JSON.stringify(before[field] ?? null)}`, ) .join(", ")}).`, ); } if (!problems.length) return problems; const why = [ paths ? "Where a launched campaign's steps lead stays fixed, because people may already be on those paths." : "", checks ? 'A launched If / else keeps what it checks; you can still change its "conditionValue".' : "", ].filter(Boolean); return [ ...problems, `${why.join(" ")} Nothing was saved. To change it, copy the campaign with dench campaign duplicate ${campaign} and set the copy's steps.`, ]; } export class StepEditError extends Error {} export type StoredStep = Record & { type: string }; export type StepChange = { step: number; type: string; fields: string[] }; export type StepEditPlan = { steps: Record[]; updates: Record[]; changes: StepChange[]; warnings: string[]; }; export type StepEditOptions = StepCheckOptions & { campaign?: string; draft?: boolean; }; /** * Where each file row's `key` sits among the stored steps: null when some row * has no key or two rows share one (match by order instead), undefined when * every row has its own key but one names no stored step. */ function keyMatch( stored: readonly StoredStep[], rows: readonly Record[], ): number[] | null | undefined { const keys = rows.map((row) => row.key); if ( keys.some((key) => typeof key !== "string" || !key) || new Set(keys).size !== keys.length ) return null; const indexes = keys.map((key) => stored.findIndex((step) => step.key === key), ); return indexes.includes(-1) ? undefined : indexes; } export function planStepEdits( storedSteps: readonly StoredStep[], entries: readonly unknown[], options: StepEditOptions = {}, ): StepEditPlan { const campaign = options.campaign ?? ""; const stored = [...storedSteps].sort( (a, b) => Number(a.order ?? 0) - Number(b.order ?? 0), ); if (!entries.length) throw new StepEditError("The file has no steps."); const rows = entries.map((entry, index) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new StepEditError( `Step ${index + 1} in the file must be an object with a "type".`, ); return entry as Record; }); const withIds = rows.filter((row) => row.stepId !== undefined).length; if (withIds > 0 && withIds < rows.length) throw new StepEditError( `Give every step its "stepId" (from dench campaign steps get ${campaign} --with-ids), or leave "stepId" out of all of them to match the steps by order.`, ); const byId = withIds > 0; if (!byId && rows.length !== stored.length) throw new StepEditError( `The file has ${rows.length} ${rows.length === 1 ? "step" : "steps"} and the campaign has ${stored.length}. steps edit changes steps in place, matched by ${options.workflow ? '"key" or by order' : "order"}, so it needs all ${stored.length}; to edit only some, include "stepId" from dench campaign steps get ${campaign} --with-ids.`, ); // A workflow is stored in path order, which can differ from the file's, // so its steps match by key whenever every key names a stored step. const keyIndex = byId || !options.workflow ? null : keyMatch(stored, rows); if (keyIndex === undefined && !options.draft) { const keys = new Set(stored.map((step) => step.key)); const unknown = rows.findIndex((row) => !keys.has(row.key)); throw new StepEditError( `Step ${unknown + 1} in the file has "key": ${JSON.stringify(rows[unknown].key)}, which is not a step of this campaign. After launch every step keeps its "key"; use the keys dench campaign steps get ${campaign} prints.`, ); } const restructure = options.draft ? `To add, remove, reorder or retype steps on a draft, use dench campaign steps set ${campaign} --file steps.json.` : `After launch a step keeps its type and place; to change the sequence, copy it with dench campaign duplicate ${campaign} and set the copy's steps.`; const edits = new Map>(); rows.forEach((row, position) => { let index = keyIndex?.[position] ?? position; if (byId) { index = stored.findIndex( (step) => String(step._id) === String(row.stepId), ); if (index < 0) throw new StepEditError( `Step id ${String(row.stepId)} is not in this campaign. Run dench campaign steps get ${campaign} --with-ids for the current ids.`, ); if (edits.has(index)) throw new StepEditError( `Step ${index + 1} (id ${String(row.stepId)}) is in the file twice.`, ); } const current = stored[index]; if (row.type !== current.type) throw new StepEditError( `Step ${index + 1} is ${TYPE_NAMES[current.type as StepType] ?? current.type} step, and the file makes it ${JSON.stringify(row.type)}. ${restructure}`, ); if (row.updatedAt !== undefined && row.updatedAt !== current.updatedAt) throw new StepEditError( `Step ${index + 1} was saved again after you read it. Run dench campaign steps get ${campaign} --with-ids again and redo the edit, so the newer change is not overwritten.`, ); const { stepId: _stepId, updatedAt: _updatedAt, ...fields } = row; edits.set(index, fields); }); if (!options.draft) { const frozen = frozenFieldProblems(stored, edits, campaign); if (frozen.length) throw new StepEditError(frozen.join("\n")); } const warnings: string[] = []; const steps = stored.map((step, index) => { const before = stepToInput(step); const fields = edits.get(index); if (!fields) return before; const after: Record = {}; for (const [key, value] of Object.entries(fields)) if (value !== undefined) after[key] = value; const kept: string[] = []; for (const key of KEEP_WHEN_OMITTED) if (after[key] === undefined && before[key] !== undefined) { after[key] = before[key]; kept.push(key); } // A new check or comparison leaves behind the saved settings it ignores. if (step.type === "condition") for (const key of conditionDrops(after)) if (kept.includes(key)) delete after[key]; if ( hasHtmlAndTextBody(before) && trimmedText(after.emailTextBody) !== "" && trimmedText(after.emailHtmlBody) === trimmedText(before.emailHtmlBody) && trimmedText(after.emailTextBody) !== trimmedText(before.emailTextBody) ) { delete after.emailHtmlBody; warnings.push(staleHtmlWarning(index)); } return after; }); const check = checkCampaignSteps(steps, options); if (check.errors.length) throw new StepEditError( check.errors.map((issue) => issue.message).join("\n"), ); warnings.push(...check.warnings); const updates: Record[] = []; const changes: StepChange[] = []; steps.forEach((after, index) => { const step = stored[index]; const before = stepToInput(step); const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])] .filter((key) => key !== "type" && before[key] !== after[key]) .filter( (key) => after[key] !== undefined || CLEARED_VALUES[key] !== undefined, ); if (!fields.length) return; updates.push({ stepId: String(step._id), expectedUpdatedAt: step.updatedAt, type: step.type, ...Object.fromEntries( fields.map((key) => [key, after[key] ?? CLEARED_VALUES[key]]), ), }); changes.push({ step: index + 1, type: step.type, fields }); }); return { steps, updates, changes, warnings }; }