Implement Case Messaging and Commenting Features in Salesforce Integration

- Added methods in `SalesforceCaseService` to fetch and add case messages, unifying email messages and case comments into a chronological conversation thread.
- Enhanced `SupportService` and `SupportController` to handle case message retrieval and comment addition, integrating new DTOs for request and response validation.
- Updated `SupportCaseDetailView` in the Portal to display conversation messages and allow users to add comments, improving user interaction with support cases.
- Introduced new schemas in the domain for case messages and comments, ensuring robust validation and type safety.
- Refactored Salesforce mappers to transform email messages and case comments into a unified format for display in the portal.
This commit is contained in:
barsa 2025-12-29 18:39:13 +09:00
parent 928ad18d7e
commit fd5336f499
10 changed files with 861 additions and 30 deletions

View File

@ -17,8 +17,12 @@ import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId } from "../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import type { SupportCase } from "@customer-portal/domain/support";
import type { SalesforceCaseRecord } from "@customer-portal/domain/support/providers";
import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support";
import type {
SalesforceCaseRecord,
SalesforceEmailMessage,
SalesforceCaseComment,
} from "@customer-portal/domain/support/providers";
import {
SALESFORCE_CASE_ORIGIN,
SALESFORCE_CASE_STATUS,
@ -30,8 +34,13 @@ import {
buildCaseByIdQuery,
buildCaseSelectFields,
buildCasesForAccountQuery,
buildEmailMessagesForCaseQuery,
buildCaseCommentsForCaseQuery,
transformSalesforceCaseToSupportCase,
transformSalesforceCasesToSupportCases,
transformEmailMessagesToCaseMessages,
transformCaseCommentsToCaseMessages,
mergeAndSortCaseMessages,
} from "@customer-portal/domain/support/providers";
// ============================================================================
@ -299,4 +308,145 @@ export class SalesforceCaseService {
return result.records?.[0] ?? null;
}
// ============================================================================
// Case Messages (Conversation) Methods
// ============================================================================
/**
* Get all messages (EmailMessages + public CaseComments) for a case.
*
* Returns a unified, chronologically sorted conversation thread.
*
* @param caseId - Salesforce Case ID
* @param accountId - Account ID for ownership validation
* @param customerEmail - Customer's email for identifying their messages
* @returns Unified message list with thread ID
*/
async getCaseMessages(
caseId: string,
accountId: string,
customerEmail?: string
): Promise<CaseMessageList> {
const safeCaseId = assertSalesforceId(caseId, "caseId");
const safeAccountId = assertSalesforceId(accountId, "accountId");
this.logger.debug({ caseId: safeCaseId }, "Fetching case messages");
// First verify case belongs to this account
const caseRecord = await this.getCaseById(safeCaseId, safeAccountId);
if (!caseRecord) {
this.logger.debug({ caseId: safeCaseId }, "Case not found or access denied for messages");
return { messages: [], threadId: null };
}
try {
// Fetch EmailMessages and CaseComments in parallel
const [emailsResult, commentsResult] = await Promise.all([
this.sf.query(buildEmailMessagesForCaseQuery(safeCaseId), {
label: "support:getCaseEmailMessages",
}) as Promise<SalesforceResponse<SalesforceEmailMessage>>,
this.sf.query(buildCaseCommentsForCaseQuery(safeCaseId), {
label: "support:getCaseComments",
}) as Promise<SalesforceResponse<SalesforceCaseComment>>,
]);
const emails = emailsResult.records || [];
const comments = commentsResult.records || [];
// Transform to unified CaseMessage format
const emailMessages = transformEmailMessagesToCaseMessages(emails, customerEmail);
const commentMessages = transformCaseCommentsToCaseMessages(comments);
// Merge and sort chronologically
const messages = mergeAndSortCaseMessages(emailMessages, commentMessages);
// Extract thread ID from first email if available
const threadId = emails[0]?.ThreadIdentifier ?? null;
this.logger.debug(
{
caseId: safeCaseId,
emailCount: emails.length,
commentCount: comments.length,
totalMessages: messages.length,
},
"Case messages retrieved"
);
return { messages, threadId };
} catch (error: unknown) {
this.logger.error("Failed to fetch case messages", {
error: extractErrorMessage(error),
caseId: safeCaseId,
});
throw new Error("Failed to fetch case messages");
}
}
/**
* Add a comment to a case (customer reply via portal).
*
* Creates a public CaseComment that will be visible to both
* the customer and support agents.
*
* @param caseId - Salesforce Case ID
* @param accountId - Account ID for ownership validation
* @param body - Comment text
* @returns Created comment ID and timestamp
*/
async addCaseComment(
caseId: string,
accountId: string,
body: string
): Promise<{ id: string; createdAt: string }> {
const safeCaseId = assertSalesforceId(caseId, "caseId");
const safeAccountId = assertSalesforceId(accountId, "accountId");
// First verify case belongs to this account
const caseRecord = await this.getCaseById(safeCaseId, safeAccountId);
if (!caseRecord) {
this.logger.warn(
{ caseId: safeCaseId },
"Attempted to add comment to non-existent/unauthorized case"
);
throw new Error("Case not found");
}
this.logger.log("Adding comment to case", {
caseId: safeCaseId,
bodyLength: body.length,
});
try {
const commentPayload = {
ParentId: safeCaseId,
CommentBody: body.trim(),
IsPublished: true, // Visible to customer
};
const created = (await this.sf.sobject("CaseComment").create(commentPayload)) as {
id?: string;
};
if (!created.id) {
throw new Error("Salesforce did not return a comment ID");
}
const createdAt = new Date().toISOString();
this.logger.log("Case comment created successfully", {
caseId: safeCaseId,
commentId: created.id,
});
return { id: created.id, createdAt };
} catch (error: unknown) {
this.logger.error("Failed to create case comment", {
error: extractErrorMessage(error),
caseId: safeCaseId,
});
throw new Error("Failed to add comment");
}
}
}

