tema b400b982f3 Improve section transitions with gradient bleed effect
- 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>
2026-02-02 17:05:54 +09:00

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