From fd5336f4997efbba9d094f604e786cd2180891b8 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 29 Dec 2025 18:39:13 +0900 Subject: [PATCH] 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. --- .../services/salesforce-case.service.ts | 154 ++++++++++++- .../src/modules/support/support.controller.ts | 38 ++++ .../src/modules/support/support.service.ts | 72 ++++++ .../support/hooks/useAddCaseComment.ts | 37 ++++ .../features/support/hooks/useCaseMessages.ts | 29 +++ .../support/views/SupportCaseDetailView.tsx | 208 +++++++++++++++--- packages/domain/support/index.ts | 2 + .../support/providers/salesforce/mapper.ts | 196 ++++++++++++++++- .../support/providers/salesforce/raw.types.ts | 84 +++++++ packages/domain/support/schema.ts | 71 ++++++ 10 files changed, 861 insertions(+), 30 deletions(-) create mode 100644 apps/portal/src/features/support/hooks/useAddCaseComment.ts create mode 100644 apps/portal/src/features/support/hooks/useCaseMessages.ts diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index 9d08fef4..e1a1abdb 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -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 { + 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>, + this.sf.query(buildCaseCommentsForCaseQuery(safeCaseId), { + label: "support:getCaseComments", + }) as Promise>, + ]); + + 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"); + } + } } diff --git a/apps/bff/src/modules/support/support.controller.ts b/apps/bff/src/modules/support/support.controller.ts index 4e80f146..dea2e064 100644 --- a/apps/bff/src/modules/support/support.controller.ts +++ b/apps/bff/src/modules/support/support.controller.ts @@ -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 { + 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 { + return this.supportService.addCaseComment(req.user.id, caseId, body); + } + @Post("cases") @ZodResponse({ description: "Create support case", type: CreateCaseResponseDto }) async createCase( diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts index 33c28252..f89fee60 100644 --- a/apps/bff/src/modules/support/support.service.ts +++ b/apps/bff/src/modules/support/support.service.ts @@ -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 { + 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 { + 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 */ diff --git a/apps/portal/src/features/support/hooks/useAddCaseComment.ts b/apps/portal/src/features/support/hooks/useAddCaseComment.ts new file mode 100644 index 00000000..cfd863b4 --- /dev/null +++ b/apps/portal/src/features/support/hooks/useAddCaseComment.ts @@ -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({ + mutationFn: async request => { + const response = await apiClient.POST( + `/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), + }); + }, + }); +} diff --git a/apps/portal/src/features/support/hooks/useCaseMessages.ts b/apps/portal/src/features/support/hooks/useCaseMessages.ts new file mode 100644 index 00000000..80c69633 --- /dev/null +++ b/apps/portal/src/features/support/hooks/useCaseMessages.ts @@ -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({ + queryKey: [...queryKeys.support.case(caseId ?? ""), "messages"], + queryFn: async () => { + const response = await apiClient.GET( + `/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 + }); +} diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx index 4d96e8ff..0e8628e5 100644 --- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx +++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx @@ -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 ( - {/* Description */} + {/* Conversation Section */}
-
-

Description

+
+

Conversation

+
-
-
-

{supportCase.description}

-
-
-
- {/* Help Text */} -
-
-
- - + {messagesLoading ? ( +
+ +
+ ) : messagesData?.messages && messagesData.messages.length > 0 ? ( +
+ {/* Original Description as first message */} + - + + {/* Conversation messages */} + {messagesData.messages.map(message => ( + + ))} +
+ ) : ( +
+ {/* Show description as the only message if no conversation yet */} + +

+ No replies yet. Our team will respond shortly. +

+
+ )} +
+ + {/* Reply Form */} + {!isCaseClosed && ( +
+
+
+