/** * Support Domain - Salesforce Provider Mapper * * Transform functions to convert raw Salesforce Case records to domain types. */ import { supportCaseSchema, caseMessageSchema, type SupportCase, type CaseMessage, } from "../../schema.js"; import type { SalesforceCaseRecord, SalesforceEmailMessage, SalesforceCaseComment, } from "./raw.types.js"; import { getStatusDisplayLabel, getPriorityDisplayLabel, SALESFORCE_CASE_STATUS, SALESFORCE_CASE_PRIORITY, } from "./raw.types.js"; // ============================================================================ // Helper Functions // ============================================================================ /** * Safely coerce a value to string or return undefined */ function ensureString(value: unknown): string | undefined { if (typeof value === "string" && value.length > 0) { return value; } return undefined; } /** * Get current ISO timestamp */ function nowIsoString(): string { return new Date().toISOString(); } // ============================================================================ // Email Body Cleaning // ============================================================================ /** * Multi-line patterns to remove from email body FIRST (before line-by-line processing). * These patterns can span multiple lines and should be removed entirely. * * Order matters - more specific patterns should come first. */ const MULTILINE_QUOTE_PATTERNS: RegExp[] = [ // Gmail multi-line: "On ... \nwrote:" or "On ... <\nemail> wrote:" // This handles cases where the email address or "wrote:" wraps to next line /On\s+(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[^]*?wrote:\s*/gi, // Generic "On wrote:" that may span lines /On\s+\d{1,2}[^]*?wrote:\s*/gi, // Japanese: "が書きました:" /\d{4}[年/-]\d{1,2}[月/-]\d{1,2}[日]?[^]*?書きました[::]\s*/g, // "---- Original Message ----" and everything after /-{2,}\s*(?:Original\s*Message|Forwarded|転送|元のメッセージ)[^]*/gi, // "___" Outlook separator and everything after /_{3,}[^]*/g, // "From: " header block (usually indicates quoted content start) // Only match if it's followed by typical email header patterns /\nFrom:\s*[^\n]+\n(?:Sent|To|Date|Subject|Cc):[^]*/gi, ]; /** * Single-line patterns that indicate the start of quoted content. * Once matched, all remaining lines are discarded. */ const QUOTE_START_LINE_PATTERNS: RegExp[] = [ // "> quoted text" at start of line /^>/, // "From:" at start of line (email header) /^From:\s+.+/i, // "送信者:" (Japanese: Sender) /^送信者[::]\s*.+/, // "Sent:" header /^Sent:\s+.+/i, // Date/Time headers appearing mid-email /^(Date|日時|送信日時)[::]\s+.+/i, // "Subject:" appearing mid-email /^(Subject|件名)[::]\s+.+/i, // "To:" appearing mid-email (not at very start) /^(To|宛先)[::]\s+.+/i, // Lines that look like "wrote:" endings we might have missed /^\s*wrote:\s*$/i, /^\s*書きました[::]?\s*$/, // Email in angle brackets followed by wrote (continuation line) /^[^<]*<[^>]+>\s*wrote:\s*$/i, ]; /** * Patterns for lines to skip (metadata) but continue processing */ const SKIP_LINE_PATTERNS: RegExp[] = [ /^(Cc|CC|Bcc|BCC)[::]\s*.*/i, /^(Reply-To|返信先)[::]\s*.*/i, ]; /** * Clean email body by removing quoted/forwarded content. * * This function strips out: * - Quoted replies (lines starting with ">") * - "On , wrote:" blocks (including multi-line Gmail format) * - "From: / To: / Subject:" quoted headers * - "-------- Original Message --------" separators * - Japanese equivalents of the above * * @param body - Raw email text body * @returns Cleaned email body with only the latest reply content */ export function cleanEmailBody(body: string): string { if (!body) return ""; let cleaned = body; // Step 1: Apply multi-line pattern removal first for (const pattern of MULTILINE_QUOTE_PATTERNS) { cleaned = cleaned.replace(pattern, ""); } // Step 2: Process line by line for remaining patterns const lines = cleaned.split(/\r?\n/); const cleanLines: string[] = []; let foundQuoteStart = false; for (const line of lines) { if (foundQuoteStart) { // Already in quoted section, skip all remaining lines continue; } const trimmedLine = line.trim(); // Check if this line starts quoted content const isQuoteStart = QUOTE_START_LINE_PATTERNS.some(pattern => pattern.test(trimmedLine)); if (isQuoteStart) { foundQuoteStart = true; continue; } // Skip metadata lines but continue processing const isSkipLine = SKIP_LINE_PATTERNS.some(pattern => pattern.test(trimmedLine)); if (isSkipLine) { continue; } cleanLines.push(line); } // Step 3: Clean up the result let result = cleanLines.join("\n"); // Remove excessive trailing whitespace/newlines result = result.replace(/\s+$/, ""); // Remove leading blank lines result = result.replace(/^\s*\n+/, ""); // If we stripped everything, return original (edge case) if (!result.trim()) { // Try to extract at least something useful from original const firstParagraph = body.split(/\n\s*\n/)[0]?.trim(); return firstParagraph || body.slice(0, 500); } return result; } // ============================================================================ // Transform Functions // ============================================================================ /** * Transform a raw Salesforce Case record to a portal SupportCase. * * Converts Salesforce API values (often in Japanese) to portal display labels (English). * * @param record - Raw Salesforce Case record from SOQL query * @returns Validated SupportCase domain object */ export function transformSalesforceCaseToSupportCase(record: SalesforceCaseRecord): SupportCase { // Get raw values const rawStatus = ensureString(record.Status) ?? SALESFORCE_CASE_STATUS.NEW; const rawPriority = ensureString(record.Priority) ?? SALESFORCE_CASE_PRIORITY.MEDIUM; return supportCaseSchema.parse({ id: record.Id, caseNumber: record.CaseNumber, subject: ensureString(record.Subject) ?? "", // Convert Japanese SF values to English display labels status: getStatusDisplayLabel(rawStatus), priority: getPriorityDisplayLabel(rawPriority), category: ensureString(record.Type) ?? null, description: ensureString(record.Description) ?? "", createdAt: ensureString(record.CreatedDate) ?? nowIsoString(), updatedAt: ensureString(record.LastModifiedDate) ?? nowIsoString(), closedAt: ensureString(record.ClosedDate) ?? null, }); } /** * Transform multiple Salesforce Case records to SupportCase array. * * @param records - Array of raw Salesforce Case records * @returns Array of validated SupportCase domain objects */ export function transformSalesforceCasesToSupportCases( records: SalesforceCaseRecord[] ): SupportCase[] { return records.map(transformSalesforceCaseToSupportCase); } /** * Build the SOQL SELECT fields for Case queries. * * Standard Salesforce Case fields based on org configuration. * Note: Type field is not accessible via API in this org. * * @param additionalFields - Optional additional fields to include * @returns Array of field names for SOQL SELECT clause */ export function buildCaseSelectFields(additionalFields: string[] = []): string[] { const baseFields = [ // Core identifiers "Id", "CaseNumber", // Case content "Subject", "Description", // Picklist fields "Status", "Priority", "Origin", // Relationships "AccountId", "ContactId", "OwnerId", // Timestamps "CreatedDate", "LastModifiedDate", "ClosedDate", // Flags "IsEscalated", ]; return [...new Set([...baseFields, ...additionalFields])]; } /** * Build a SOQL query for fetching cases for an account. * * @param accountId - Salesforce Account ID * @param origin - Case origin to filter by (e.g., "Portal Support") * @param additionalFields - Optional additional fields to include * @returns SOQL query string */ export function buildCasesForAccountQuery( accountId: string, origin: string, additionalFields: string[] = [] ): string { const fields = buildCaseSelectFields(additionalFields).join(", "); return ` SELECT ${fields} FROM Case WHERE AccountId = '${accountId}' AND Origin = '${origin}' ORDER BY CreatedDate DESC LIMIT 100 `.trim(); } /** * Build a SOQL query for fetching a single case by ID. * * @param caseId - Salesforce Case ID * @param accountId - Salesforce Account ID (for ownership validation) * @param origin - Case origin to filter by * @param additionalFields - Optional additional fields to include * @returns SOQL query string */ export function buildCaseByIdQuery( caseId: string, accountId: string, origin: string, additionalFields: string[] = [] ): string { const fields = buildCaseSelectFields(additionalFields).join(", "); return ` SELECT ${fields} FROM Case WHERE Id = '${caseId}' AND AccountId = '${accountId}' AND Origin = '${origin}' LIMIT 1 `.trim(); } // ============================================================================ // EmailMessage Transform Functions // ============================================================================ /** * Build the SOQL SELECT fields for EmailMessage queries. */ export function buildEmailMessageSelectFields(): string[] { return [ "Id", "ParentId", "Subject", "TextBody", "HtmlBody", "FromAddress", "FromName", "ToAddress", "Incoming", "Status", "MessageDate", "CreatedDate", "HasAttachment", ]; } /** * Build SOQL query for fetching EmailMessages for a Case. */ export function buildEmailMessagesForCaseQuery(caseId: string): string { const fields = buildEmailMessageSelectFields().join(", "); return ` SELECT ${fields} FROM EmailMessage WHERE ParentId = '${caseId}' ORDER BY MessageDate ASC `.trim(); } /** * Transform a Salesforce EmailMessage to a unified CaseMessage. * * Cleans the email body to show only the latest reply, stripping out * quoted content and previous email chains for a cleaner conversation view. * * @param email - Raw Salesforce EmailMessage * @param customerEmail - Customer's email address for comparison */ export function transformEmailMessageToCaseMessage( email: SalesforceEmailMessage, customerEmail?: string ): CaseMessage { const isIncoming = email.Incoming === true; const fromEmail = ensureString(email.FromAddress); const fromName = ensureString(email.FromName) ?? fromEmail ?? "Unknown"; // Determine if this is from the customer // Incoming emails are from customer, or check email match const isCustomer = isIncoming || (customerEmail ? fromEmail?.toLowerCase() === customerEmail.toLowerCase() : false); // Get the raw email body and clean it (strip quoted content) const rawBody = ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? ""; const cleanedBody = cleanEmailBody(rawBody); return caseMessageSchema.parse({ id: email.Id, type: "email", body: cleanedBody, author: { name: fromName, email: fromEmail ?? null, isCustomer, }, createdAt: ensureString(email.MessageDate) ?? ensureString(email.CreatedDate) ?? nowIsoString(), direction: isIncoming ? "inbound" : "outbound", hasAttachment: email.HasAttachment === true, }); } /** * Transform multiple EmailMessages to CaseMessages. */ export function transformEmailMessagesToCaseMessages( emails: SalesforceEmailMessage[], customerEmail?: string ): CaseMessage[] { return emails.map(email => transformEmailMessageToCaseMessage(email, customerEmail)); } // ============================================================================ // CaseComment Transform Functions // ============================================================================ /** * Build the SOQL SELECT fields for CaseComment queries. */ export function buildCaseCommentSelectFields(): string[] { return [ "Id", "ParentId", "CommentBody", "IsPublished", "CreatedById", "CreatedBy.Id", "CreatedBy.Name", "CreatedBy.Email", "CreatedDate", ]; } /** * Build SOQL query for fetching public CaseComments for a Case. */ export function buildCaseCommentsForCaseQuery(caseId: string): string { const fields = buildCaseCommentSelectFields().join(", "); return ` SELECT ${fields} FROM CaseComment WHERE ParentId = '${caseId}' AND IsPublished = true ORDER BY CreatedDate ASC `.trim(); } /** * Transform a Salesforce CaseComment to a unified CaseMessage. * * @param comment - Raw Salesforce CaseComment * @param customerContactId - Customer's Salesforce Contact ID for comparison */ export function transformCaseCommentToCaseMessage( comment: SalesforceCaseComment, customerContactId?: string ): CaseMessage { const authorName = comment.CreatedBy?.Name ?? "Unknown"; const authorEmail = comment.CreatedBy?.Email ?? null; // Determine if this is from the customer // If CreatedById matches customer's contact ID, it's from customer // Otherwise, we assume it's from an agent (conservative approach) const isCustomer = customerContactId ? comment.CreatedById === customerContactId : false; return caseMessageSchema.parse({ id: comment.Id, type: "comment", body: ensureString(comment.CommentBody) ?? "", author: { name: authorName, email: authorEmail, isCustomer, }, createdAt: ensureString(comment.CreatedDate) ?? nowIsoString(), direction: null, // Comments don't have direction }); } /** * Transform multiple CaseComments to CaseMessages. */ export function transformCaseCommentsToCaseMessages( comments: SalesforceCaseComment[], customerContactId?: string ): CaseMessage[] { return comments.map(comment => transformCaseCommentToCaseMessage(comment, customerContactId)); } // ============================================================================ // Unified Message Functions // ============================================================================ /** * Merge and sort EmailMessages and CaseComments into a unified timeline. * * @param emailMessages - Transformed email messages * @param caseComments - Transformed case comments * @returns Merged and sorted array of CaseMessages */ export function mergeAndSortCaseMessages( emailMessages: CaseMessage[], caseComments: CaseMessage[] ): CaseMessage[] { const allMessages = [...emailMessages, ...caseComments]; // Sort by createdAt ascending (oldest first) return allMessages.sort((a, b) => { const dateA = new Date(a.createdAt).getTime(); const dateB = new Date(b.createdAt).getTime(); return dateA - dateB; }); }