import { Injectable, Inject, NotFoundException, ForbiddenException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SUPPORT_CASE_PRIORITY, SUPPORT_CASE_STATUS, type SupportCase, type SupportCaseFilter, type SupportCaseList, 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"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SupportCacheService } from "./support-cache.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { hashEmailForLogs } from "@bff/core/logging/redaction.util.js"; /** * Status values that indicate an open/active case * (Display values after mapping from Salesforce API names) */ const OPEN_STATUSES: string[] = [ SUPPORT_CASE_STATUS.NEW, SUPPORT_CASE_STATUS.IN_PROGRESS, SUPPORT_CASE_STATUS.AWAITING_CUSTOMER, ]; /** * Status values that indicate a resolved/closed case * (Display values after mapping from Salesforce API names) */ const RESOLVED_STATUSES: string[] = [SUPPORT_CASE_STATUS.CLOSED]; /** * Priority values that indicate high priority */ const HIGH_PRIORITIES: string[] = [SUPPORT_CASE_PRIORITY.HIGH]; @Injectable() export class SupportService { constructor( private readonly caseService: SalesforceCaseService, private readonly mappingsService: MappingsService, private readonly cacheService: SupportCacheService, @Inject(Logger) private readonly logger: Logger ) {} /** * List cases for a user with optional filters * * Uses Redis caching with 2-minute TTL to reduce Salesforce API calls. * Cache is invalidated when customer creates a new case. */ async listCases(userId: string, filters?: SupportCaseFilter): Promise { const accountId = await this.getAccountIdForUser(userId); try { // 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 }; }); // 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 }; } return caseList; } catch (error) { this.logger.error("Failed to list support cases", { userId, error: extractErrorMessage(error), }); throw error; } } /** * Get a single case by ID */ async getCase(userId: string, caseId: string): Promise { 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, error: extractErrorMessage(error), }); throw error; } } /** * Create a new support case * * Invalidates case list cache after successful creation. */ async createCase(userId: string, request: CreateCaseRequest): Promise { const accountId = await this.getAccountIdForUser(userId); try { const result = await this.caseService.createCase({ subject: request.subject, description: request.description, ...(request.priority === undefined ? {} : { priority: request.priority }), accountId, origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT, }); // Invalidate cache so new case appears immediately await this.cacheService.invalidateCaseList(accountId); 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, error: extractErrorMessage(error), }); throw error; } } /** * Create a contact request from public form (no authentication required) * Creates a Web-to-Case in Salesforce or sends an email notification */ async createPublicContactRequest(request: PublicContactRequest): Promise { const emailHash = hashEmailForLogs(request.email); this.logger.log("Creating public contact request", { emailHash }); try { // Create a case without account association (Web-to-Case style) await this.caseService.createWebCase({ subject: `Contact from ${request.name}`, description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`, suppliedEmail: request.email, suppliedName: request.name, ...(request.phone === undefined ? {} : { suppliedPhone: request.phone }), origin: "Web", priority: "Medium", }); this.logger.log("Public contact request created successfully", { emailHash, }); } catch (error) { this.logger.error("Failed to create public contact request", { error: extractErrorMessage(error), emailHash, }); // Don't throw - we don't want to expose internal errors to public users // In production, this should send a fallback email notification } } // ============================================================================ // Case Messages (Conversation) Methods // ============================================================================ /** * Get all messages for a case (conversation view) * * Returns a unified timeline of EmailMessages and public CaseComments. * Uses Redis caching with 1-minute TTL for active conversations. * Cache is invalidated when customer adds a comment. * * @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 { // 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); }); 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. * Invalidates messages cache after successful comment so it appears immediately. */ 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); // Invalidate caches so new comment appears immediately await this.cacheService.invalidateAllForAccount(accountId, caseId); 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 */ private async getAccountIdForUser(userId: string): Promise { 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; } const search = filters.search?.toLowerCase().trim(); 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) { const haystack = `${supportCase.subject} ${supportCase.description} ${supportCase.caseNumber}`.toLowerCase(); if (!haystack.includes(search)) { return false; } } return true; }); } /** * Build summary statistics for cases */ 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, }; } }