diff --git a/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts index aa81728a..655d42bb 100644 --- a/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts @@ -273,6 +273,8 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy { const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]); const eligibility = this.extractStringField(payload, ["Internet_Eligibility__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, [ "Internet_Eligibility_Request_Date_Time__c", ]); @@ -280,8 +282,7 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy { "Internet_Eligibility_Checked_Date_Time__c", ]); - // Note: Request ID field is not used in this environment - const requestId = undefined; + const requestId = caseId; // Also extract ID verification fields for notifications const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]); @@ -303,7 +304,9 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy { }); await this.catalogCache.invalidateEligibility(accountId); - const hasDetails = Boolean(status || eligibility || requestedAt || checkedAt || requestId); + const hasDetails = Boolean( + status || eligibility || requestedAt || checkedAt || requestId || notes + ); if (hasDetails) { await this.catalogCache.setEligibilityDetails(accountId, { status: this.mapEligibilityStatus(status, eligibility), @@ -311,7 +314,7 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy { requestId: requestId ?? null, requestedAt: requestedAt ?? null, checkedAt: checkedAt ?? null, - notes: null, // Field not used + notes: notes ?? null, }); } 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 66ccad45..9d08fef4 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -1,10 +1,12 @@ /** * Salesforce Case Integration Service * - * Encapsulates all Salesforce Case operations for the portal. - * - Queries cases filtered by Origin = 'Portal Website' - * - Creates cases with portal-specific defaults - * - Validates account ownership for security + * Unified service for all Salesforce Case operations. + * + * Case Types: + * - 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 */ @@ -15,31 +17,62 @@ 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, CreateCaseRequest } from "@customer-portal/domain/support"; +import type { SupportCase } from "@customer-portal/domain/support"; import type { SalesforceCaseRecord } from "@customer-portal/domain/support/providers"; import { SALESFORCE_CASE_ORIGIN, SALESFORCE_CASE_STATUS, SALESFORCE_CASE_PRIORITY, + toSalesforcePriority, + type SalesforceCaseOrigin, } from "@customer-portal/domain/support/providers"; import { buildCaseByIdQuery, buildCaseSelectFields, buildCasesForAccountQuery, - toSalesforcePriority, transformSalesforceCaseToSupportCase, transformSalesforceCasesToSupportCases, } from "@customer-portal/domain/support/providers"; +// ============================================================================ +// Types +// ============================================================================ + /** - * Parameters for creating a case in Salesforce - * Extends domain CreateCaseRequest with infrastructure-specific fields + * Parameters for creating any case in Salesforce. + * + * 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 */ 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 */ 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() @@ -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 { const safeAccountId = assertSalesforceId(accountId, "accountId"); 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 { const result = (await this.sf.query(soql, { @@ -92,7 +125,7 @@ export class SalesforceCaseService { const soql = buildCaseByIdQuery( safeCaseId, safeAccountId, - SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE + SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT ); 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 }> { const safeAccountId = assertSalesforceId(params.accountId, "accountId"); const safeContactId = params.contactId ? assertSalesforceId(params.contactId, "contactId") : undefined; + const safeOpportunityId = params.opportunityId + ? assertSalesforceId(params.opportunityId, "opportunityId") + : undefined; - this.logger.log( - { accountId: safeAccountId, subject: params.subject }, - "Creating portal support case" - ); + const isInternalCase = params.origin === SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION; - // Build case payload with portal defaults - // Convert portal display values to Salesforce API values + this.logger.log("Creating Salesforce case", { + accountIdTail: safeAccountId.slice(-4), + origin: params.origin, + isInternal: isInternalCase, + hasOpportunity: !!safeOpportunityId, + }); + + // Convert portal display priority to Salesforce API value const sfPriority = params.priority ? toSalesforcePriority(params.priority) : SALESFORCE_CASE_PRIORITY.MEDIUM; const casePayload: Record = { - Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE, + Origin: params.origin, Status: SALESFORCE_CASE_STATUS.NEW, Priority: sfPriority, Subject: params.subject.trim(), @@ -146,18 +192,17 @@ export class SalesforceCaseService { }; // 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) { casePayload.ContactId = safeContactId; } else { - // Only set AccountId when no ContactId is available - // Note: This requires AccountId field-level security write permission casePayload.AccountId = safeAccountId; } - // Note: Category maps to Salesforce Type field - // Only set if Type picklist is configured in Salesforce - // Currently skipped as Type picklist values are unknown + // Link to Opportunity if provided (used for workflow cases) + if (safeOpportunityId) { + casePayload.OpportunityId = safeOpportunityId; + } try { 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 caseNumber = createdCase?.CaseNumber ?? created.id; - this.logger.log( - { caseId: created.id, caseNumber }, - "Portal support case created successfully" - ); + this.logger.log("Salesforce case created successfully", { + caseId: created.id, + caseNumber, + origin: params.origin, + }); return { id: created.id, caseNumber }; } catch (error: unknown) { - this.logger.error("Failed to create support case", { + this.logger.error("Failed to create Salesforce case", { 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 - * Does not require an Account - uses supplied contact info + * Create a Web-to-Case for public contact form submissions. + * + * Does not require an Account - uses supplied contact info. + * Separate from createCase() because it has a different payload structure. */ - async createWebCase(params: { - subject: string; - description: string; - suppliedEmail: string; - suppliedName: string; - suppliedPhone?: string; - origin?: string; - priority?: string; - }): Promise<{ id: string; caseNumber: string }> { + async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> { this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail }); const casePayload: Record = { @@ -258,148 +299,4 @@ export class SalesforceCaseService { 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 { - 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 = { - 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 { - 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 = { - 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"); - } - } } diff --git a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts index 0f7a6d0e..4504eeb1 100644 --- a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts @@ -29,7 +29,7 @@ export class InvoiceRetrievalService { /** * Get paginated invoices for a user */ - async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise { + async getInvoices(userId: string, options: Partial = {}): Promise { const { page = INVOICE_PAGINATION.DEFAULT_PAGE, limit = INVOICE_PAGINATION.DEFAULT_LIMIT, diff --git a/apps/bff/src/modules/services/services/internet-services.service.ts b/apps/bff/src/modules/services/services/internet-services.service.ts index ef093af3..c0a8554a 100644 --- a/apps/bff/src/modules/services/services/internet-services.service.ts +++ b/apps/bff/src/modules/services/services/internet-services.service.ts @@ -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 { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.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 { extractErrorMessage } from "@bff/core/utils/error.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, private mappingsService: MappingsService, private catalogCache: ServicesCacheService, - private lockService: DistributedLockService, private opportunityResolution: OpportunityResolutionService, private caseService: SalesforceCaseService ) { @@ -300,85 +299,52 @@ export class InternetServicesService extends BaseServicesService { } try { - const lockKey = `internet:eligibility:${sfAccountId}`; + const subject = "Internet availability check request (Portal)"; - const caseId = await this.lockService.withLock( - lockKey, - async () => { - // 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); + // 1) Find or create Opportunity for Internet eligibility (this service remains locked internally) + const { opportunityId, wasCreated: opportunityCreated } = + await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); - if (existing.status === "pending") { - this.logger.log("Eligibility request already pending; skipping new case creation", { - userId, - sfAccountIdTail: sfAccountId.slice(-4), - }); + // 2) Build case description + 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); - // Try to find the existing open case to return its ID (best effort) - try { - const cases = await this.caseService.getCasesForAccount(sfAccountId); - const openCase = cases.find( - c => c.status !== "Closed" && c.subject.includes("Internet availability check") - ); - if (openCase) { - return openCase.id; - } - } catch (error) { - this.logger.warn("Failed to lookup existing case for pending request", { error }); - } + // 3) Create Case linked to Opportunity (internal workflow case) + const { id: createdCaseId } = await this.caseService.createCase({ + accountId: sfAccountId, + opportunityId, + subject, + description: descriptionLines.join("\n"), + origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, + }); - // If we can't find the case ID but status is pending, we return a placeholder or empty string. - // The frontend primarily relies on the status change. - return ""; - } + // 4) Update Account eligibility status (best-effort for optional fields) + await this.updateAccountEligibilityRequestState(sfAccountId, { + caseId: createdCaseId, + notes: request.notes, + }); + await this.catalogCache.invalidateEligibility(sfAccountId); - // 1) Find or create Opportunity for Internet eligibility - const { opportunityId, wasCreated: opportunityCreated } = - await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); + this.logger.log("Created eligibility Case linked to Opportunity", { + userId, + sfAccountIdTail: sfAccountId.slice(-4), + caseIdTail: createdCaseId.slice(-4), + opportunityIdTail: opportunityId.slice(-4), + opportunityCreated, + }); - // 2) Build case description - 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; + return createdCaseId; } catch (error) { this.logger.error("Failed to create eligibility request", { userId, @@ -415,18 +381,44 @@ export class InternetServicesService extends BaseServicesService { "Internet_Eligibility_Checked_Date_Time__c", "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("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ?? + "Internet_Eligibility_Notes__c", + "ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD" + ); + const caseIdField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ?? + "Internet_Eligibility_Case_Id__c", + "ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD" + ); - const soql = ` - SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField} - FROM Account - WHERE Id = '${sfAccountId}' - LIMIT 1 - `; + const selectBase = `Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}`; + const selectExtended = `${selectBase}, ${notesField}, ${caseIdField}`; - const res = (await this.sf.query(soql, { - label: "services:internet:eligibility_details", - })) as SalesforceResponse>; + const queryAccount = async (selectFields: string, labelSuffix: string) => { + const soql = ` + SELECT ${selectFields} + FROM Account + WHERE Id = '${sfAccountId}' + LIMIT 1 + `; + return (await this.sf.query(soql, { + label: `services:internet:eligibility_details:${labelSuffix}`, + })) as SalesforceResponse>; + }; + + let res: SalesforceResponse>; + 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 | undefined) ?? undefined; if (!record) { return internetEligibilityDetailsSchema.parse({ @@ -461,6 +453,8 @@ export class InternetServicesService extends BaseServicesService { const requestedAtRaw = record[requestedAtField]; const checkedAtRaw = record[checkedAtField]; + const notesRaw = record[notesField]; + const requestIdRaw = record[caseIdField]; const requestedAt = typeof requestedAtRaw === "string" @@ -478,17 +472,23 @@ export class InternetServicesService extends BaseServicesService { return internetEligibilityDetailsSchema.parse({ status, eligibility, - requestId: null, // Always null as field is not used + requestId: + typeof requestIdRaw === "string" && requestIdRaw.trim().length > 0 + ? requestIdRaw.trim() + : null, requestedAt, 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() // which links the Case to the Opportunity - private async updateAccountEligibilityRequestState(sfAccountId: string): Promise { + private async updateAccountEligibilityRequestState( + sfAccountId: string, + params?: { caseId?: string; notes?: string } + ): Promise { const statusField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? "Internet_Eligibility_Status__c", @@ -499,17 +499,45 @@ export class InternetServicesService extends BaseServicesService { "Internet_Eligibility_Request_Date_Time__c", "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" ); + const caseIdField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ?? + "Internet_Eligibility_Case_Id__c", + "ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD" + ); + const notesField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ?? + "Internet_Eligibility_Notes__c", + "ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD" + ); const update = this.sf.sobject("Account")?.update; if (!update) { throw new Error("Salesforce Account update method not available"); } - await update({ + const basePayload: { Id: string } & Record = { Id: sfAccountId, [statusField]: "Pending", [requestedAtField]: new Date().toISOString(), - }); + }; + const extendedPayload: { Id: string } & Record = { ...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); + } } } diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index a8e229a2..f871a2aa 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -18,6 +18,7 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien import { MappingsService } from "@bff/modules/id-mappings/mappings.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 { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { EmailService } from "@bff/infra/email/email.service.js"; import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import type { @@ -225,16 +226,35 @@ export class InternetCancellationService { this.logger.warn("Could not find Opportunity for subscription", { subscriptionId }); } - // Create Salesforce Case for cancellation - const caseId = await this.caseService.createCancellationCase({ + // Build description with all form data + 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, opportunityId: opportunityId || undefined, - whmcsServiceId: subscriptionId, - productType: "Internet", - cancellationMonth: request.cancellationMonth, - cancellationDate, - alternativeEmail: request.alternativeEmail || undefined, - comments: request.comments, + subject: `Cancellation Request - Internet (${request.cancellationMonth})`, + description: descriptionLines.join("\n"), + origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, + priority: "High", }); this.logger.log("Cancellation case created", { diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts index 9cdc9f4c..33c28252 100644 --- a/apps/bff/src/modules/support/support.service.ts +++ b/apps/bff/src/modules/support/support.service.ts @@ -10,6 +10,7 @@ import { type CreateCaseResponse, type PublicContactRequest, } 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 { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -110,9 +111,9 @@ export class SupportService { const result = await this.caseService.createCase({ subject: request.subject, description: request.description, - category: request.category, priority: request.priority, accountId, + origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT, }); this.logger.log("Support case created", { diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 075df65e..29f238b2 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -3,6 +3,8 @@ import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.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 { assertSalesforceId, assertSoqlFieldName, @@ -34,6 +36,7 @@ export class ResidenceCardService { constructor( private readonly sf: SalesforceConnection, private readonly mappings: MappingsService, + private readonly caseService: SalesforceCaseService, private readonly config: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} @@ -98,7 +101,9 @@ export class ResidenceCardService { : null; const fileMeta = - status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId); + status === "not_submitted" + ? null + : await this.getLatestIdVerificationFileMetadata(sfAccountId); const payload = { status, @@ -149,6 +154,33 @@ export class ResidenceCardService { } 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 extension = extname(params.filename || "").replace(/^\./, ""); const title = basename(params.filename || "residence-card", extension ? `.${extension}` : ""); @@ -159,26 +191,33 @@ export class ResidenceCardService { } try { + // Attach file directly to the Case (not Account) so CS can see it in the Case's Files const result = await create({ Title: title || "residence-card", PathOnClient: params.filename || "residence-card", VersionData: versionData, - FirstPublishLocationId: sfAccountId, + FirstPublishLocationId: caseId, // Attach to Case, not Account }); - const id = (result as { id?: unknown })?.id; - if (typeof id !== "string" || id.trim().length === 0) { + const contentVersionId = (result as { id?: unknown })?.id; + if (typeof contentVersionId !== "string" || contentVersionId.trim().length === 0) { throw new DomainHttpException( ErrorCode.EXTERNAL_SERVICE_ERROR, HttpStatus.SERVICE_UNAVAILABLE ); } + + this.logger.log("ID verification file attached to Case", { + caseIdTail: caseId.slice(-4), + contentVersionId, + filename: params.filename, + }); } catch (error) { if (error instanceof DomainHttpException) { throw error; } this.logger.error("Failed to upload residence card to Salesforce Files", { userId: params.userId, - sfAccountIdTail: sfAccountId.slice(-4), + caseIdTail: caseId.slice(-4), error: extractErrorMessage(error), }); 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; mimeType: string | null; sizeBytes: number | null; submittedAt: string | null; } | null> { 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 = ` SELECT ContentDocumentId FROM ContentDocumentLink - WHERE LinkedEntityId = '${accountId}' + WHERE LinkedEntityId = '${caseId}' ORDER BY SystemModstamp DESC LIMIT 1 `; @@ -292,7 +354,7 @@ export class ResidenceCardService { submittedAt, }; } 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), error: extractErrorMessage(error), }); diff --git a/apps/portal/next-env.d.ts b/apps/portal/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/portal/next-env.d.ts +++ b/apps/portal/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/domain/package.json b/packages/domain/package.json index c0369a14..041c060d 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -128,6 +128,6 @@ }, "devDependencies": { "typescript": "catalog:", - "zod": "^4.2.1" + "zod": "catalog:" } } diff --git a/packages/domain/support/contract.ts b/packages/domain/support/contract.ts index daf8efa0..5a34260c 100644 --- a/packages/domain/support/contract.ts +++ b/packages/domain/support/contract.ts @@ -69,6 +69,6 @@ export const SUPPORT_CASE_CATEGORY = { } 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; diff --git a/packages/domain/support/providers/salesforce/mapper.ts b/packages/domain/support/providers/salesforce/mapper.ts index e87de79d..be2530b1 100644 --- a/packages/domain/support/providers/salesforce/mapper.ts +++ b/packages/domain/support/providers/salesforce/mapper.ts @@ -123,7 +123,7 @@ export function buildCaseSelectFields(additionalFields: string[] = []): string[] * Build a SOQL query for fetching cases for an account. * * @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 * @returns SOQL query string */ diff --git a/packages/domain/support/providers/salesforce/raw.types.ts b/packages/domain/support/providers/salesforce/raw.types.ts index df7c1456..29beb4ea 100644 --- a/packages/domain/support/providers/salesforce/raw.types.ts +++ b/packages/domain/support/providers/salesforce/raw.types.ts @@ -168,8 +168,11 @@ export type SalesforceCaseCreatePayload = z.infer Object Manager > Case > Fields > Case Origin */ export const SALESFORCE_CASE_ORIGIN = { - // Portal origin (used for portal-created cases) - PORTAL_WEBSITE: "Portal Website", + // Customer-visible support cases created from the portal support UI + PORTAL_SUPPORT: "Portal Support", + + // Internal portal workflow cases (eligibility, verification, cancellations) + PORTAL_NOTIFICATION: "Portal Notification", // Phone/Email origins PHONE: "電話", // Japanese: Phone diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5793688..e59b3094 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,8 +14,8 @@ catalogs: specifier: 5.9.3 version: 5.9.3 zod: - specifier: 4.1.13 - version: 4.1.13 + specifier: 4.2.1 + version: 4.2.1 overrides: js-yaml: ">=4.1.1" @@ -64,7 +64,7 @@ importers: dependencies: "@customer-portal/domain": specifier: workspace:* - version: file:packages/domain(zod@4.1.13) + version: link:../../packages/domain "@nestjs/bullmq": 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) @@ -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) nestjs-zod: 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: specifier: ^9.0.1 version: 9.0.1 @@ -151,7 +151,7 @@ importers: version: 5.0.1(express@5.1.0) zod: specifier: "catalog:" - version: 4.1.13 + version: 4.2.1 devDependencies: "@nestjs/cli": specifier: ^11.0.14 @@ -260,7 +260,7 @@ importers: specifier: "catalog:" version: 5.9.3 zod: - specifier: ^4.2.1 + specifier: "catalog:" version: 4.2.1 packages: @@ -508,11 +508,6 @@ packages: } 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": resolution: { @@ -7513,12 +7508,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.1.13: - resolution: - { - integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==, - } - zod@4.2.1: resolution: { @@ -7729,10 +7718,6 @@ snapshots: "@colors/colors@1.5.0": optional: true - "@customer-portal/domain@file:packages/domain(zod@4.1.13)": - dependencies: - zod: 4.1.13 - "@discoveryjs/json-ext@0.5.7": {} "@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)": @@ -10757,12 +10742,12 @@ snapshots: pino-http: 11.0.0 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: "@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 rxjs: 7.8.2 - zod: 4.1.13 + zod: 4.2.1 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) @@ -11928,8 +11913,6 @@ snapshots: dependencies: zod: 4.2.1 - zod@4.1.13: {} - zod@4.2.1: {} zustand@5.0.9(@types/react@19.2.7)(react@19.2.3): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29367b4f..2a87c641 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,4 +5,4 @@ packages: catalog: "@types/node": 24.10.3 typescript: 5.9.3 - zod: 4.1.13 + zod: 4.2.1