Assist_Design/apps/bff/src/modules/services/application/internet-eligibility.service.ts

292 lines
12 KiB
TypeScript
Raw Normal View History

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, unknown>): 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<string | null> {
const details = await this.getEligibilityDetailsForUser(userId);
return details.eligibility;
}
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> {
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<InternetEligibilityDetails>(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<string> {
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 descriptionLines: string[] = [
"Customer requested to check if internet service is available at the following address:",
"",
request.address ? formatAddressForLog(request.address) : "",
opportunityLink ? `\n\nOpportunity: ${opportunityLink}` : "",
].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<InternetEligibilityDetails> {
const eligibilityField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c",
"ACCOUNT_INTERNET_ELIGIBILITY_FIELD"
);
const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c",
"ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
);
const requestedAtField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ??
"Internet_Eligibility_Request_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
);
const checkedAtField = assertSoqlFieldName(
this.config.get<string>("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<Record<string, unknown>>;
};
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<string, InternetEligibilityStatus> = {
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<void> {
const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c",
"ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
);
const requestedAtField = assertSoqlFieldName(
this.config.get<string>("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<string, unknown> = {
Id: sfAccountId,
[statusField]: "Pending",
[requestedAtField]: new Date().toISOString(),
};
await update(basePayload);
}
}