Assist_Design/apps/bff/src/modules/services/application/internet-eligibility.service.ts
barsa b206de8dba refactor: enterprise-grade cleanup of BFF and domain packages
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
2026-02-24 19:05:30 +09:00

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);
}
}