2026-01-15 14:38:25 +09:00

508 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 <date> ... <email>\nwrote:" or "On <date> ... <name> <\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 <date> <name> wrote:" that may span lines
/On\s+\d{1,2}[^]*?wrote:\s*/gi,
// Japanese: "<date>に<name>が書きました:"
/\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: <email>" 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 <date>, <name> 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",
// Email-to-Case Threading
"Thread_Id",
];
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;
});
}