View File

@ -21,9 +21,14 @@ import {
createCaseRequestSchema,
createCaseResponseSchema,
publicContactRequestSchema,
caseMessageListSchema,
addCaseCommentRequestSchema,
addCaseCommentResponseSchema,
type SupportCaseList,
type SupportCase,
type CreateCaseResponse,
type CaseMessageList,
type AddCaseCommentResponse,
} from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { hashEmailForLogs } from "./support.logging.js";
@ -36,6 +41,9 @@ class PublicContactRequestDto extends createZodDto(publicContactRequestSchema) {
class SupportCaseListDto extends createZodDto(supportCaseListSchema) {}
class SupportCaseDto extends createZodDto(supportCaseSchema) {}
class CreateCaseResponseDto extends createZodDto(createCaseResponseSchema) {}
class CaseMessageListDto extends createZodDto(caseMessageListSchema) {}
class AddCaseCommentRequestDto extends createZodDto(addCaseCommentRequestSchema) {}
class AddCaseCommentResponseDto extends createZodDto(addCaseCommentResponseSchema) {}
class ActionMessageResponseDto extends createZodDto(actionMessageResponseSchema) {}
@Controller("support")
@ -63,6 +71,36 @@ export class SupportController {
return this.supportService.getCase(req.user.id, caseId);
}
/**
* Get all messages for a case (conversation view)
*
* Returns a unified timeline of EmailMessages and public CaseComments,
* sorted chronologically for displaying a conversation thread.
*/
@Get("cases/:id/messages")
@ZodResponse({ description: "Get case messages", type: CaseMessageListDto })
async getCaseMessages(
@Request() req: RequestWithUser,
@Param("id") caseId: string
): Promise<CaseMessageList> {
return this.supportService.getCaseMessages(req.user.id, caseId, req.user.email);
}
/**
* Add a comment to a case (customer reply via portal)
*
* Creates a public CaseComment visible to both customer and agents.
*/
@Post("cases/:id/comments")
@ZodResponse({ description: "Add case comment", type: AddCaseCommentResponseDto })
async addCaseComment(
@Request() req: RequestWithUser,
@Param("id") caseId: string,
@Body() body: AddCaseCommentRequestDto
): Promise<AddCaseCommentResponse> {
return this.supportService.addCaseComment(req.user.id, caseId, body);
}
@Post("cases")
@ZodResponse({ description: "Create support case", type: CreateCaseResponseDto })
async createCase(

View File

@ -9,6 +9,9 @@ import {
type CreateCaseRequest,
type CreateCaseResponse,
type PublicContactRequest,
type CaseMessageList,
type AddCaseCommentRequest,
type AddCaseCommentResponse,
} from "@customer-portal/domain/support";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
@ -165,6 +168,75 @@ export class SupportService {
}
}
// ============================================================================
// Case Messages (Conversation) Methods
// ============================================================================
/**
* Get all messages for a case (conversation view)
*
* Returns a unified timeline of EmailMessages and public CaseComments.
*
* @param userId - Portal user ID
* @param caseId - Salesforce Case ID
* @param customerEmail - Customer's email for identifying their messages
*/
async getCaseMessages(
userId: string,
caseId: string,
customerEmail?: string
): Promise<CaseMessageList> {
const accountId = await this.getAccountIdForUser(userId);
try {
const messages = await this.caseService.getCaseMessages(caseId, accountId, customerEmail);
return messages;
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error("Failed to get case messages", {
userId,
caseId,
error: extractErrorMessage(error),
});
throw error;
}
}
/**
* Add a comment to a case (customer reply via portal)
*
* Creates a public CaseComment visible to both customer and agents.
*/
async addCaseComment(
userId: string,
caseId: string,
request: AddCaseCommentRequest
): Promise<AddCaseCommentResponse> {
const accountId = await this.getAccountIdForUser(userId);
try {
const result = await this.caseService.addCaseComment(caseId, accountId, request.body);
this.logger.log("Case comment added", {
userId,
caseId,
commentId: result.id,
});
return result;
} catch (error) {
this.logger.error("Failed to add case comment", {
userId,
caseId,
error: extractErrorMessage(error),
});
throw error;
}
}
/**
* Get Salesforce account ID for a user
*/

View File

@ -0,0 +1,37 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, getDataOrThrow, queryKeys } from "@/core/api";
import type {
AddCaseCommentRequest,
AddCaseCommentResponse,
} from "@customer-portal/domain/support";
/**
* Hook to add a comment to a case (customer reply via portal)
*
* Creates a public CaseComment visible to both customer and agents.
*/
export function useAddCaseComment(caseId: string) {
const queryClient = useQueryClient();
return useMutation<AddCaseCommentResponse, Error, AddCaseCommentRequest>({
mutationFn: async request => {
const response = await apiClient.POST<AddCaseCommentResponse>(
`/api/support/cases/${caseId}/comments`,
{ body: request }
);
return getDataOrThrow(response, "Failed to add comment");
},
onSuccess: () => {
// Invalidate the messages query to refetch the conversation
void queryClient.invalidateQueries({
queryKey: [...queryKeys.support.case(caseId), "messages"],
});
// Also invalidate the case itself as it may have updated
void queryClient.invalidateQueries({
queryKey: queryKeys.support.case(caseId),
});
},
});
}

