Comprehensive refactoring across 70 files (net -298 lines) improving type safety, error handling, and code organization: - Replace .passthrough()/.catchall(z.unknown()) with .strip() in all Zod schemas - Tighten Record<string, unknown> to bounded union types where possible - Replace throw new Error with domain-specific exceptions (OrderException, FulfillmentException, WhmcsOperationException, SalesforceOperationException, etc.) - Split AuthTokenService (625 lines) into TokenGeneratorService and TokenRefreshService with thin orchestrator - Deduplicate FreebitClientService with shared makeRequest() method - Add typed interfaces to WHMCS facade, order service, and fulfillment mapper - Externalize hardcoded config values to ConfigService with env fallbacks - Consolidate duplicate billing cycle enums into shared billingCycleSchema - Standardize logger usage (nestjs-pino @Inject(Logger) everywhere) - Move shared WHMCS number coercion helpers to whmcs-utils/schema.ts
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
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<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) {
|
|
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 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<string, string> = {};
|
|
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<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 InternalServerErrorException("Salesforce Account update method not available");
|
|
}
|
|
|
|
const basePayload: { Id: string } & Record<string, unknown> = {
|
|
Id: sfAccountId,
|
|
[statusField]: "Pending",
|
|
[requestedAtField]: new Date().toISOString(),
|
|
};
|
|
await update(basePayload);
|
|
}
|
|
}
|