- Deleted migration file that removed cached profile fields from the users table, centralizing profile data retrieval from WHMCS. - Updated CsrfMiddleware to include new public authentication endpoints for password reset, setting password, and WHMCS account linking. - Enhanced error handling in password and WHMCS linking workflows to provide clearer feedback on missing mappings and improve user experience. - Adjusted user creation and update methods in UsersFacade to handle cases where WHMCS mappings are not yet available, ensuring smoother account setup.
243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
import { Injectable, Inject } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import type {
|
|
FreebitAccountDetailsResponse,
|
|
FreebitTrafficInfoResponse,
|
|
FreebitQuotaHistoryResponse,
|
|
SimDetails,
|
|
SimUsage,
|
|
SimTopUpHistory,
|
|
} from "../interfaces/freebit.types";
|
|
import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service";
|
|
|
|
@Injectable()
|
|
export class FreebitMapperService {
|
|
constructor(
|
|
@Inject(Logger) private readonly logger: Logger,
|
|
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService
|
|
) {}
|
|
|
|
private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean {
|
|
// If value is undefined or null, return the default
|
|
if (value === undefined || value === null) {
|
|
return defaultValue;
|
|
}
|
|
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
if (typeof value === "number") {
|
|
return value === 10 || value === 1;
|
|
}
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "on" || normalized === "true") {
|
|
return true;
|
|
}
|
|
if (normalized === "off" || normalized === "false") {
|
|
return false;
|
|
}
|
|
const numeric = Number(normalized);
|
|
if (!Number.isNaN(numeric)) {
|
|
return numeric === 10 || numeric === 1;
|
|
}
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Map SIM status from Freebit API to domain status
|
|
*/
|
|
mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" {
|
|
switch (status) {
|
|
case "active":
|
|
return "active";
|
|
case "suspended":
|
|
return "suspended";
|
|
case "temporary":
|
|
case "waiting":
|
|
return "pending";
|
|
case "obsolete":
|
|
return "cancelled";
|
|
default:
|
|
return "pending";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map Freebit account details response to SimDetails
|
|
*/
|
|
async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise<SimDetails> {
|
|
const account = response.responseDatas[0];
|
|
if (!account) {
|
|
throw new Error("No account data in response");
|
|
}
|
|
|
|
let simType: "standard" | "nano" | "micro" | "esim" = "standard";
|
|
if (account.eid) {
|
|
simType = "esim";
|
|
} else if (account.simSize) {
|
|
simType = account.simSize;
|
|
}
|
|
|
|
// Try to get voice options from database first
|
|
let voiceMailEnabled = true;
|
|
let callWaitingEnabled = true;
|
|
let internationalRoamingEnabled = true;
|
|
let networkType = String(account.networkType ?? account.contractLine ?? "4G");
|
|
|
|
if (this.voiceOptionsService) {
|
|
try {
|
|
const storedOptions = await this.voiceOptionsService.getVoiceOptions(
|
|
String(account.account ?? "")
|
|
);
|
|
|
|
if (storedOptions) {
|
|
voiceMailEnabled = storedOptions.voiceMailEnabled;
|
|
callWaitingEnabled = storedOptions.callWaitingEnabled;
|
|
internationalRoamingEnabled = storedOptions.internationalRoamingEnabled;
|
|
networkType = storedOptions.networkType;
|
|
|
|
this.logger.debug("[FreebitMapper] Loaded voice options from database", {
|
|
account: account.account,
|
|
options: storedOptions,
|
|
});
|
|
} else {
|
|
// No stored options, check API response
|
|
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, true);
|
|
callWaitingEnabled = this.parseOptionFlag(
|
|
account.callwaiting ?? account.callWaiting,
|
|
true
|
|
);
|
|
internationalRoamingEnabled = this.parseOptionFlag(
|
|
account.worldwing ?? account.worldWing,
|
|
true
|
|
);
|
|
|
|
this.logger.debug(
|
|
"[FreebitMapper] No stored options found, using defaults or API values",
|
|
{
|
|
account: account.account,
|
|
voiceMailEnabled,
|
|
callWaitingEnabled,
|
|
internationalRoamingEnabled,
|
|
}
|
|
);
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn("[FreebitMapper] Failed to load voice options from database", {
|
|
account: account.account,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
account: String(account.account ?? ""),
|
|
status: this.mapSimStatus(String(account.state ?? account.status ?? "pending")),
|
|
planCode: String(account.planCode ?? ""),
|
|
planName: String(account.planName ?? ""),
|
|
simType,
|
|
iccid: String(account.iccid ?? ""),
|
|
eid: String(account.eid ?? ""),
|
|
msisdn: String(account.msisdn ?? account.account ?? ""),
|
|
imsi: String(account.imsi ?? ""),
|
|
remainingQuotaMb: Number(account.remainingQuotaMb ?? account.quota ?? 0),
|
|
remainingQuotaKb: Number(account.remainingQuotaKb ?? 0),
|
|
voiceMailEnabled,
|
|
callWaitingEnabled,
|
|
internationalRoamingEnabled,
|
|
networkType,
|
|
activatedAt: account.startDate ? String(account.startDate) : undefined,
|
|
expiresAt: account.async ? String(account.async.date) : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map Freebit traffic info response to SimUsage
|
|
*/
|
|
mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage {
|
|
if (!response.traffic) {
|
|
throw new Error("No traffic data in response");
|
|
}
|
|
|
|
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
|
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
|
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
|
usageKb: parseInt(usage, 10) || 0,
|
|
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
|
|
}));
|
|
|
|
return {
|
|
account: String(response.account ?? ""),
|
|
todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100,
|
|
todayUsageKb,
|
|
recentDaysUsage: recentDaysData,
|
|
isBlacklisted: response.traffic.blackList === "10",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map Freebit quota history response to SimTopUpHistory
|
|
*/
|
|
mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory {
|
|
if (!response.quotaHistory) {
|
|
throw new Error("No history data in response");
|
|
}
|
|
|
|
return {
|
|
account,
|
|
totalAdditions: Number(response.total) || 0,
|
|
additionCount: Number(response.count) || 0,
|
|
history: response.quotaHistory.map(item => ({
|
|
quotaKb: parseInt(item.quota, 10),
|
|
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
|
|
addedDate: item.date,
|
|
expiryDate: item.expire,
|
|
campaignCode: item.quotaCode,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize account identifier (remove formatting)
|
|
*/
|
|
normalizeAccount(account: string): string {
|
|
return account.replace(/[-\s()]/g, "");
|
|
}
|
|
|
|
/**
|
|
* Validate account format
|
|
*/
|
|
validateAccount(account: string): boolean {
|
|
const normalized = this.normalizeAccount(account);
|
|
// Basic validation - should be digits, typically 10-11 digits for Japanese phone numbers
|
|
return /^\d{10,11}$/.test(normalized);
|
|
}
|
|
|
|
/**
|
|
* Format date for Freebit API (YYYYMMDD)
|
|
*/
|
|
formatDateForApi(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}${month}${day}`;
|
|
}
|
|
|
|
/**
|
|
* Parse date from Freebit API format (YYYYMMDD)
|
|
*/
|
|
parseDateFromApi(dateString: string): Date | null {
|
|
if (!/^\d{8}$/.test(dateString)) {
|
|
return null;
|
|
}
|
|
|
|
const year = parseInt(dateString.substring(0, 4), 10);
|
|
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
|
|
const day = parseInt(dateString.substring(6, 8), 10);
|
|
|
|
return new Date(year, month, day);
|
|
}
|
|
}
|