View File

@ -0,0 +1,29 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuthSession } from "@/features/auth/stores/auth.store";
import { apiClient, getDataOrThrow, queryKeys } from "@/core/api";
import type { CaseMessageList } from "@customer-portal/domain/support";
/**
* Hook to fetch messages for a case (conversation view)
*
* Returns a unified timeline of EmailMessages and public CaseComments.
*/
export function useCaseMessages(caseId: string | undefined) {
const { isAuthenticated } = useAuthSession();
return useQuery<CaseMessageList>({
queryKey: [...queryKeys.support.case(caseId ?? ""), "messages"],
queryFn: async () => {
const response = await apiClient.GET<CaseMessageList>(
`/api/support/cases/${caseId}/messages`
);
return getDataOrThrow(response, "Failed to load case messages");
},
enabled: isAuthenticated && !!caseId,
// Refetch more frequently for active conversations
refetchInterval: 30000, // 30 seconds
staleTime: 10000, // 10 seconds
});
}

View File

@ -1,17 +1,30 @@
"use client";
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import {
CalendarIcon,
ClockIcon,
TagIcon,
ArrowLeftIcon,
PaperAirplaneIcon,
EnvelopeIcon,
ChatBubbleLeftIcon,
} from "@heroicons/react/24/outline";
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
import { PageLayout } from "@/components/templates/PageLayout";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { Button, Spinner } from "@/components/atoms";
import { useSupportCase } from "@/features/support/hooks/useSupportCase";
import { useCaseMessages } from "@/features/support/hooks/useCaseMessages";
import { useAddCaseComment } from "@/features/support/hooks/useAddCaseComment";
import {
getCaseStatusIcon,
getCaseStatusClasses,
getCasePriorityClasses,
} from "@/features/support/utils";
import { formatIsoDate, formatIsoRelative } from "@/shared/utils";
import type { CaseMessage } from "@customer-portal/domain/support";
import { CLOSED_STATUSES } from "@customer-portal/domain/support";
interface SupportCaseDetailViewProps {
caseId: string;
@ -19,11 +32,36 @@ interface SupportCaseDetailViewProps {
export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
const { data: supportCase, isLoading, error, refetch } = useSupportCase(caseId);
const {
data: messagesData,
isLoading: messagesLoading,
refetch: refetchMessages,
} = useCaseMessages(caseId);
const addCommentMutation = useAddCaseComment(caseId);
const [replyText, setReplyText] = useState("");
const pageError =
error && process.env.NODE_ENV !== "development"
? "Unable to load this case right now. Please try again."
: error;
const isCaseClosed = supportCase
? CLOSED_STATUSES.includes(supportCase.status as (typeof CLOSED_STATUSES)[number])
: false;
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyText.trim() || addCommentMutation.isPending) return;
try {
await addCommentMutation.mutateAsync({ body: replyText.trim() });
setReplyText("");
} catch {
// Error is handled by the mutation
}
};
if (!isLoading && !supportCase && !error) {
return (
<PageLayout
@ -124,40 +162,158 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
</div>
</div>
{/* Description */}
{/* Conversation Section */}
<div className="border border-border rounded-xl bg-card overflow-hidden shadow-[var(--cp-shadow-1)]">
<div className="px-5 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">Description</h3>
<div className="px-5 py-3 border-b border-border flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Conversation</h3>
<button
type="button"
onClick={() => void refetchMessages()}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Refresh
</button>
</div>
<div className="p-5">
<div className="prose prose-sm max-w-none text-muted-foreground">
<p className="whitespace-pre-wrap leading-relaxed m-0">{supportCase.description}</p>
</div>
</div>
</div>
{/* Help Text */}
<div className="rounded-lg bg-info-soft border border-info/25 px-4 py-3">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-info" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clipRule="evenodd"
<div className="p-5">
{messagesLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : messagesData?.messages && messagesData.messages.length > 0 ? (
<div className="space-y-4">
{/* Original Description as first message */}
<MessageBubble
message={{
id: "description",
type: "comment",
body: supportCase.description,
author: { name: "You", email: null, isCustomer: true },
createdAt: supportCase.createdAt,
direction: null,
}}
/>
</svg>
{/* Conversation messages */}
{messagesData.messages.map(message => (
<MessageBubble key={message.id} message={message} />
))}
</div>
) : (
<div className="space-y-4">
{/* Show description as the only message if no conversation yet */}
<MessageBubble
message={{
id: "description",
type: "comment",
body: supportCase.description,
author: { name: "You", email: null, isCustomer: true },
createdAt: supportCase.createdAt,
direction: null,
}}
/>
<p className="text-center text-sm text-muted-foreground py-4">
No replies yet. Our team will respond shortly.
</p>
</div>
)}
</div>
{/* Reply Form */}
{!isCaseClosed && (
<div className="px-5 py-4 border-t border-border bg-muted/20">
<form onSubmit={handleSubmitReply}>
<div className="space-y-3">
<textarea
value={replyText}
onChange={e => setReplyText(e.target.value)}
placeholder="Type your reply..."
rows={3}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none"
disabled={addCommentMutation.isPending}
/>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Your reply will be visible to our support team.
</p>
<Button
type="submit"
disabled={!replyText.trim() || addCommentMutation.isPending}
leftIcon={
addCommentMutation.isPending ? (
<Spinner size="sm" />
) : (
<PaperAirplaneIcon className="h-4 w-4" />
)
}
>
{addCommentMutation.isPending ? "Sending..." : "Send Reply"}
</Button>
</div>
{addCommentMutation.isError && (
<p className="text-sm text-destructive">
Failed to send reply. Please try again.
</p>
)}
</div>
</form>
</div>
<div className="text-sm text-muted-foreground">
<p className="font-medium">Need to update this case?</p>
<p className="mt-0.5 text-foreground/80">
Reply via email and your response will be added to this case automatically.
)}
{/* Closed case notice */}
{isCaseClosed && (
<div className="px-5 py-4 border-t border-border bg-muted/20">
<p className="text-sm text-muted-foreground text-center">
This case is closed. If you need further assistance, please{" "}
<a href="/account/support/new" className="text-primary hover:underline">
create a new case
</a>
.
</p>
</div>
</div>
)}
</div>
</div>
)}
</PageLayout>
);
}
/**
* Message bubble component for displaying individual messages
*/
function MessageBubble({ message }: { message: CaseMessage }) {
const isCustomer = message.author.isCustomer;
const isEmail = message.type === "email";
return (
<div className={`flex ${isCustomer ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 ${
isCustomer
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted text-foreground rounded-bl-md"
}`}
>
{/* Author and type indicator */}
<div
className={`flex items-center gap-2 mb-1 text-xs ${
isCustomer ? "text-primary-foreground/70" : "text-muted-foreground"
}`}
>
{isEmail ? (
<EnvelopeIcon className="h-3 w-3" />
) : (
<ChatBubbleLeftIcon className="h-3 w-3" />
)}
<span className="font-medium">{message.author.name}</span>
<span></span>
<span>{formatIsoRelative(message.createdAt)}</span>
</div>
{/* Message body */}
<p className="whitespace-pre-wrap text-sm leading-relaxed">{message.body}</p>
</div>
</div>
);
}

