Enhance Salesforce Case Integration and Update Eligibility Logic
- Added new fields for notes and case ID in the ServicesCdcSubscriber to improve eligibility detail tracking. - Refactored the SalesforceCaseService to unify case creation for various case types, including customer support and internal workflow cases. - Updated InternetServicesService to create internal workflow cases for eligibility checks, enhancing tracking and management. - Improved error handling and logging consistency across services when creating cases. - Cleaned up unused imports and optimized code structure for better maintainability.
This commit is contained in:
parent
a55967a31f
commit
a938c605c7
@ -273,6 +273,8 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
|
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
|
||||||
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
|
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
|
||||||
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
|
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
|
||||||
|
const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]);
|
||||||
|
const caseId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]);
|
||||||
const requestedAt = this.extractStringField(payload, [
|
const requestedAt = this.extractStringField(payload, [
|
||||||
"Internet_Eligibility_Request_Date_Time__c",
|
"Internet_Eligibility_Request_Date_Time__c",
|
||||||
]);
|
]);
|
||||||
@ -280,8 +282,7 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
"Internet_Eligibility_Checked_Date_Time__c",
|
"Internet_Eligibility_Checked_Date_Time__c",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Note: Request ID field is not used in this environment
|
const requestId = caseId;
|
||||||
const requestId = undefined;
|
|
||||||
|
|
||||||
// Also extract ID verification fields for notifications
|
// Also extract ID verification fields for notifications
|
||||||
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
|
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
|
||||||
@ -303,7 +304,9 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.catalogCache.invalidateEligibility(accountId);
|
await this.catalogCache.invalidateEligibility(accountId);
|
||||||
const hasDetails = Boolean(status || eligibility || requestedAt || checkedAt || requestId);
|
const hasDetails = Boolean(
|
||||||
|
status || eligibility || requestedAt || checkedAt || requestId || notes
|
||||||
|
);
|
||||||
if (hasDetails) {
|
if (hasDetails) {
|
||||||
await this.catalogCache.setEligibilityDetails(accountId, {
|
await this.catalogCache.setEligibilityDetails(accountId, {
|
||||||
status: this.mapEligibilityStatus(status, eligibility),
|
status: this.mapEligibilityStatus(status, eligibility),
|
||||||
@ -311,7 +314,7 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
requestId: requestId ?? null,
|
requestId: requestId ?? null,
|
||||||
requestedAt: requestedAt ?? null,
|
requestedAt: requestedAt ?? null,
|
||||||
checkedAt: checkedAt ?? null,
|
checkedAt: checkedAt ?? null,
|
||||||
notes: null, // Field not used
|
notes: notes ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Salesforce Case Integration Service
|
* Salesforce Case Integration Service
|
||||||
*
|
*
|
||||||
* Encapsulates all Salesforce Case operations for the portal.
|
* Unified service for all Salesforce Case operations.
|
||||||
* - Queries cases filtered by Origin = 'Portal Website'
|
*
|
||||||
* - Creates cases with portal-specific defaults
|
* Case Types:
|
||||||
* - Validates account ownership for security
|
* - Customer Support Cases (Origin: Portal Support) - visible to customers
|
||||||
|
* - Internal Workflow Cases (Origin: Portal Notification) - for CS team only
|
||||||
|
* - Web-to-Case (Origin: Web) - public contact form submissions
|
||||||
*
|
*
|
||||||
* Uses domain types and mappers from @customer-portal/domain/support
|
* Uses domain types and mappers from @customer-portal/domain/support
|
||||||
*/
|
*/
|
||||||
@ -15,31 +17,62 @@ import { SalesforceConnection } from "./salesforce-connection.service.js";
|
|||||||
import { assertSalesforceId } from "../utils/soql.util.js";
|
import { assertSalesforceId } from "../utils/soql.util.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||||
import type { SupportCase, CreateCaseRequest } from "@customer-portal/domain/support";
|
import type { SupportCase } from "@customer-portal/domain/support";
|
||||||
import type { SalesforceCaseRecord } from "@customer-portal/domain/support/providers";
|
import type { SalesforceCaseRecord } from "@customer-portal/domain/support/providers";
|
||||||
import {
|
import {
|
||||||
SALESFORCE_CASE_ORIGIN,
|
SALESFORCE_CASE_ORIGIN,
|
||||||
SALESFORCE_CASE_STATUS,
|
SALESFORCE_CASE_STATUS,
|
||||||
SALESFORCE_CASE_PRIORITY,
|
SALESFORCE_CASE_PRIORITY,
|
||||||
|
toSalesforcePriority,
|
||||||
|
type SalesforceCaseOrigin,
|
||||||
} from "@customer-portal/domain/support/providers";
|
} from "@customer-portal/domain/support/providers";
|
||||||
import {
|
import {
|
||||||
buildCaseByIdQuery,
|
buildCaseByIdQuery,
|
||||||
buildCaseSelectFields,
|
buildCaseSelectFields,
|
||||||
buildCasesForAccountQuery,
|
buildCasesForAccountQuery,
|
||||||
toSalesforcePriority,
|
|
||||||
transformSalesforceCaseToSupportCase,
|
transformSalesforceCaseToSupportCase,
|
||||||
transformSalesforceCasesToSupportCases,
|
transformSalesforceCasesToSupportCases,
|
||||||
} from "@customer-portal/domain/support/providers";
|
} from "@customer-portal/domain/support/providers";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for creating a case in Salesforce
|
* Parameters for creating any case in Salesforce.
|
||||||
* Extends domain CreateCaseRequest with infrastructure-specific fields
|
*
|
||||||
|
* The `origin` field determines case visibility:
|
||||||
|
* - PORTAL_SUPPORT: Customer-visible support case
|
||||||
|
* - PORTAL_NOTIFICATION: Internal CS workflow case (eligibility, verification, cancellation)
|
||||||
*/
|
*/
|
||||||
export interface CreateCaseParams extends CreateCaseRequest {
|
export interface CreateCaseParams {
|
||||||
/** Salesforce Account ID */
|
/** Salesforce Account ID */
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
/** Case subject line */
|
||||||
|
subject: string;
|
||||||
|
/** Case description/body */
|
||||||
|
description: string;
|
||||||
|
/** Case origin - determines visibility and routing */
|
||||||
|
origin: SalesforceCaseOrigin;
|
||||||
|
/** Priority (defaults to Medium) */
|
||||||
|
priority?: string;
|
||||||
/** Optional Salesforce Contact ID */
|
/** Optional Salesforce Contact ID */
|
||||||
contactId?: string;
|
contactId?: string;
|
||||||
|
/** Optional Opportunity ID for workflow cases */
|
||||||
|
opportunityId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for Web-to-Case (public contact form, no account)
|
||||||
|
*/
|
||||||
|
export interface CreateWebCaseParams {
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
suppliedEmail: string;
|
||||||
|
suppliedName: string;
|
||||||
|
suppliedPhone?: string;
|
||||||
|
origin?: string;
|
||||||
|
priority?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -50,13 +83,13 @@ export class SalesforceCaseService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all cases for an account filtered by Portal Website origin
|
* Get all customer-visible support cases for an account
|
||||||
*/
|
*/
|
||||||
async getCasesForAccount(accountId: string): Promise<SupportCase[]> {
|
async getCasesForAccount(accountId: string): Promise<SupportCase[]> {
|
||||||
const safeAccountId = assertSalesforceId(accountId, "accountId");
|
const safeAccountId = assertSalesforceId(accountId, "accountId");
|
||||||
this.logger.debug({ accountId: safeAccountId }, "Fetching portal cases for account");
|
this.logger.debug({ accountId: safeAccountId }, "Fetching portal cases for account");
|
||||||
|
|
||||||
const soql = buildCasesForAccountQuery(safeAccountId, SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE);
|
const soql = buildCasesForAccountQuery(safeAccountId, SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = (await this.sf.query(soql, {
|
const result = (await this.sf.query(soql, {
|
||||||
@ -92,7 +125,7 @@ export class SalesforceCaseService {
|
|||||||
const soql = buildCaseByIdQuery(
|
const soql = buildCaseByIdQuery(
|
||||||
safeCaseId,
|
safeCaseId,
|
||||||
safeAccountId,
|
safeAccountId,
|
||||||
SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE
|
SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -118,27 +151,40 @@ export class SalesforceCaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new case with Portal Website origin
|
* Create a case in Salesforce.
|
||||||
|
*
|
||||||
|
* This is the unified method for creating all case types:
|
||||||
|
* - Customer support cases (origin: PORTAL_SUPPORT)
|
||||||
|
* - Internal workflow cases (origin: PORTAL_NOTIFICATION)
|
||||||
|
*
|
||||||
|
* @param params - Case creation parameters
|
||||||
|
* @returns Created case ID and case number
|
||||||
*/
|
*/
|
||||||
async createCase(params: CreateCaseParams): Promise<{ id: string; caseNumber: string }> {
|
async createCase(params: CreateCaseParams): Promise<{ id: string; caseNumber: string }> {
|
||||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||||
const safeContactId = params.contactId
|
const safeContactId = params.contactId
|
||||||
? assertSalesforceId(params.contactId, "contactId")
|
? assertSalesforceId(params.contactId, "contactId")
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const safeOpportunityId = params.opportunityId
|
||||||
|
? assertSalesforceId(params.opportunityId, "opportunityId")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
this.logger.log(
|
const isInternalCase = params.origin === SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION;
|
||||||
{ accountId: safeAccountId, subject: params.subject },
|
|
||||||
"Creating portal support case"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build case payload with portal defaults
|
this.logger.log("Creating Salesforce case", {
|
||||||
// Convert portal display values to Salesforce API values
|
accountIdTail: safeAccountId.slice(-4),
|
||||||
|
origin: params.origin,
|
||||||
|
isInternal: isInternalCase,
|
||||||
|
hasOpportunity: !!safeOpportunityId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert portal display priority to Salesforce API value
|
||||||
const sfPriority = params.priority
|
const sfPriority = params.priority
|
||||||
? toSalesforcePriority(params.priority)
|
? toSalesforcePriority(params.priority)
|
||||||
: SALESFORCE_CASE_PRIORITY.MEDIUM;
|
: SALESFORCE_CASE_PRIORITY.MEDIUM;
|
||||||
|
|
||||||
const casePayload: Record<string, unknown> = {
|
const casePayload: Record<string, unknown> = {
|
||||||
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE,
|
Origin: params.origin,
|
||||||
Status: SALESFORCE_CASE_STATUS.NEW,
|
Status: SALESFORCE_CASE_STATUS.NEW,
|
||||||
Priority: sfPriority,
|
Priority: sfPriority,
|
||||||
Subject: params.subject.trim(),
|
Subject: params.subject.trim(),
|
||||||
@ -146,18 +192,17 @@ export class SalesforceCaseService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Set ContactId if available - Salesforce will auto-populate AccountId from Contact
|
// Set ContactId if available - Salesforce will auto-populate AccountId from Contact
|
||||||
// If no ContactId, we must set AccountId directly (requires FLS write permission)
|
// If no ContactId, we must set AccountId directly
|
||||||
if (safeContactId) {
|
if (safeContactId) {
|
||||||
casePayload.ContactId = safeContactId;
|
casePayload.ContactId = safeContactId;
|
||||||
} else {
|
} else {
|
||||||
// Only set AccountId when no ContactId is available
|
|
||||||
// Note: This requires AccountId field-level security write permission
|
|
||||||
casePayload.AccountId = safeAccountId;
|
casePayload.AccountId = safeAccountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Category maps to Salesforce Type field
|
// Link to Opportunity if provided (used for workflow cases)
|
||||||
// Only set if Type picklist is configured in Salesforce
|
if (safeOpportunityId) {
|
||||||
// Currently skipped as Type picklist values are unknown
|
casePayload.OpportunityId = safeOpportunityId;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
||||||
@ -170,34 +215,30 @@ export class SalesforceCaseService {
|
|||||||
const createdCase = await this.getCaseByIdInternal(created.id);
|
const createdCase = await this.getCaseByIdInternal(created.id);
|
||||||
const caseNumber = createdCase?.CaseNumber ?? created.id;
|
const caseNumber = createdCase?.CaseNumber ?? created.id;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log("Salesforce case created successfully", {
|
||||||
{ caseId: created.id, caseNumber },
|
caseId: created.id,
|
||||||
"Portal support case created successfully"
|
caseNumber,
|
||||||
);
|
origin: params.origin,
|
||||||
|
});
|
||||||
|
|
||||||
return { id: created.id, caseNumber };
|
return { id: created.id, caseNumber };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.error("Failed to create support case", {
|
this.logger.error("Failed to create Salesforce case", {
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
accountId: safeAccountId,
|
accountIdTail: safeAccountId.slice(-4),
|
||||||
|
origin: params.origin,
|
||||||
});
|
});
|
||||||
throw new Error("Failed to create support case");
|
throw new Error("Failed to create case");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Web-to-Case for public contact form submissions
|
* Create a Web-to-Case for public contact form submissions.
|
||||||
* Does not require an Account - uses supplied contact info
|
*
|
||||||
|
* Does not require an Account - uses supplied contact info.
|
||||||
|
* Separate from createCase() because it has a different payload structure.
|
||||||
*/
|
*/
|
||||||
async createWebCase(params: {
|
async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> {
|
||||||
subject: string;
|
|
||||||
description: string;
|
|
||||||
suppliedEmail: string;
|
|
||||||
suppliedName: string;
|
|
||||||
suppliedPhone?: string;
|
|
||||||
origin?: string;
|
|
||||||
priority?: string;
|
|
||||||
}): Promise<{ id: string; caseNumber: string }> {
|
|
||||||
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
|
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
|
||||||
|
|
||||||
const casePayload: Record<string, unknown> = {
|
const casePayload: Record<string, unknown> = {
|
||||||
@ -258,148 +299,4 @@ export class SalesforceCaseService {
|
|||||||
|
|
||||||
return result.records?.[0] ?? null;
|
return result.records?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Opportunity-Linked Cases
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an eligibility check case linked to an Opportunity
|
|
||||||
*
|
|
||||||
* @param params - Case parameters including Opportunity link
|
|
||||||
* @returns Created case ID
|
|
||||||
*/
|
|
||||||
async createEligibilityCase(params: {
|
|
||||||
accountId: string;
|
|
||||||
opportunityId: string;
|
|
||||||
subject: string;
|
|
||||||
description: string;
|
|
||||||
}): Promise<string> {
|
|
||||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
|
||||||
const safeOpportunityId = assertSalesforceId(params.opportunityId, "opportunityId");
|
|
||||||
|
|
||||||
this.logger.log("Creating eligibility check case linked to Opportunity", {
|
|
||||||
accountIdTail: safeAccountId.slice(-4),
|
|
||||||
opportunityIdTail: safeOpportunityId.slice(-4),
|
|
||||||
});
|
|
||||||
|
|
||||||
const casePayload: Record<string, unknown> = {
|
|
||||||
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE,
|
|
||||||
Status: SALESFORCE_CASE_STATUS.NEW,
|
|
||||||
Priority: SALESFORCE_CASE_PRIORITY.MEDIUM,
|
|
||||||
Subject: params.subject,
|
|
||||||
Description: params.description,
|
|
||||||
AccountId: safeAccountId,
|
|
||||||
// Link Case to Opportunity - this is a standard lookup field
|
|
||||||
OpportunityId: safeOpportunityId,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
|
||||||
|
|
||||||
if (!created.id) {
|
|
||||||
throw new Error("Salesforce did not return a case ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log("Eligibility case created and linked to Opportunity", {
|
|
||||||
caseId: created.id,
|
|
||||||
opportunityIdTail: safeOpportunityId.slice(-4),
|
|
||||||
});
|
|
||||||
|
|
||||||
return created.id;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error("Failed to create eligibility case", {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
accountIdTail: safeAccountId.slice(-4),
|
|
||||||
});
|
|
||||||
throw new Error("Failed to create eligibility check case");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a cancellation request case linked to an Opportunity
|
|
||||||
*
|
|
||||||
* All customer-provided details (comments, alternative email) go here.
|
|
||||||
* The Opportunity only gets the core lifecycle fields (dates, status).
|
|
||||||
*
|
|
||||||
* @param params - Cancellation case parameters
|
|
||||||
* @returns Created case ID
|
|
||||||
*/
|
|
||||||
async createCancellationCase(params: {
|
|
||||||
accountId: string;
|
|
||||||
opportunityId?: string;
|
|
||||||
whmcsServiceId: number;
|
|
||||||
productType: string;
|
|
||||||
cancellationMonth: string;
|
|
||||||
cancellationDate: string;
|
|
||||||
alternativeEmail?: string;
|
|
||||||
comments?: string;
|
|
||||||
}): Promise<string> {
|
|
||||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
|
||||||
const safeOpportunityId = params.opportunityId
|
|
||||||
? assertSalesforceId(params.opportunityId, "opportunityId")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
this.logger.log("Creating cancellation request case", {
|
|
||||||
accountIdTail: safeAccountId.slice(-4),
|
|
||||||
opportunityId: safeOpportunityId ? safeOpportunityId.slice(-4) : "none",
|
|
||||||
whmcsServiceId: params.whmcsServiceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build description with all form data
|
|
||||||
const descriptionLines = [
|
|
||||||
`Cancellation Request from Portal`,
|
|
||||||
``,
|
|
||||||
`Product Type: ${params.productType}`,
|
|
||||||
`WHMCS Service ID: ${params.whmcsServiceId}`,
|
|
||||||
`Cancellation Month: ${params.cancellationMonth}`,
|
|
||||||
`Service End Date: ${params.cancellationDate}`,
|
|
||||||
``,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (params.alternativeEmail) {
|
|
||||||
descriptionLines.push(`Alternative Contact Email: ${params.alternativeEmail}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.comments) {
|
|
||||||
descriptionLines.push(``, `Customer Comments:`, params.comments);
|
|
||||||
}
|
|
||||||
|
|
||||||
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
|
|
||||||
|
|
||||||
const casePayload: Record<string, unknown> = {
|
|
||||||
Origin: "Portal",
|
|
||||||
Status: SALESFORCE_CASE_STATUS.NEW,
|
|
||||||
Priority: SALESFORCE_CASE_PRIORITY.HIGH,
|
|
||||||
Subject: `Cancellation Request - ${params.productType} (${params.cancellationMonth})`,
|
|
||||||
Description: descriptionLines.join("\n"),
|
|
||||||
AccountId: safeAccountId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Link to Opportunity if we have one
|
|
||||||
if (safeOpportunityId) {
|
|
||||||
casePayload.OpportunityId = safeOpportunityId;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
|
||||||
|
|
||||||
if (!created.id) {
|
|
||||||
throw new Error("Salesforce did not return a case ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log("Cancellation case created", {
|
|
||||||
caseId: created.id,
|
|
||||||
hasOpportunityLink: !!safeOpportunityId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return created.id;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error("Failed to create cancellation case", {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
accountIdTail: safeAccountId.slice(-4),
|
|
||||||
});
|
|
||||||
throw new Error("Failed to create cancellation request case");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export class InvoiceRetrievalService {
|
|||||||
/**
|
/**
|
||||||
* Get paginated invoices for a user
|
* Get paginated invoices for a user
|
||||||
*/
|
*/
|
||||||
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> {
|
async getInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
|
||||||
const {
|
const {
|
||||||
page = INVOICE_PAGINATION.DEFAULT_PAGE,
|
page = INVOICE_PAGINATION.DEFAULT_PAGE,
|
||||||
limit = INVOICE_PAGINATION.DEFAULT_LIMIT,
|
limit = INVOICE_PAGINATION.DEFAULT_LIMIT,
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||||
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
|
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
|
||||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||||
@ -42,7 +42,6 @@ export class InternetServicesService extends BaseServicesService {
|
|||||||
@Inject(Logger) logger: Logger,
|
@Inject(Logger) logger: Logger,
|
||||||
private mappingsService: MappingsService,
|
private mappingsService: MappingsService,
|
||||||
private catalogCache: ServicesCacheService,
|
private catalogCache: ServicesCacheService,
|
||||||
private lockService: DistributedLockService,
|
|
||||||
private opportunityResolution: OpportunityResolutionService,
|
private opportunityResolution: OpportunityResolutionService,
|
||||||
private caseService: SalesforceCaseService
|
private caseService: SalesforceCaseService
|
||||||
) {
|
) {
|
||||||
@ -300,85 +299,52 @@ export class InternetServicesService extends BaseServicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lockKey = `internet:eligibility:${sfAccountId}`;
|
const subject = "Internet availability check request (Portal)";
|
||||||
|
|
||||||
const caseId = await this.lockService.withLock(
|
// 1) Find or create Opportunity for Internet eligibility (this service remains locked internally)
|
||||||
lockKey,
|
const { opportunityId, wasCreated: opportunityCreated } =
|
||||||
async () => {
|
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
|
||||||
// Idempotency: if we already have a pending request, do not create a new Case.
|
|
||||||
// The Case creation is a signal of interest; if status is pending, interest is already signaled/active.
|
|
||||||
const existing = await this.queryEligibilityDetails(sfAccountId);
|
|
||||||
|
|
||||||
if (existing.status === "pending") {
|
// 2) Build case description
|
||||||
this.logger.log("Eligibility request already pending; skipping new case creation", {
|
const descriptionLines: string[] = [
|
||||||
userId,
|
"Portal internet availability check requested.",
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
"",
|
||||||
});
|
`UserId: ${userId}`,
|
||||||
|
`Email: ${request.email}`,
|
||||||
|
`SalesforceAccountId: ${sfAccountId}`,
|
||||||
|
`OpportunityId: ${opportunityId}`,
|
||||||
|
"",
|
||||||
|
request.notes ? `Notes: ${request.notes}` : "",
|
||||||
|
request.address ? `Address: ${formatAddressForLog(request.address)}` : "",
|
||||||
|
"",
|
||||||
|
`RequestedAt: ${new Date().toISOString()}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
// Try to find the existing open case to return its ID (best effort)
|
// 3) Create Case linked to Opportunity (internal workflow case)
|
||||||
try {
|
const { id: createdCaseId } = await this.caseService.createCase({
|
||||||
const cases = await this.caseService.getCasesForAccount(sfAccountId);
|
accountId: sfAccountId,
|
||||||
const openCase = cases.find(
|
opportunityId,
|
||||||
c => c.status !== "Closed" && c.subject.includes("Internet availability check")
|
subject,
|
||||||
);
|
description: descriptionLines.join("\n"),
|
||||||
if (openCase) {
|
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
|
||||||
return openCase.id;
|
});
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn("Failed to lookup existing case for pending request", { error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we can't find the case ID but status is pending, we return a placeholder or empty string.
|
// 4) Update Account eligibility status (best-effort for optional fields)
|
||||||
// The frontend primarily relies on the status change.
|
await this.updateAccountEligibilityRequestState(sfAccountId, {
|
||||||
return "";
|
caseId: createdCaseId,
|
||||||
}
|
notes: request.notes,
|
||||||
|
});
|
||||||
|
await this.catalogCache.invalidateEligibility(sfAccountId);
|
||||||
|
|
||||||
// 1) Find or create Opportunity for Internet eligibility
|
this.logger.log("Created eligibility Case linked to Opportunity", {
|
||||||
const { opportunityId, wasCreated: opportunityCreated } =
|
userId,
|
||||||
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
|
caseIdTail: createdCaseId.slice(-4),
|
||||||
|
opportunityIdTail: opportunityId.slice(-4),
|
||||||
|
opportunityCreated,
|
||||||
|
});
|
||||||
|
|
||||||
// 2) Build case description
|
return createdCaseId;
|
||||||
const subject = "Internet availability check request (Portal)";
|
|
||||||
const descriptionLines: string[] = [
|
|
||||||
"Portal internet availability check requested.",
|
|
||||||
"",
|
|
||||||
`UserId: ${userId}`,
|
|
||||||
`Email: ${request.email}`,
|
|
||||||
`SalesforceAccountId: ${sfAccountId}`,
|
|
||||||
`OpportunityId: ${opportunityId}`,
|
|
||||||
"",
|
|
||||||
request.notes ? `Notes: ${request.notes}` : "",
|
|
||||||
request.address ? `Address: ${formatAddressForLog(request.address)}` : "",
|
|
||||||
"",
|
|
||||||
`RequestedAt: ${new Date().toISOString()}`,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
// 3) Create Case linked to Opportunity
|
|
||||||
const createdCaseId = await this.caseService.createEligibilityCase({
|
|
||||||
accountId: sfAccountId,
|
|
||||||
opportunityId,
|
|
||||||
subject,
|
|
||||||
description: descriptionLines.join("\n"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4) Update Account eligibility status
|
|
||||||
await this.updateAccountEligibilityRequestState(sfAccountId);
|
|
||||||
await this.catalogCache.invalidateEligibility(sfAccountId);
|
|
||||||
|
|
||||||
this.logger.log("Created eligibility Case linked to Opportunity", {
|
|
||||||
userId,
|
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
|
||||||
caseIdTail: createdCaseId.slice(-4),
|
|
||||||
opportunityIdTail: opportunityId.slice(-4),
|
|
||||||
opportunityCreated,
|
|
||||||
});
|
|
||||||
|
|
||||||
return createdCaseId;
|
|
||||||
},
|
|
||||||
{ ttlMs: 10_000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
return caseId;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to create eligibility request", {
|
this.logger.error("Failed to create eligibility request", {
|
||||||
userId,
|
userId,
|
||||||
@ -415,18 +381,44 @@ export class InternetServicesService extends BaseServicesService {
|
|||||||
"Internet_Eligibility_Checked_Date_Time__c",
|
"Internet_Eligibility_Checked_Date_Time__c",
|
||||||
"ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD"
|
"ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD"
|
||||||
);
|
);
|
||||||
// Note: Notes and Case ID fields removed as they are not present/needed in the Salesforce schema
|
const notesField = assertSoqlFieldName(
|
||||||
|
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ??
|
||||||
|
"Internet_Eligibility_Notes__c",
|
||||||
|
"ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD"
|
||||||
|
);
|
||||||
|
const caseIdField = assertSoqlFieldName(
|
||||||
|
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
|
||||||
|
"Internet_Eligibility_Case_Id__c",
|
||||||
|
"ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
|
||||||
|
);
|
||||||
|
|
||||||
const soql = `
|
const selectBase = `Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}`;
|
||||||
SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}
|
const selectExtended = `${selectBase}, ${notesField}, ${caseIdField}`;
|
||||||
FROM Account
|
|
||||||
WHERE Id = '${sfAccountId}'
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
const res = (await this.sf.query(soql, {
|
const queryAccount = async (selectFields: string, labelSuffix: string) => {
|
||||||
label: "services:internet:eligibility_details",
|
const soql = `
|
||||||
})) as SalesforceResponse<Record<string, unknown>>;
|
SELECT ${selectFields}
|
||||||
|
FROM Account
|
||||||
|
WHERE Id = '${sfAccountId}'
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
return (await this.sf.query(soql, {
|
||||||
|
label: `services:internet:eligibility_details:${labelSuffix}`,
|
||||||
|
})) as SalesforceResponse<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: SalesforceResponse<Record<string, unknown>>;
|
||||||
|
try {
|
||||||
|
res = await queryAccount(selectExtended, "extended");
|
||||||
|
} catch (error) {
|
||||||
|
// Hardening: if optional fields aren't available (FLS / schema drift),
|
||||||
|
// fall back to the minimal field set rather than failing the whole endpoint.
|
||||||
|
this.logger.warn("Eligibility details query failed (extended); falling back to base fields", {
|
||||||
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
res = await queryAccount(selectBase, "base");
|
||||||
|
}
|
||||||
const record = (res.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
|
const record = (res.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return internetEligibilityDetailsSchema.parse({
|
return internetEligibilityDetailsSchema.parse({
|
||||||
@ -461,6 +453,8 @@ export class InternetServicesService extends BaseServicesService {
|
|||||||
|
|
||||||
const requestedAtRaw = record[requestedAtField];
|
const requestedAtRaw = record[requestedAtField];
|
||||||
const checkedAtRaw = record[checkedAtField];
|
const checkedAtRaw = record[checkedAtField];
|
||||||
|
const notesRaw = record[notesField];
|
||||||
|
const requestIdRaw = record[caseIdField];
|
||||||
|
|
||||||
const requestedAt =
|
const requestedAt =
|
||||||
typeof requestedAtRaw === "string"
|
typeof requestedAtRaw === "string"
|
||||||
@ -478,17 +472,23 @@ export class InternetServicesService extends BaseServicesService {
|
|||||||
return internetEligibilityDetailsSchema.parse({
|
return internetEligibilityDetailsSchema.parse({
|
||||||
status,
|
status,
|
||||||
eligibility,
|
eligibility,
|
||||||
requestId: null, // Always null as field is not used
|
requestId:
|
||||||
|
typeof requestIdRaw === "string" && requestIdRaw.trim().length > 0
|
||||||
|
? requestIdRaw.trim()
|
||||||
|
: null,
|
||||||
requestedAt,
|
requestedAt,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
notes: null, // Always null as field is not used
|
notes: typeof notesRaw === "string" && notesRaw.trim().length > 0 ? notesRaw.trim() : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase()
|
// Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase()
|
||||||
// which links the Case to the Opportunity
|
// which links the Case to the Opportunity
|
||||||
|
|
||||||
private async updateAccountEligibilityRequestState(sfAccountId: string): Promise<void> {
|
private async updateAccountEligibilityRequestState(
|
||||||
|
sfAccountId: string,
|
||||||
|
params?: { caseId?: string; notes?: string }
|
||||||
|
): Promise<void> {
|
||||||
const statusField = assertSoqlFieldName(
|
const statusField = assertSoqlFieldName(
|
||||||
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
|
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
|
||||||
"Internet_Eligibility_Status__c",
|
"Internet_Eligibility_Status__c",
|
||||||
@ -499,17 +499,45 @@ export class InternetServicesService extends BaseServicesService {
|
|||||||
"Internet_Eligibility_Request_Date_Time__c",
|
"Internet_Eligibility_Request_Date_Time__c",
|
||||||
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
|
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
|
||||||
);
|
);
|
||||||
|
const caseIdField = assertSoqlFieldName(
|
||||||
|
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
|
||||||
|
"Internet_Eligibility_Case_Id__c",
|
||||||
|
"ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
|
||||||
|
);
|
||||||
|
const notesField = assertSoqlFieldName(
|
||||||
|
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ??
|
||||||
|
"Internet_Eligibility_Notes__c",
|
||||||
|
"ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD"
|
||||||
|
);
|
||||||
|
|
||||||
const update = this.sf.sobject("Account")?.update;
|
const update = this.sf.sobject("Account")?.update;
|
||||||
if (!update) {
|
if (!update) {
|
||||||
throw new Error("Salesforce Account update method not available");
|
throw new Error("Salesforce Account update method not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
await update({
|
const basePayload: { Id: string } & Record<string, unknown> = {
|
||||||
Id: sfAccountId,
|
Id: sfAccountId,
|
||||||
[statusField]: "Pending",
|
[statusField]: "Pending",
|
||||||
[requestedAtField]: new Date().toISOString(),
|
[requestedAtField]: new Date().toISOString(),
|
||||||
});
|
};
|
||||||
|
const extendedPayload: { Id: string } & Record<string, unknown> = { ...basePayload };
|
||||||
|
if (params?.caseId) {
|
||||||
|
extendedPayload[caseIdField] = params.caseId;
|
||||||
|
}
|
||||||
|
if (typeof params?.notes === "string") {
|
||||||
|
extendedPayload[notesField] = params.notes.trim().length > 0 ? params.notes.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await update(extendedPayload);
|
||||||
|
} catch (error) {
|
||||||
|
// Hardening: If optional fields fail (schema/FLS), still set core Pending fields.
|
||||||
|
this.logger.warn("Eligibility Account update failed (extended); retrying base update", {
|
||||||
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
await update(basePayload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien
|
|||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
|
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||||
import type {
|
import type {
|
||||||
@ -225,16 +226,35 @@ export class InternetCancellationService {
|
|||||||
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
|
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Salesforce Case for cancellation
|
// Build description with all form data
|
||||||
const caseId = await this.caseService.createCancellationCase({
|
const descriptionLines = [
|
||||||
|
`Cancellation Request from Portal`,
|
||||||
|
``,
|
||||||
|
`Product Type: Internet`,
|
||||||
|
`WHMCS Service ID: ${subscriptionId}`,
|
||||||
|
`Cancellation Month: ${request.cancellationMonth}`,
|
||||||
|
`Service End Date: ${cancellationDate}`,
|
||||||
|
``,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (request.alternativeEmail) {
|
||||||
|
descriptionLines.push(`Alternative Contact Email: ${request.alternativeEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.comments) {
|
||||||
|
descriptionLines.push(``, `Customer Comments:`, request.comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
|
||||||
|
|
||||||
|
// Create Salesforce Case for cancellation (internal workflow case)
|
||||||
|
const { id: caseId } = await this.caseService.createCase({
|
||||||
accountId: sfAccountId,
|
accountId: sfAccountId,
|
||||||
opportunityId: opportunityId || undefined,
|
opportunityId: opportunityId || undefined,
|
||||||
whmcsServiceId: subscriptionId,
|
subject: `Cancellation Request - Internet (${request.cancellationMonth})`,
|
||||||
productType: "Internet",
|
description: descriptionLines.join("\n"),
|
||||||
cancellationMonth: request.cancellationMonth,
|
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
|
||||||
cancellationDate,
|
priority: "High",
|
||||||
alternativeEmail: request.alternativeEmail || undefined,
|
|
||||||
comments: request.comments,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("Cancellation case created", {
|
this.logger.log("Cancellation case created", {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
type CreateCaseResponse,
|
type CreateCaseResponse,
|
||||||
type PublicContactRequest,
|
type PublicContactRequest,
|
||||||
} from "@customer-portal/domain/support";
|
} 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 { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
@ -110,9 +111,9 @@ export class SupportService {
|
|||||||
const result = await this.caseService.createCase({
|
const result = await this.caseService.createCase({
|
||||||
subject: request.subject,
|
subject: request.subject,
|
||||||
description: request.description,
|
description: request.description,
|
||||||
category: request.category,
|
|
||||||
priority: request.priority,
|
priority: request.priority,
|
||||||
accountId,
|
accountId,
|
||||||
|
origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("Support case created", {
|
this.logger.log("Support case created", {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
|
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||||
import {
|
import {
|
||||||
assertSalesforceId,
|
assertSalesforceId,
|
||||||
assertSoqlFieldName,
|
assertSoqlFieldName,
|
||||||
@ -34,6 +36,7 @@ export class ResidenceCardService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly sf: SalesforceConnection,
|
private readonly sf: SalesforceConnection,
|
||||||
private readonly mappings: MappingsService,
|
private readonly mappings: MappingsService,
|
||||||
|
private readonly caseService: SalesforceCaseService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -98,7 +101,9 @@ export class ResidenceCardService {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const fileMeta =
|
const fileMeta =
|
||||||
status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId);
|
status === "not_submitted"
|
||||||
|
? null
|
||||||
|
: await this.getLatestIdVerificationFileMetadata(sfAccountId);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
status,
|
status,
|
||||||
@ -149,6 +154,33 @@ export class ResidenceCardService {
|
|||||||
}
|
}
|
||||||
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||||
|
|
||||||
|
// Create an internal workflow Case for CS to track this submission.
|
||||||
|
// (No lock/dedupe: multiple submissions may create multiple cases by design.)
|
||||||
|
const subject = "ID verification review (Portal)";
|
||||||
|
const descriptionLines: string[] = [
|
||||||
|
"Portal ID verification submitted (residence card).",
|
||||||
|
"",
|
||||||
|
`UserId: ${params.userId}`,
|
||||||
|
// Email is not included here to reduce sensitive data in case bodies.
|
||||||
|
`SalesforceAccountId: ${sfAccountId}`,
|
||||||
|
"",
|
||||||
|
`Filename: ${params.filename || "residence-card"}`,
|
||||||
|
`MimeType: ${params.mimeType}`,
|
||||||
|
`SizeBytes: ${params.sizeBytes}`,
|
||||||
|
"",
|
||||||
|
"The ID document is attached to this Case (see Files related list).",
|
||||||
|
"",
|
||||||
|
`SubmittedAt: ${new Date().toISOString()}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { id: caseId } = await this.caseService.createCase({
|
||||||
|
accountId: sfAccountId,
|
||||||
|
subject,
|
||||||
|
description: descriptionLines.join("\n"),
|
||||||
|
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload file to Salesforce Files and attach to the Case
|
||||||
const versionData = params.content.toString("base64");
|
const versionData = params.content.toString("base64");
|
||||||
const extension = extname(params.filename || "").replace(/^\./, "");
|
const extension = extname(params.filename || "").replace(/^\./, "");
|
||||||
const title = basename(params.filename || "residence-card", extension ? `.${extension}` : "");
|
const title = basename(params.filename || "residence-card", extension ? `.${extension}` : "");
|
||||||
@ -159,26 +191,33 @@ export class ResidenceCardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Attach file directly to the Case (not Account) so CS can see it in the Case's Files
|
||||||
const result = await create({
|
const result = await create({
|
||||||
Title: title || "residence-card",
|
Title: title || "residence-card",
|
||||||
PathOnClient: params.filename || "residence-card",
|
PathOnClient: params.filename || "residence-card",
|
||||||
VersionData: versionData,
|
VersionData: versionData,
|
||||||
FirstPublishLocationId: sfAccountId,
|
FirstPublishLocationId: caseId, // Attach to Case, not Account
|
||||||
});
|
});
|
||||||
const id = (result as { id?: unknown })?.id;
|
const contentVersionId = (result as { id?: unknown })?.id;
|
||||||
if (typeof id !== "string" || id.trim().length === 0) {
|
if (typeof contentVersionId !== "string" || contentVersionId.trim().length === 0) {
|
||||||
throw new DomainHttpException(
|
throw new DomainHttpException(
|
||||||
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||||
HttpStatus.SERVICE_UNAVAILABLE
|
HttpStatus.SERVICE_UNAVAILABLE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log("ID verification file attached to Case", {
|
||||||
|
caseIdTail: caseId.slice(-4),
|
||||||
|
contentVersionId,
|
||||||
|
filename: params.filename,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DomainHttpException) {
|
if (error instanceof DomainHttpException) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
this.logger.error("Failed to upload residence card to Salesforce Files", {
|
this.logger.error("Failed to upload residence card to Salesforce Files", {
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
caseIdTail: caseId.slice(-4),
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
});
|
});
|
||||||
throw new DomainHttpException(
|
throw new DomainHttpException(
|
||||||
@ -239,17 +278,40 @@ export class ResidenceCardService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLatestAccountFileMetadata(accountId: string): Promise<{
|
/**
|
||||||
|
* Get the latest ID verification file metadata from Cases linked to the Account.
|
||||||
|
*
|
||||||
|
* Files are attached to ID verification Cases (not the Account directly).
|
||||||
|
* We find the most recent Case with the ID verification subject and get its file.
|
||||||
|
*/
|
||||||
|
private async getLatestIdVerificationFileMetadata(accountId: string): Promise<{
|
||||||
filename: string | null;
|
filename: string | null;
|
||||||
mimeType: string | null;
|
mimeType: string | null;
|
||||||
sizeBytes: number | null;
|
sizeBytes: number | null;
|
||||||
submittedAt: string | null;
|
submittedAt: string | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
|
// Find the most recent ID verification case for this account
|
||||||
|
const caseSoql = `
|
||||||
|
SELECT Id
|
||||||
|
FROM Case
|
||||||
|
WHERE AccountId = '${accountId}'
|
||||||
|
AND Origin = '${SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION}'
|
||||||
|
AND Subject LIKE '%ID verification%'
|
||||||
|
ORDER BY CreatedDate DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const caseRes = (await this.sf.query(caseSoql, {
|
||||||
|
label: "verification:residence_card:latest_case",
|
||||||
|
})) as SalesforceResponse<{ Id?: string }>;
|
||||||
|
const caseId = caseRes.records?.[0]?.Id;
|
||||||
|
if (!caseId) return null;
|
||||||
|
|
||||||
|
// Get files linked to that case
|
||||||
const linkSoql = `
|
const linkSoql = `
|
||||||
SELECT ContentDocumentId
|
SELECT ContentDocumentId
|
||||||
FROM ContentDocumentLink
|
FROM ContentDocumentLink
|
||||||
WHERE LinkedEntityId = '${accountId}'
|
WHERE LinkedEntityId = '${caseId}'
|
||||||
ORDER BY SystemModstamp DESC
|
ORDER BY SystemModstamp DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
@ -292,7 +354,7 @@ export class ResidenceCardService {
|
|||||||
submittedAt,
|
submittedAt,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn("Failed to load residence card file metadata from Salesforce", {
|
this.logger.warn("Failed to load ID verification file metadata from Salesforce", {
|
||||||
accountIdTail: accountId.slice(-4),
|
accountIdTail: accountId.slice(-4),
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
});
|
});
|
||||||
|
|||||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -128,6 +128,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"zod": "^4.2.1"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,6 @@ export const SUPPORT_CASE_CATEGORY = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portal Website origin - used to filter and create portal cases
|
* Portal support origin - used to filter and create customer-visible portal support cases
|
||||||
*/
|
*/
|
||||||
export const PORTAL_CASE_ORIGIN = "Portal Website" as const;
|
export const PORTAL_CASE_ORIGIN = "Portal Support" as const;
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export function buildCaseSelectFields(additionalFields: string[] = []): string[]
|
|||||||
* Build a SOQL query for fetching cases for an account.
|
* Build a SOQL query for fetching cases for an account.
|
||||||
*
|
*
|
||||||
* @param accountId - Salesforce Account ID
|
* @param accountId - Salesforce Account ID
|
||||||
* @param origin - Case origin to filter by (e.g., "Portal Website")
|
* @param origin - Case origin to filter by (e.g., "Portal Support")
|
||||||
* @param additionalFields - Optional additional fields to include
|
* @param additionalFields - Optional additional fields to include
|
||||||
* @returns SOQL query string
|
* @returns SOQL query string
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -168,8 +168,11 @@ export type SalesforceCaseCreatePayload = z.infer<typeof salesforceCaseCreatePay
|
|||||||
* API Names from Salesforce Setup > Object Manager > Case > Fields > Case Origin
|
* API Names from Salesforce Setup > Object Manager > Case > Fields > Case Origin
|
||||||
*/
|
*/
|
||||||
export const SALESFORCE_CASE_ORIGIN = {
|
export const SALESFORCE_CASE_ORIGIN = {
|
||||||
// Portal origin (used for portal-created cases)
|
// Customer-visible support cases created from the portal support UI
|
||||||
PORTAL_WEBSITE: "Portal Website",
|
PORTAL_SUPPORT: "Portal Support",
|
||||||
|
|
||||||
|
// Internal portal workflow cases (eligibility, verification, cancellations)
|
||||||
|
PORTAL_NOTIFICATION: "Portal Notification",
|
||||||
|
|
||||||
// Phone/Email origins
|
// Phone/Email origins
|
||||||
PHONE: "電話", // Japanese: Phone
|
PHONE: "電話", // Japanese: Phone
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@ -14,8 +14,8 @@ catalogs:
|
|||||||
specifier: 5.9.3
|
specifier: 5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
zod:
|
zod:
|
||||||
specifier: 4.1.13
|
specifier: 4.2.1
|
||||||
version: 4.1.13
|
version: 4.2.1
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
js-yaml: ">=4.1.1"
|
js-yaml: ">=4.1.1"
|
||||||
@ -64,7 +64,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@customer-portal/domain":
|
"@customer-portal/domain":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: file:packages/domain(zod@4.1.13)
|
version: link:../../packages/domain
|
||||||
"@nestjs/bullmq":
|
"@nestjs/bullmq":
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
version: 11.0.4(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(bullmq@5.65.1)
|
version: 11.0.4(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(bullmq@5.65.1)
|
||||||
@ -121,7 +121,7 @@ importers:
|
|||||||
version: 4.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
|
version: 4.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
|
||||||
nestjs-zod:
|
nestjs-zod:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13)
|
version: 5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.2.1)
|
||||||
p-queue:
|
p-queue:
|
||||||
specifier: ^9.0.1
|
specifier: ^9.0.1
|
||||||
version: 9.0.1
|
version: 9.0.1
|
||||||
@ -151,7 +151,7 @@ importers:
|
|||||||
version: 5.0.1(express@5.1.0)
|
version: 5.0.1(express@5.1.0)
|
||||||
zod:
|
zod:
|
||||||
specifier: "catalog:"
|
specifier: "catalog:"
|
||||||
version: 4.1.13
|
version: 4.2.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
"@nestjs/cli":
|
"@nestjs/cli":
|
||||||
specifier: ^11.0.14
|
specifier: ^11.0.14
|
||||||
@ -260,7 +260,7 @@ importers:
|
|||||||
specifier: "catalog:"
|
specifier: "catalog:"
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.2.1
|
specifier: "catalog:"
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@ -508,11 +508,6 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=0.1.90" }
|
engines: { node: ">=0.1.90" }
|
||||||
|
|
||||||
"@customer-portal/domain@file:packages/domain":
|
|
||||||
resolution: { directory: packages/domain, type: directory }
|
|
||||||
peerDependencies:
|
|
||||||
zod: 4.1.13
|
|
||||||
|
|
||||||
"@discoveryjs/json-ext@0.5.7":
|
"@discoveryjs/json-ext@0.5.7":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -7513,12 +7508,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.0 || ^4.0.0
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
|
||||||
zod@4.1.13:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==,
|
|
||||||
}
|
|
||||||
|
|
||||||
zod@4.2.1:
|
zod@4.2.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -7729,10 +7718,6 @@ snapshots:
|
|||||||
"@colors/colors@1.5.0":
|
"@colors/colors@1.5.0":
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
"@customer-portal/domain@file:packages/domain(zod@4.1.13)":
|
|
||||||
dependencies:
|
|
||||||
zod: 4.1.13
|
|
||||||
|
|
||||||
"@discoveryjs/json-ext@0.5.7": {}
|
"@discoveryjs/json-ext@0.5.7": {}
|
||||||
|
|
||||||
"@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)":
|
"@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)":
|
||||||
@ -10757,12 +10742,12 @@ snapshots:
|
|||||||
pino-http: 11.0.0
|
pino-http: 11.0.0
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
|
|
||||||
nestjs-zod@5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13):
|
nestjs-zod@5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.2.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
"@nestjs/common": 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
"@nestjs/common": 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
zod: 4.1.13
|
zod: 4.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@nestjs/swagger": 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
"@nestjs/swagger": 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
||||||
|
|
||||||
@ -11928,8 +11913,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
zod: 4.2.1
|
zod: 4.2.1
|
||||||
|
|
||||||
zod@4.1.13: {}
|
|
||||||
|
|
||||||
zod@4.2.1: {}
|
zod@4.2.1: {}
|
||||||
|
|
||||||
zustand@5.0.9(@types/react@19.2.7)(react@19.2.3):
|
zustand@5.0.9(@types/react@19.2.7)(react@19.2.3):
|
||||||
|
|||||||
@ -5,4 +5,4 @@ packages:
|
|||||||
catalog:
|
catalog:
|
||||||
"@types/node": 24.10.3
|
"@types/node": 24.10.3
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
zod: 4.1.13
|
zod: 4.2.1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user