- Replace animated blob hero background with dot grid pattern - Add gradient bleed transitions between all landing page sections - Apply same gradient bleed technique to About page sections - Remove unused blob-float animations from globals.css - Make Trust and Values sections full-width for visual consistency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
import { FreebitClientService } from "./freebit-client.service.js";
|
|
import { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js";
|
|
import { FreebitAccountService } from "./freebit-account.service.js";
|
|
import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service.js";
|
|
import type {
|
|
FreebitVoiceOptionSettings,
|
|
FreebitVoiceOptionRequest,
|
|
FreebitVoiceOptionResponse,
|
|
FreebitContractLineChangeRequest,
|
|
FreebitContractLineChangeResponse,
|
|
} from "../interfaces/freebit.types.js";
|
|
|
|
export interface VoiceFeatures {
|
|
voiceMailEnabled?: boolean;
|
|
callWaitingEnabled?: boolean;
|
|
internationalRoamingEnabled?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Service for Freebit voice features and network type operations.
|
|
* Handles PA05-06 (voice options) and PA05-38 (network type) operations.
|
|
*/
|
|
@Injectable()
|
|
export class FreebitVoiceService {
|
|
constructor(
|
|
private readonly client: FreebitClientService,
|
|
private readonly rateLimiter: FreebitRateLimiterService,
|
|
private readonly accountService: FreebitAccountService,
|
|
@Inject(Logger) private readonly logger: Logger,
|
|
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService
|
|
) {}
|
|
|
|
/**
|
|
* Update SIM features (voice options and network type)
|
|
*
|
|
* IMPORTANT TIMING CONSTRAINTS from Freebit API:
|
|
* - PA05-06 (voice features): Runs with immediate effect
|
|
* - PA05-38 (contract line): Runs with immediate effect
|
|
* - These must run 30 minutes apart to avoid canceling each other
|
|
*/
|
|
async updateSimFeatures(
|
|
account: string,
|
|
features: {
|
|
voiceMailEnabled?: boolean;
|
|
callWaitingEnabled?: boolean;
|
|
internationalRoamingEnabled?: boolean;
|
|
networkType?: "4G" | "5G";
|
|
}
|
|
): Promise<void> {
|
|
try {
|
|
const voiceFeatures = {
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
};
|
|
|
|
const hasVoiceFeatures = Object.values(voiceFeatures).some(
|
|
value => typeof value === "boolean"
|
|
);
|
|
const hasNetworkTypeChange = typeof features.networkType === "string";
|
|
|
|
// Freebit API requires voice features and network type changes to be 30 minutes apart.
|
|
if (hasVoiceFeatures && hasNetworkTypeChange) {
|
|
throw new BadRequestException(
|
|
"Cannot update voice features and network type simultaneously. " +
|
|
"Voice and network type changes must be 30 minutes apart per Freebit API requirements. " +
|
|
"Use SimManagementQueueService to schedule the network type change after voice updates."
|
|
);
|
|
}
|
|
|
|
// Validate that at least one feature is specified
|
|
if (!hasVoiceFeatures && !hasNetworkTypeChange) {
|
|
throw new BadRequestException(
|
|
"No features specified for update. Please provide at least one of: " +
|
|
"voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, or networkType."
|
|
);
|
|
}
|
|
|
|
if (hasVoiceFeatures) {
|
|
await this.updateVoiceFeatures(account, voiceFeatures);
|
|
this.logger.log(`Voice features updated successfully`, {
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
});
|
|
} else if (hasNetworkTypeChange) {
|
|
await this.updateNetworkType(account, features.networkType!);
|
|
this.logger.log(`Network type updated successfully`, {
|
|
account,
|
|
networkType: features.networkType,
|
|
});
|
|
}
|
|
|
|
this.logger.log(`Successfully updated SIM features for account ${account}`, {
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
networkType: features.networkType,
|
|
});
|
|
} catch (error) {
|
|
const message = extractErrorMessage(error);
|
|
this.logger.error(`Failed to update SIM features for account ${account}`, {
|
|
account,
|
|
features,
|
|
error: message,
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
throw new BadRequestException(`Failed to update SIM features: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update voice features (voicemail, call waiting, international roaming)
|
|
* Uses PA05-06 MVNO Voice Option Change endpoint - runs with immediate effect
|
|
*/
|
|
async updateVoiceFeatures(account: string, features: VoiceFeatures): Promise<void> {
|
|
try {
|
|
await this.rateLimiter.executeWithSpacing(account, "voice", async () => {
|
|
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
|
|
const talkOption: FreebitVoiceOptionSettings = {};
|
|
|
|
if (typeof features.voiceMailEnabled === "boolean") {
|
|
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
|
}
|
|
|
|
if (typeof features.callWaitingEnabled === "boolean") {
|
|
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
|
}
|
|
|
|
if (typeof features.internationalRoamingEnabled === "boolean") {
|
|
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
|
if (features.internationalRoamingEnabled) {
|
|
talkOption.worldWingCreditLimit = "50000";
|
|
}
|
|
}
|
|
|
|
if (Object.keys(talkOption).length === 0) {
|
|
throw new BadRequestException("No voice options specified for update");
|
|
}
|
|
|
|
return {
|
|
account,
|
|
userConfirmed: "10",
|
|
aladinOperated: "10",
|
|
talkOption,
|
|
};
|
|
};
|
|
|
|
const voiceOptionPayload = buildVoiceOptionPayload();
|
|
|
|
this.logger.debug(
|
|
"Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)",
|
|
{
|
|
account,
|
|
payload: voiceOptionPayload,
|
|
}
|
|
);
|
|
|
|
await this.client.makeAuthenticatedRequest<
|
|
FreebitVoiceOptionResponse,
|
|
typeof voiceOptionPayload
|
|
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
|
|
|
|
this.logger.log("Voice option change completed via PA05-06", {
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
});
|
|
|
|
// Save to database for future retrieval
|
|
if (this.voiceOptionsService) {
|
|
try {
|
|
await this.voiceOptionsService.saveVoiceOptions(account, features);
|
|
} catch (dbError) {
|
|
this.logger.warn("Failed to save voice options to database (non-fatal)", {
|
|
account,
|
|
error: extractErrorMessage(dbError),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
const message = extractErrorMessage(error);
|
|
this.logger.error(`Failed to update voice features for account ${account}`, {
|
|
account,
|
|
features,
|
|
error: message,
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
throw new BadRequestException(`Failed to update voice features: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update network type (4G/5G)
|
|
* Uses PA05-38 contract line change - runs with immediate effect
|
|
* NOTE: Must be called 30 minutes after PA05-06 if both are being updated
|
|
*/
|
|
async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise<void> {
|
|
let eid: string | undefined;
|
|
let productNumber: string | undefined;
|
|
// PA05-38 may require MSISDN (phone number) instead of internal account ID
|
|
let apiAccount = account;
|
|
|
|
try {
|
|
try {
|
|
const details = await this.accountService.getSimDetails(account);
|
|
if (details.eid) {
|
|
eid = details.eid;
|
|
} else if (details.iccid) {
|
|
productNumber = details.iccid;
|
|
}
|
|
// Use MSISDN if available, as PA05-38 expects phone number format
|
|
if (details.msisdn && details.msisdn.length >= 10) {
|
|
apiAccount = details.msisdn;
|
|
}
|
|
this.logger.debug(`Resolved SIM identifiers for contract line change`, {
|
|
originalAccount: account,
|
|
apiAccount,
|
|
eid,
|
|
productNumber,
|
|
msisdn: details.msisdn,
|
|
currentNetworkType: details.networkType,
|
|
});
|
|
if (details.networkType?.toUpperCase() === networkType.toUpperCase()) {
|
|
this.logger.log(
|
|
`Network type already ${networkType} for account ${account}; skipping update.`,
|
|
{
|
|
account,
|
|
networkType,
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
} catch (resolveError) {
|
|
this.logger.warn(`Unable to resolve SIM identifiers before contract line change`, {
|
|
account,
|
|
error: extractErrorMessage(resolveError),
|
|
});
|
|
}
|
|
|
|
await this.rateLimiter.executeWithSpacing(account, "network", async () => {
|
|
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
|
|
account: apiAccount,
|
|
contractLine: networkType,
|
|
...(eid ? { eid } : {}),
|
|
...(productNumber ? { productNumber } : {}),
|
|
};
|
|
|
|
this.logger.debug(`Updating network type via PA05-38`, {
|
|
originalAccount: account,
|
|
apiAccount,
|
|
networkType,
|
|
request,
|
|
});
|
|
|
|
// PA05-38 uses form-urlencoded format (json={...}), not pure JSON
|
|
const response = await this.client.makeAuthenticatedRequest<
|
|
FreebitContractLineChangeResponse,
|
|
typeof request
|
|
>("/mvno/contractline/change/", request);
|
|
|
|
this.logger.log(`Successfully updated network type for account ${account}`, {
|
|
account,
|
|
networkType,
|
|
resultCode: response.resultCode,
|
|
statusCode: response.status?.statusCode,
|
|
message: response.status?.message,
|
|
});
|
|
|
|
// Save to database for future retrieval
|
|
if (this.voiceOptionsService) {
|
|
try {
|
|
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
|
|
} catch (dbError) {
|
|
this.logger.warn("Failed to save network type to database (non-fatal)", {
|
|
account,
|
|
error: extractErrorMessage(dbError),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
const message = extractErrorMessage(error);
|
|
this.logger.error(`Failed to update network type for account ${account}`, {
|
|
account,
|
|
networkType,
|
|
error: message,
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
throw new BadRequestException(`Failed to update network type: ${message}`);
|
|
}
|
|
}
|
|
}
|