2025-11-26 16:36:06 +09:00
|
|
|
import { Injectable, Inject, NotFoundException, ForbiddenException } from "@nestjs/common";
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
2025-11-18 14:06:27 +09:00
|
|
|
import {
|
|
|
|
|
SUPPORT_CASE_PRIORITY,
|
|
|
|
|
SUPPORT_CASE_STATUS,
|
|
|
|
|
type SupportCase,
|
|
|
|
|
type SupportCaseFilter,
|
|
|
|
|
type SupportCaseList,
|
2025-11-26 16:36:06 +09:00
|
|
|
type CreateCaseRequest,
|
|
|
|
|
type CreateCaseResponse,
|
2025-12-25 19:01:00 +09:00
|
|
|
type PublicContactRequest,
|
2025-12-29 18:39:13 +09:00
|
|
|
type CaseMessageList,
|
|
|
|
|
type AddCaseCommentRequest,
|
|
|
|
|
type AddCaseCommentResponse,
|
2025-11-18 14:06:27 +09:00
|
|
|
} from "@customer-portal/domain/support";
|
2025-12-29 16:53:32 +09:00
|
|
|
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
2025-12-10 16:08:34 +09:00
|
|
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
|
|
|
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
2026-01-05 15:11:56 +09:00
|
|
|
import { SupportCacheService } from "./support-cache.service.js";
|
2025-12-29 15:07:11 +09:00
|
|
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
2026-02-24 11:57:58 +09:00
|
|
|
import { hashEmailForLogs } from "@bff/core/logging/redaction.util.js";
|
2025-11-18 14:06:27 +09:00
|
|
|
|
2025-11-26 16:36:06 +09:00
|
|
|
/**
|
|
|
|
|
* Status values that indicate an open/active case
|
2026-01-05 15:11:56 +09:00
|
|
|
* (Display values after mapping from Salesforce API names)
|
2025-11-26 16:36:06 +09:00
|
|
|
*/
|
|
|
|
|
const OPEN_STATUSES: string[] = [
|
|
|
|
|
SUPPORT_CASE_STATUS.NEW,
|
2025-11-18 14:06:27 +09:00
|
|
|
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
2026-01-05 15:11:56 +09:00
|
|
|
SUPPORT_CASE_STATUS.AWAITING_CUSTOMER,
|
2025-11-18 14:06:27 +09:00
|
|
|
];
|
|
|
|
|
|
2025-11-26 16:36:06 +09:00
|
|
|
/**
|
|
|
|
|
* Status values that indicate a resolved/closed case
|
2026-01-05 15:11:56 +09:00
|
|
|
* (Display values after mapping from Salesforce API names)
|
2025-11-26 16:36:06 +09:00
|
|
|
*/
|
2026-01-05 15:11:56 +09:00
|
|
|
const RESOLVED_STATUSES: string[] = [SUPPORT_CASE_STATUS.CLOSED];
|
2025-11-18 14:06:27 +09:00
|
|
|
|
2025-11-26 16:36:06 +09:00
|
|
|
/**
|
|
|
|
|
* Priority values that indicate high priority
|
|
|
|
|
*/
|
|
|
|
|
const HIGH_PRIORITIES: string[] = [SUPPORT_CASE_PRIORITY.HIGH];
|
2025-11-18 14:06:27 +09:00
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class SupportService {
|
2025-11-26 16:36:06 +09:00
|
|
|
constructor(
|
|
|
|
|
private readonly caseService: SalesforceCaseService,
|
|
|
|
|
private readonly mappingsService: MappingsService,
|
2026-01-05 15:11:56 +09:00
|
|
|
private readonly cacheService: SupportCacheService,
|
2025-11-26 16:36:06 +09:00
|
|
|
@Inject(Logger) private readonly logger: Logger
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List cases for a user with optional filters
|
2026-01-05 15:11:56 +09:00
|
|
|
*
|
|
|
|
|
* Uses Redis caching with 2-minute TTL to reduce Salesforce API calls.
|
|
|
|
|
* Cache is invalidated when customer creates a new case.
|
2025-11-26 16:36:06 +09:00
|
|
|
*/
|
|
|
|
|
async listCases(userId: string, filters?: SupportCaseFilter): Promise<SupportCaseList> {
|
|
|
|
|
const accountId = await this.getAccountIdForUser(userId);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-05 15:11:56 +09:00
|
|
|
// Use cache with TTL (no CDC events for cases)
|
|
|
|
|
const caseList = await this.cacheService.getCaseList(accountId, async () => {
|
|
|
|
|
const cases = await this.caseService.getCasesForAccount(accountId);
|
|
|
|
|
const summary = this.buildSummary(cases);
|
|
|
|
|
return { cases, summary };
|
|
|
|
|
});
|
2025-11-26 16:36:06 +09:00
|
|
|
|
2026-01-05 15:11:56 +09:00
|
|
|
// Apply filters after cache (filters are user-specific, cache is account-level)
|
|
|
|
|
if (filters && Object.keys(filters).length > 0) {
|
|
|
|
|
const filteredCases = this.applyFilters(caseList.cases, filters);
|
|
|
|
|
const summary = this.buildSummary(filteredCases);
|
|
|
|
|
return { cases: filteredCases, summary };
|
|
|
|
|
}
|
2025-11-26 16:36:06 +09:00
|
|
|
|
2026-01-05 15:11:56 +09:00
|
|
|
return caseList;
|
2025-11-26 16:36:06 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Failed to list support cases", {
|
|
|
|
|
userId,
|
2025-12-29 15:07:11 +09:00
|
|
|
error: extractErrorMessage(error),
|
2025-11-26 16:36:06 +09:00
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a single case by ID
|
|
|
|
|
*/
|
|
|
|
|
async getCase(userId: string, caseId: string): Promise<SupportCase> {
|
|
|
|
|
const accountId = await this.getAccountIdForUser(userId);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// SalesforceCaseService now returns SupportCase directly using domain mappers
|
|
|
|
|
const supportCase = await this.caseService.getCaseById(caseId, accountId);
|
|
|
|
|
|
|
|
|
|
if (!supportCase) {
|
|
|
|
|
throw new NotFoundException("Support case not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return supportCase;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof NotFoundException) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
this.logger.error("Failed to get support case", {
|
|
|
|
|
userId,
|
|
|
|
|
caseId,
|
2025-12-29 15:07:11 +09:00
|
|
|
error: extractErrorMessage(error),
|
2025-11-26 16:36:06 +09:00
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a new support case
|
2026-01-05 15:11:56 +09:00
|
|
|
*
|
|
|
|
|
* Invalidates case list cache after successful creation.
|
2025-11-26 16:36:06 +09:00
|
|
|
*/
|
|
|
|
|
async createCase(userId: string, request: CreateCaseRequest): Promise<CreateCaseResponse> {
|
|
|
|
|
const accountId = await this.getAccountIdForUser(userId);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await this.caseService.createCase({
|
|
|
|
|
subject: request.subject,
|
|
|
|
|
description: request.description,
|
2026-01-15 11:28:25 +09:00
|
|
|
...(request.priority === undefined ? {} : { priority: request.priority }),
|
2025-11-26 16:36:06 +09:00
|
|
|
accountId,
|
2025-12-29 16:53:32 +09:00
|
|
|
origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT,
|
2025-11-26 16:36:06 +09:00
|
|
|
});
|
|
|
|
|
|
2026-01-05 15:11:56 +09:00
|
|
|
// Invalidate cache so new case appears immediately
|
|
|
|
|
await this.cacheService.invalidateCaseList(accountId);
|
|
|
|
|
|
2025-11-26 16:36:06 +09:00
|
|
|
this.logger.log("Support case created", {
|
|
|
|
|
userId,
|
|
|
|
|
caseId: result.id,
|
|
|
|
|
caseNumber: result.caseNumber,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Failed to create support case", {
|
|
|
|
|
userId,
|
2025-12-29 15:07:11 +09:00
|
|
|
error: extractErrorMessage(error),
|
2025-11-26 16:36:06 +09:00
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
2025-11-18 14:06:27 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-17 14:07:22 +09:00
|
|
|
/**
|
|
|
|
|
* Create a contact request from public form (no authentication required)
|
|
|
|
|
* Creates a Web-to-Case in Salesforce or sends an email notification
|
|
|
|
|
*/
|
2025-12-25 19:01:00 +09:00
|
|
|
async createPublicContactRequest(request: PublicContactRequest): Promise<void> {
|
|
|
|
|
const emailHash = hashEmailForLogs(request.email);
|
|
|
|
|
this.logger.log("Creating public contact request", { emailHash });
|
2025-12-17 14:07:22 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Create a case without account association (Web-to-Case style)
|
|
|
|
|
await this.caseService.createWebCase({
|
2026-03-04 16:16:14 +09:00
|
|
|
subject: `Contact from ${request.name}`,
|
2025-12-17 14:07:22 +09:00
|
|
|
description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`,
|
|
|
|
|
suppliedEmail: request.email,
|
|
|
|
|
suppliedName: request.name,
|
2026-01-15 11:28:25 +09:00
|
|
|
...(request.phone === undefined ? {} : { suppliedPhone: request.phone }),
|
2025-12-17 14:07:22 +09:00
|
|
|
origin: "Web",
|
|
|
|
|
priority: "Medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.logger.log("Public contact request created successfully", {
|
2025-12-25 19:01:00 +09:00
|
|
|
emailHash,
|
2025-12-17 14:07:22 +09:00
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Failed to create public contact request", {
|
2025-12-29 15:07:11 +09:00
|
|
|
error: extractErrorMessage(error),
|
2025-12-25 19:01:00 +09:00
|
|
|
emailHash,
|
2025-12-17 14:07:22 +09:00
|
|
|
});
|
|
|
|
|
// Don't throw - we don't want to expose internal errors to public users
|
|
|
|
|
// In production, this should send a fallback email notification
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 18:39:13 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// Case Messages (Conversation) Methods
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all messages for a case (conversation view)
|
|
|
|
|
*
|
|
|
|
|
* Returns a unified timeline of EmailMessages and public CaseComments.
|
2026-01-05 15:11:56 +09:00
|
|
|
* Uses Redis caching with 1-minute TTL for active conversations.
|
|
|
|
|
* Cache is invalidated when customer adds a comment.
|
2025-12-29 18:39:13 +09:00
|
|
|
*
|
|
|
|
|
* @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 {
|
2026-01-05 15:11:56 +09:00
|
|
|
// Use cache with short TTL for messages (fresher for active conversations)
|
|
|
|
|
const messages = await this.cacheService.getCaseMessages(caseId, async () => {
|
|
|
|
|
return this.caseService.getCaseMessages(caseId, accountId, customerEmail);
|
|
|
|
|
});
|
2025-12-29 18:39:13 +09:00
|
|
|
|
|
|
|
|
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.
|
2026-01-05 15:11:56 +09:00
|
|
|
* Invalidates messages cache after successful comment so it appears immediately.
|
2025-12-29 18:39:13 +09:00
|
|
|
*/
|
|
|
|
|
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);
|
|
|
|
|
|
2026-01-05 15:11:56 +09:00
|
|
|
// Invalidate caches so new comment appears immediately
|
|
|
|
|
await this.cacheService.invalidateAllForAccount(accountId, caseId);
|
|
|
|
|
|
2025-12-29 18:39:13 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 16:36:06 +09:00
|
|
|
/**
|
|
|
|
|
* Get Salesforce account ID for a user
|
|
|
|
|
*/
|
|
|
|
|
private async getAccountIdForUser(userId: string): Promise<string> {
|
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
|
|
|
|
|
|
|
|
if (!mapping?.sfAccountId) {
|
|
|
|
|
this.logger.warn("No Salesforce account mapping found for user", { userId });
|
|
|
|
|
throw new ForbiddenException("Account not linked to Salesforce");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mapping.sfAccountId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Apply filters to cases
|
|
|
|
|
*/
|
|
|
|
|
private applyFilters(cases: SupportCase[], filters?: SupportCaseFilter): SupportCase[] {
|
|
|
|
|
if (!filters) {
|
|
|
|
|
return cases;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 14:06:27 +09:00
|
|
|
const search = filters.search?.toLowerCase().trim();
|
2025-11-26 16:36:06 +09:00
|
|
|
|
2025-11-18 14:06:27 +09:00
|
|
|
return cases.filter(supportCase => {
|
|
|
|
|
if (filters.status && supportCase.status !== filters.status) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (filters.priority && supportCase.priority !== filters.priority) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (filters.category && supportCase.category !== filters.category) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (search) {
|
2025-11-26 16:36:06 +09:00
|
|
|
const haystack =
|
|
|
|
|
`${supportCase.subject} ${supportCase.description} ${supportCase.caseNumber}`.toLowerCase();
|
2025-11-18 14:06:27 +09:00
|
|
|
if (!haystack.includes(search)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 16:36:06 +09:00
|
|
|
/**
|
|
|
|
|
* Build summary statistics for cases
|
|
|
|
|
*/
|
2025-11-18 14:06:27 +09:00
|
|
|
private buildSummary(cases: SupportCase[]): SupportCaseList["summary"] {
|
|
|
|
|
const open = cases.filter(c => OPEN_STATUSES.includes(c.status)).length;
|
|
|
|
|
const highPriority = cases.filter(c => HIGH_PRIORITIES.includes(c.priority)).length;
|
|
|
|
|
const resolved = cases.filter(c => RESOLVED_STATUSES.includes(c.status)).length;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
total: cases.length,
|
|
|
|
|
open,
|
|
|
|
|
highPriority,
|
|
|
|
|
resolved,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|