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:
parent
928ad18d7e
commit
fd5336f499
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
37
apps/portal/src/features/support/hooks/useAddCaseComment.ts
Normal file
37
apps/portal/src/features/support/hooks/useAddCaseComment.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
29
apps/portal/src/features/support/hooks/useCaseMessages.ts
Normal file
29
apps/portal/src/features/support/hooks/useCaseMessages.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user