export type TimelineRun = { _id: string; stepId: string; stepIndex: number; type: string; status: string; scheduledAt?: number; startedAt?: number; waitUntil?: number; retryAt?: number; /** A wait for acceptance: when it stops waiting either way. */ timeoutAt?: number; completedAt?: number; emailMessageId?: string; lastError?: string; result?: unknown; }; export type TimelineEmail = { _id: string; subject?: string; textBody?: string; status?: string; sentAt?: number; firstOpenedAt?: number; firstClickedAt?: number; lastError?: string; }; export type TimelineLinkedin = { outreachStepRunId?: string; direction?: string; text?: string; sentAt?: number; deliveredAt?: number; readAt?: number; }; export type TimelineResult = { enrollment: { _id: string; displayName?: string; status: string; lastError?: string; repliedAt?: number; linkedinProfileUrl?: string; personalization?: unknown; }; steps: Array<{ _id: string; type: string; order: number; waitHours?: number; /** If / else and Wait until they accept steps: what they check. */ label?: string; }>; runs: TimelineRun[]; emailMessages: TimelineEmail[]; linkedinMessages: TimelineLinkedin[]; }; export type TimelineStep = { step: number; stepId: string; type: string; status: string; outcome: string; at: number | null; detail: string[]; /** An If / else or a wait for acceptance: the path it took. */ branch?: "yes" | "no"; /** Still waiting for them to accept: when it stops waiting. */ acceptanceDeadline?: number; subject?: string; text?: string; error?: string; }; const SKIP_REASONS: Record = { missing_email_identity: "Skipped: no email address", missing_linkedin_identity: "Skipped: no LinkedIn profile", not_connected_on_linkedin: "Skipped: not connected on LinkedIn", already_connected_on_linkedin: "Skipped: already connected on LinkedIn", already_connected: "Skipped: already connected", not_connected: "Skipped: not connected on LinkedIn", linkedin_connection_timeout: "Skipped: they did not accept the request in time", recipient_invited_sender: "Skipped: they had already sent you a request", provider_outcome_unresolved: "Skipped: we could not confirm it went out", unsubscribe: "Skipped: they unsubscribed", bounce: "Skipped: their address bounced", }; const RUN_STATUS: Record = { queued: "Queued", running: "Sending", waiting: "Waiting", retry: "Will retry", sent: "Done", skipped: "Skipped", failed: "Failed", unknown: "Needs a look", }; const EMAIL_STATUS: Record = { queued: "sending", bounced: "bounced", complained: "marked as spam", failed: "could not send", }; function resultField(result: unknown, key: string): unknown { return result && typeof result === "object" ? (result as Record)[key] : undefined; } function skipReason(result: unknown): string { const reason = resultField(result, "reason"); return (typeof reason === "string" && SKIP_REASONS[reason]) || "Skipped"; } /** Why an If / else or a wait for acceptance took its No path. */ const NO_REASONS: Record = { missing_linkedin_identity: "no LinkedIn profile", missing_email_identity: "no email address", linkedin_connection_timeout: "they did not accept in time", linkedin_invite_withdrawn: "the connection request was withdrawn", linkedin_invite_not_sent: "no connection request went out", }; const LOGIC_TYPES = new Set(["condition", "wait_for_connection"]); export function branchOf(run: TimelineRun): "yes" | "no" | undefined { if (!LOGIC_TYPES.has(run.type) || run.status !== "sent") return undefined; const branch = resultField(run.result, "branch"); return branch === "yes" || branch === "no" ? branch : undefined; } /** What the editor calls each exit: Yes / No, or Accepted / Not accepted. */ export function branchExitLabel(type: string, branch: "yes" | "no"): string { if (type === "wait_for_connection") return branch === "yes" ? "Accepted" : "Not accepted"; return branch === "yes" ? "Yes" : "No"; } function logicOutcome(run: TimelineRun): string | undefined { if (!LOGIC_TYPES.has(run.type)) return undefined; const branch = branchOf(run); if (branch) { const reason = resultField(run.result, "reason"); const why = branch === "no" && typeof reason === "string" ? NO_REASONS[reason] : ""; return `${branchExitLabel(run.type, branch)}${why ? ` (${why})` : ""}`; } if (run.status === "sent") return "Done"; if (run.type === "wait_for_connection") return run.status === "waiting" ? "Waiting for them to accept" : run.status === "queued" || run.status === "running" ? "Checking whether they accepted" : undefined; return ["queued", "running", "waiting"].includes(run.status) ? "Checking" : undefined; } function outcomeOf(run: TimelineRun): string { if (run.status === "skipped") return skipReason(run.result); const logic = logicOutcome(run); if (logic) return logic; if (run.status === "sent") { if (run.type === "wait") return "Waited"; if (run.type === "linkedin_visit") return "Visited"; return "Sent"; } return RUN_STATUS[run.status] ?? run.status; } export function timelineSteps(result: TimelineResult): TimelineStep[] { const steps = new Map(result.steps.map((step) => [String(step._id), step])); const emails = new Map( result.emailMessages.map((message) => [String(message._id), message]), ); const linkedin = new Map( result.linkedinMessages.map((message) => [ String(message.outreachStepRunId ?? ""), message, ]), ); return [...result.runs] .sort((a, b) => a.stepIndex - b.stepIndex) .map((run) => { const step = steps.get(String(run.stepId)); const email = run.emailMessageId ? emails.get(String(run.emailMessageId)) : undefined; const message = linkedin.get(String(run._id)); const detail: string[] = []; if (email) { if (email.firstClickedAt) detail.push("clicked"); else if (email.firstOpenedAt) detail.push("opened"); else if (email.status && EMAIL_STATUS[email.status]) detail.push(EMAIL_STATUS[email.status]); } else if (message) { if (message.readAt) detail.push("read"); else if (message.deliveredAt) detail.push("delivered"); } const text = (email?.textBody ?? message?.text)?.trim(); const problem = run.status === "sent" || run.status === "skipped" ? undefined : (run.lastError ?? email?.lastError)?.trim(); const branch = branchOf(run); const waitingForAccept = run.type === "wait_for_connection" && run.status === "waiting" && typeof run.timeoutAt === "number"; return { step: (step?.order ?? run.stepIndex) + 1, stepId: String(run.stepId), type: run.type, status: run.status, outcome: outcomeOf(run), at: run.completedAt ?? run.retryAt ?? run.waitUntil ?? run.startedAt ?? run.scheduledAt ?? null, detail, ...(branch ? { branch } : {}), ...(waitingForAccept ? { acceptanceDeadline: run.timeoutAt } : {}), ...(email?.subject ? { subject: email.subject } : {}), ...(text ? { text } : {}), ...(problem ? { error: problem } : {}), }; }); } export function personAddress(enrollment: TimelineResult["enrollment"]) { const snapshot = enrollment.personalization && typeof enrollment.personalization === "object" ? (enrollment.personalization as Record) : {}; const email = typeof snapshot.email === "string" ? snapshot.email : undefined; return { ...(email ? { email } : {}), ...(enrollment.linkedinProfileUrl ? { linkedinProfileUrl: enrollment.linkedinProfileUrl } : {}), }; }