import { Injectable, Inject, BadRequestException, InternalServerErrorException, } 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 { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js"; 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"; @Injectable() export class InternetEligibilityService { // eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor 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 workflowCases: WorkflowCaseManager ) {} 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) { 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 BadRequestException("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 { // 1) Find or create Opportunity for Internet eligibility const { opportunityId, wasCreated: opportunityCreated } = await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); // 2) Create eligibility check case via WorkflowCaseManager // Build address object conditionally to avoid exactOptionalPropertyTypes issues const eligibilityAddress: Record = {}; if (request.address.address1) eligibilityAddress["address1"] = request.address.address1; if (request.address.address2) eligibilityAddress["address2"] = request.address.address2; if (request.address.city) eligibilityAddress["city"] = request.address.city; if (request.address.state) eligibilityAddress["state"] = request.address.state; if (request.address.postcode) eligibilityAddress["postcode"] = request.address.postcode; if (request.address.country) eligibilityAddress["country"] = request.address.country; // Note: streetAddress not passed in this flow (basic address type) - duplicate detection will be skipped await this.workflowCases.notifyEligibilityCheck({ accountId: sfAccountId, opportunityId, opportunityCreated, address: eligibilityAddress as { address1?: string; address2?: string; city?: string; state?: string; postcode?: string; country?: string; }, }); // 3) Update Account eligibility status await this.updateAccountEligibilityRequestState(sfAccountId); await this.catalogCache.invalidateEligibility(sfAccountId); this.logger.log("Eligibility check request submitted", { userId, sfAccountIdTail: sfAccountId.slice(-4), opportunityIdTail: opportunityId.slice(-4), opportunityCreated, }); // Return the opportunity ID as the request identifier return opportunityId; } catch (error) { this.logger.error("Failed to create eligibility request", { userId, sfAccountId, error: extractErrorMessage(error), }); throw new InternalServerErrorException( "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 InternalServerErrorException("Salesforce Account update method not available"); } const basePayload: { Id: string } & Record = { Id: sfAccountId, [statusField]: "Pending", [requestedAtField]: new Date().toISOString(), }; await update(basePayload); } }