View File

@ -14,6 +14,8 @@ export {
SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_CATEGORY,
PORTAL_CASE_ORIGIN,
CLOSED_STATUSES,
OPEN_STATUSES,
} from "./contract.js";
// Schemas (includes derived types)

View File

@ -4,8 +4,17 @@
* Transform functions to convert raw Salesforce Case records to domain types.
*/
import { supportCaseSchema, type SupportCase } from "../../schema.js";
import type { SalesforceCaseRecord } from "./raw.types.js";
import {
supportCaseSchema,
caseMessageSchema,
type SupportCase,
type CaseMessage,
} from "../../schema.js";
import type {
SalesforceCaseRecord,
SalesforceEmailMessage,
SalesforceCaseComment,
} from "./raw.types.js";
import {
getStatusDisplayLabel,
getPriorityDisplayLabel,
@ -165,3 +174,186 @@ export function buildCaseByIdQuery(
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.
*
* @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);
return caseMessageSchema.parse({
id: email.Id,
type: "email",
body: ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? "",
author: {
name: fromName,
email: fromEmail ?? null,
isCustomer,
},
createdAt: ensureString(email.MessageDate) ?? ensureString(email.CreatedDate) ?? nowIsoString(),
direction: isIncoming ? "inbound" : "outbound",
});
}
/**
* 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;
});
}

View File

@ -137,6 +137,90 @@ export const salesforceCaseRecordSchema = z.object({
export type SalesforceCaseRecord = z.infer<typeof salesforceCaseRecordSchema>;
// ============================================================================
// Salesforce EmailMessage Record (for case email conversations)
// ============================================================================
/**
* Raw Salesforce EmailMessage record schema
*
* Represents email messages attached to a Case.
* Used for displaying email conversation history.
*/
export const salesforceEmailMessageSchema = z.object({
Id: z.string(),
ParentId: z.string(), // Case ID
// Email content
Subject: z.string().nullable().optional(),
TextBody: z.string().nullable().optional(),
HtmlBody: z.string().nullable().optional(),
// Sender/recipient
FromAddress: z.string().nullable().optional(),
FromName: z.string().nullable().optional(),
ToAddress: z.string().nullable().optional(),
ToName: z.string().nullable().optional(),
CcAddress: z.string().nullable().optional(),
BccAddress: z.string().nullable().optional(),
// Direction and status
Incoming: z.boolean().nullable().optional(), // true = customer sent, false = agent sent
Status: z.string().nullable().optional(), // 0=New, 1=Read, 2=Replied, 3=Sent, 4=Forwarded, 5=Draft
// Threading
ThreadIdentifier: z.string().nullable().optional(),
MessageIdentifier: z.string().nullable().optional(),
// Timestamps
MessageDate: z.string().nullable().optional(),
CreatedDate: z.string().nullable().optional(),
// Flags
HasAttachment: z.boolean().nullable().optional(),
IsExternallyVisible: z.boolean().nullable().optional(),
});
export type SalesforceEmailMessage = z.infer<typeof salesforceEmailMessageSchema>;
// ============================================================================
// Salesforce CaseComment Record (for case comments)
// ============================================================================
/**
* Raw Salesforce CaseComment record schema
*
* Represents comments added to a Case.
* Used for displaying comment conversation history.
*/
export const salesforceCaseCommentSchema = z.object({
Id: z.string(),
ParentId: z.string(), // Case ID
// Comment content
CommentBody: z.string().nullable().optional(),
// Visibility
IsPublished: z.boolean().nullable().optional(), // true = visible to customer
// Author
CreatedById: z.string().nullable().optional(),
CreatedBy: z
.object({
Id: z.string().optional(),
Name: z.string().nullable().optional(),
Email: z.string().nullable().optional(),
})
.nullable()
.optional(),
// Timestamps
CreatedDate: z.string().nullable().optional(),
LastModifiedDate: z.string().nullable().optional(),
});
export type SalesforceCaseComment = z.infer<typeof salesforceCaseCommentSchema>;
// ============================================================================
// Salesforce Case Create Payload
// ============================================================================

