import { Injectable, Inject, BadRequestException } from "@nestjs/common"; 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 { ServicesCacheService } from "./services-cache.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 { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { assertSalesforceId, assertSoqlFieldName, } from "@bff/integrations/salesforce/utils/soql.util.js"; import type { InternetEligibilityDetails, InternetEligibilityStatus, } from "@customer-portal/domain/services"; import { internetEligibilityDetailsSchema } from "@customer-portal/domain/services"; import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; function formatAddressForLog(address: Record): string { const address1 = typeof address["address1"] === "string" ? address["address1"].trim() : ""; const address2 = typeof address["address2"] === "string" ? address["address2"].trim() : ""; const city = typeof address["city"] === "string" ? address["city"].trim() : ""; const state = typeof address["state"] === "string" ? address["state"].trim() : ""; const postcode = typeof address["postcode"] === "string" ? address["postcode"].trim() : ""; const country = typeof address["country"] === "string" ? address["country"].trim() : ""; return [address1, address2, city, state, postcode, country].filter(Boolean).join(", "); } @Injectable() export class InternetEligibilityService { constructor( private readonly sf: SalesforceConnection, private readonly config: ConfigService, @Inject(Logger) private readonly logger: Logger, private readonly mappingsService: MappingsService, private readonly catalogCache: ServicesCacheService, private readonly opportunityResolution: OpportunityResolutionService, private readonly caseService: SalesforceCaseService ) {} async getEligibilityForUser(userId: string): Promise { const details = await this.getEligibilityDetailsForUser(userId); return details.eligibility; } async getEligibilityDetailsForUser(userId: string): Promise { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.sfAccountId) { return internetEligibilityDetailsSchema.parse({ status: "not_requested", eligibility: null, requestId: null, requestedAt: null, checkedAt: null, notes: null, }); } const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); // Explicitly define the validator to handle potential malformed cache data // If the cache returns undefined or missing fields, we treat it as a cache miss or malformed data // and force a re-fetch or ensure safe defaults are applied. return this.catalogCache .getCachedEligibility(eligibilityKey, async () => this.queryEligibilityDetails(sfAccountId) ) .then(async data => { // Safety check: ensure the data matches the schema before returning. // This protects against cache corruption (e.g. missing fields treated as undefined). const result = internetEligibilityDetailsSchema.safeParse(data); if (!result.success) { this.logger.warn("Cached eligibility data was malformed, treating as cache miss", { userId, sfAccountId, errors: result.error.format(), }); // Invalidate bad cache and re-fetch this.catalogCache.invalidateEligibility(sfAccountId).catch((error: unknown) => this.logger.error("Failed to invalidate malformed eligibility cache", { error: extractErrorMessage(error), }) ); return this.queryEligibilityDetails(sfAccountId); } return result.data; }); } async requestEligibilityCheckForUser( userId: string, request: InternetEligibilityCheckRequest ): Promise { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.sfAccountId) { throw new Error("No Salesforce mapping found for current user"); } const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); if ( !request.address || !request.address.address1 || !request.address.city || !request.address.postcode ) { throw new BadRequestException("Service address is required to request eligibility review."); } try { const subject = "Internet availability check request (Portal)"; // 1) Find or create Opportunity for Internet eligibility (this service remains locked internally) const { opportunityId, wasCreated: opportunityCreated } = await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); // 2) Build case description const instanceUrl = this.sf.getInstanceUrl(); const opportunityLink = instanceUrl ? `${instanceUrl}/lightning/r/Opportunity/${opportunityId}/view` : null; const opportunityStatus = opportunityCreated ? "Created new opportunity for this request" : "Linked to existing opportunity"; const descriptionLines: string[] = [ "Customer requested to check if internet service is available at the following address:", "", request.address ? formatAddressForLog(request.address) : "", "", opportunityLink ? `Opportunity: ${opportunityLink}` : "", `Opportunity Status: ${opportunityStatus}`, ].filter(Boolean); // 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, }); // 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; } catch (error) { this.logger.error("Failed to create eligibility request", { userId, sfAccountId, error: extractErrorMessage(error), }); throw new Error("Failed to request availability check. Please try again later."); } } checkPlanEligibility(planOfferingType: string | null | undefined, eligibility: string): boolean { // Simple match: user's eligibility field must equal plan's Salesforce offering type // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" return planOfferingType === eligibility; } private async queryEligibilityDetails(sfAccountId: string): Promise { const eligibilityField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c", "ACCOUNT_INTERNET_ELIGIBILITY_FIELD" ); const statusField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? "Internet_Eligibility_Status__c", "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD" ); const requestedAtField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ?? "Internet_Eligibility_Request_Date_Time__c", "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" ); const checkedAtField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD") ?? "Internet_Eligibility_Checked_Date_Time__c", "ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD" ); const selectBase = `Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}`; 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>; }; const res = await queryAccount(selectBase, "base"); const record = res.records?.[0] ?? undefined; if (!record) { return internetEligibilityDetailsSchema.parse({ status: "not_requested", eligibility: null, requestId: null, requestedAt: null, checkedAt: null, notes: null, }); } const eligibilityRaw = record[eligibilityField]; const eligibility = typeof eligibilityRaw === "string" && eligibilityRaw.trim().length > 0 ? eligibilityRaw.trim() : null; const statusRaw = record[statusField]; const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; const statusMap: Record = { pending: "pending", checking: "pending", eligible: "eligible", ineligible: "ineligible", "not available": "ineligible", }; const status: InternetEligibilityStatus = statusMap[normalizedStatus] ?? (eligibility ? "eligible" : "not_requested"); const requestedAtRaw = record[requestedAtField]; const checkedAtRaw = record[checkedAtField]; // Normalize datetime strings to ISO 8601 with Z suffix (Salesforce may return +0000 offset) const normalizeDateTime = (value: unknown): string | null => { if (value instanceof Date) { return value.toISOString(); } if (typeof value === "string" && value.trim()) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } return null; }; const requestedAt = normalizeDateTime(requestedAtRaw); const checkedAt = normalizeDateTime(checkedAtRaw); return internetEligibilityDetailsSchema.parse({ status, eligibility, requestId: null, requestedAt, checkedAt, notes: null, }); } private async updateAccountEligibilityRequestState(sfAccountId: string): Promise { const statusField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? "Internet_Eligibility_Status__c", "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD" ); const requestedAtField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ?? "Internet_Eligibility_Request_Date_Time__c", "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" ); const update = this.sf.sobject("Account")?.update; if (!update) { throw new Error("Salesforce Account update method not available"); } const basePayload: { Id: string } & Record = { Id: sfAccountId, [statusField]: "Pending", [requestedAtField]: new Date().toISOString(), }; await update(basePayload); } }