2026-01-07 17:13:27 +09:00
|
|
|
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";
|
2026-01-15 11:28:25 +09:00
|
|
|
import {
|
|
|
|
|
assertSalesforceId,
|
|
|
|
|
assertSoqlFieldName,
|
|
|
|
|
} from "@bff/integrations/salesforce/utils/soql.util.js";
|
2026-01-07 17:13:27 +09:00
|
|
|
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 {
|
2026-01-15 11:28:25 +09:00
|
|
|
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() : "";
|
2026-01-07 17:13:27 +09:00
|
|
|
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)
|
|
|
|
|
)
|
2026-01-15 11:28:25 +09:00
|
|
|
.then(async data => {
|
2026-01-07 17:13:27 +09:00
|
|
|
// 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;
|
|
|
|
|
|
2026-01-15 17:33:23 +09:00
|
|
|
const opportunityStatus = opportunityCreated
|
|
|
|
|
? "Created new opportunity for this request"
|
|
|
|
|
: "Linked to existing opportunity";
|
|
|
|
|
|
2026-01-07 17:13:27 +09:00
|
|
|
const descriptionLines: string[] = [
|
|
|
|
|
"Customer requested to check if internet service is available at the following address:",
|
|
|
|
|
"",
|
|
|
|
|
request.address ? formatAddressForLog(request.address) : "",
|
2026-01-15 17:33:23 +09:00
|
|
|
"",
|
|
|
|
|
opportunityLink ? `Opportunity: ${opportunityLink}` : "",
|
|
|
|
|
`Opportunity Status: ${opportunityStatus}`,
|
2026-01-07 17:13:27 +09:00
|
|
|
].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");
|
2026-01-15 11:28:25 +09:00
|
|
|
const record = res.records?.[0] ?? undefined;
|
2026-01-07 17:13:27 +09:00
|
|
|
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() : "";
|
|
|
|
|
|
2026-01-15 11:28:25 +09:00
|
|
|
const statusMap: Record<string, InternetEligibilityStatus> = {
|
|
|
|
|
pending: "pending",
|
|
|
|
|
checking: "pending",
|
|
|
|
|
eligible: "eligible",
|
|
|
|
|
ineligible: "ineligible",
|
|
|
|
|
"not available": "ineligible",
|
|
|
|
|
};
|
2026-01-07 17:13:27 +09:00
|
|
|
const status: InternetEligibilityStatus =
|
2026-01-15 11:28:25 +09:00
|
|
|
statusMap[normalizedStatus] ?? (eligibility ? "eligible" : "not_requested");
|
2026-01-07 17:13:27 +09:00
|
|
|
|
|
|
|
|
const requestedAtRaw = record[requestedAtField];
|
|
|
|
|
const checkedAtRaw = record[checkedAtField];
|
|
|
|
|
|
feat: Enhance Public VPN Plans view with marketing content and new components
- Added detailed service highlights, how it works steps, and FAQs to the Public VPN Plans view.
- Introduced new components: CtaButton, FeaturedServiceCard, ProcessStep, ServiceCard, ServiceShowcaseCard, TrustBadge, TrustIndicators, HowItWorks, ServiceCTA, and ServiceFAQ for improved layout and functionality.
- Implemented a new design for the landing page with enhanced visuals and user engagement elements.
- Updated the VPN plans section to include a more informative and visually appealing layout.
2026-01-13 18:19:58 +09:00
|
|
|
// 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);
|
2026-01-07 17:13:27 +09:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|