2025-11-26 16:36:06 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Support Domain - Salesforce Provider Mapper
|
|
|
|
|
|
*
|
|
|
|
|
|
* Transform functions to convert raw Salesforce Case records to domain types.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-12-29 18:39:13 +09:00
|
|
|
|
import {
|
|
|
|
|
|
supportCaseSchema,
|
|
|
|
|
|
caseMessageSchema,
|
|
|
|
|
|
type SupportCase,
|
|
|
|
|
|
type CaseMessage,
|
|
|
|
|
|
} from "../../schema.js";
|
|
|
|
|
|
import type {
|
|
|
|
|
|
SalesforceCaseRecord,
|
|
|
|
|
|
SalesforceEmailMessage,
|
|
|
|
|
|
SalesforceCaseComment,
|
|
|
|
|
|
} from "./raw.types.js";
|
2025-11-26 16:36:06 +09:00
|
|
|
|
import {
|
|
|
|
|
|
getStatusDisplayLabel,
|
|
|
|
|
|
getPriorityDisplayLabel,
|
|
|
|
|
|
SALESFORCE_CASE_STATUS,
|
|
|
|
|
|
SALESFORCE_CASE_PRIORITY,
|
2025-12-10 15:22:10 +09:00
|
|
|
|
} from "./raw.types.js";
|
2025-11-26 16:36:06 +09:00
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 15:11:56 +09:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 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();
|
2026-01-15 11:28:25 +09:00
|
|
|
|
return firstParagraph || body.slice(0, 500);
|
2026-01-05 15:11:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 16:36:06 +09:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 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
|
|
|
|
|
|
*/
|
2025-12-25 17:30:02 +09:00
|
|
|
|
export function transformSalesforceCaseToSupportCase(record: SalesforceCaseRecord): SupportCase {
|
2025-11-26 16:36:06 +09:00
|
|
|
|
// 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
|
2025-12-29 16:53:32 +09:00
|
|
|
|
* @param origin - Case origin to filter by (e.g., "Portal Support")
|
2025-11-26 16:36:06 +09:00
|
|
|
|
* @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();
|
|
|
|
|
|
}
|
2025-12-29 18:39:13 +09:00
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 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.
|
|
|
|
|
|
*
|
2026-01-05 15:11:56 +09:00
|
|
|
|
* Cleans the email body to show only the latest reply, stripping out
|
|
|
|
|
|
* quoted content and previous email chains for a cleaner conversation view.
|
|
|
|
|
|
*
|
2025-12-29 18:39:13 +09:00
|
|
|
|
* @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);
|
|
|
|
|
|
|
2026-01-05 15:11:56 +09:00
|
|
|
|
// Get the raw email body and clean it (strip quoted content)
|
|
|
|
|
|
const rawBody = ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? "";
|
|
|
|
|
|
const cleanedBody = cleanEmailBody(rawBody);
|
|
|
|
|
|
|
2025-12-29 18:39:13 +09:00
|
|
|
|
return caseMessageSchema.parse({
|
|
|
|
|
|
id: email.Id,
|
|
|
|
|
|
type: "email",
|
2026-01-05 15:11:56 +09:00
|
|
|
|
body: cleanedBody,
|
2025-12-29 18:39:13 +09:00
|
|
|
|
author: {
|
|
|
|
|
|
name: fromName,
|
|
|
|
|
|
email: fromEmail ?? null,
|
|
|
|
|
|
isCustomer,
|
|
|
|
|
|
},
|
|
|
|
|
|
createdAt: ensureString(email.MessageDate) ?? ensureString(email.CreatedDate) ?? nowIsoString(),
|
|
|
|
|
|
direction: isIncoming ? "inbound" : "outbound",
|
2026-01-05 15:11:56 +09:00
|
|
|
|
hasAttachment: email.HasAttachment === true,
|
2025-12-29 18:39:13 +09:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|