View File

@ -101,6 +101,71 @@ export const publicContactRequestSchema = z.object({
message: z.string().min(10, "Message must be at least 10 characters"),
});
// ============================================================================
// Case Message Schemas (for conversation view)
// ============================================================================
/**
* Message type - either from email exchange or case comment
*/
export const caseMessageTypeSchema = z.enum(["email", "comment"]);
/**
* Message direction for emails
*/
export const caseMessageDirectionSchema = z.enum(["inbound", "outbound"]);
/**
* Unified case message schema - represents either an EmailMessage or CaseComment
* Used for displaying conversation threads in the portal
*/
export const caseMessageSchema = z.object({
/** Unique identifier (EmailMessage.Id or CaseComment.Id) */
id: z.string(),
/** Message type: email or comment */
type: caseMessageTypeSchema,
/** Message body/content */
body: z.string(),
/** Who sent/wrote the message */
author: z.object({
name: z.string(),
email: z.string().nullable(),
isCustomer: z.boolean(),
}),
/** When the message was created/sent */
createdAt: z.string(),
/** For emails: inbound (customer→agent) or outbound (agent→customer) */
direction: caseMessageDirectionSchema.nullable(),
});
/**
* List of case messages for conversation view
*/
export const caseMessageListSchema = z.object({
messages: z.array(caseMessageSchema),
/** Case thread identifier for email threading */
threadId: z.string().nullable(),
});
/**
* Request schema for adding a comment to a case
*/
export const addCaseCommentRequestSchema = z.object({
body: z.string().min(1, "Message is required").max(32000),
});
/**
* Response schema for adding a comment
*/
export const addCaseCommentResponseSchema = z.object({
id: z.string(),
createdAt: z.string(),
});
// ============================================================================
// Type Exports
// ============================================================================
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
@ -111,3 +176,9 @@ export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;
export type CreateCaseRequest = z.infer<typeof createCaseRequestSchema>;
export type CreateCaseResponse = z.infer<typeof createCaseResponseSchema>;
export type PublicContactRequest = z.infer<typeof publicContactRequestSchema>;
export type CaseMessageType = z.infer<typeof caseMessageTypeSchema>;
export type CaseMessageDirection = z.infer<typeof caseMessageDirectionSchema>;
export type CaseMessage = z.infer<typeof caseMessageSchema>;
export type CaseMessageList = z.infer<typeof caseMessageListSchema>;
export type AddCaseCommentRequest = z.infer<typeof addCaseCommentRequestSchema>;
export type AddCaseCommentResponse = z.infer<typeof addCaseCommentResponseSchema>;