Refactor WHMCS client handling to improve type safety and address management. Updated methods in WhmcsService and WhmcsClientService to return NormalizedWhmcsClient type, enhancing consistency. Simplified address retrieval logic and improved caching mechanisms in WhmcsCacheService. Additionally, introduced utility functions for custom field handling in WHMCS integration workflows, ensuring better data management and clarity.

This commit is contained in:
barsa 2025-10-20 16:26:47 +09:00
parent d041831434
commit 8a90003075
8 changed files with 402 additions and 44 deletions

View File

@ -5,7 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service";
import { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
import type { NormalizedWhmcsClient } from "../utils/whmcs-client.utils";
export interface CacheOptions {
ttl?: number;
@ -148,15 +148,15 @@ export class WhmcsCacheService {
* Get cached client data
* Returns WhmcsClient (type inferred from domain)
*/
async getClientData(clientId: number) {
async getClientData(clientId: number): Promise<NormalizedWhmcsClient | null> {
const key = this.buildClientKey(clientId);
return this.get(key, "client");
return this.get<NormalizedWhmcsClient>(key, "client");
}
/**
* Cache client data
*/
async setClientData(clientId: number, data: ReturnType<typeof CustomerProviders.Whmcs.transformWhmcsClientResponse>) {
async setClientData(clientId: number, data: NormalizedWhmcsClient) {
const key = this.buildClientKey(clientId);
await this.set(key, data, "client", [`client:${clientId}`]);
}

View File

@ -13,9 +13,9 @@ import type {
WhmcsValidateLoginResponse,
} from "@customer-portal/domain/customer";
import {
Providers as CustomerProviders,
type Address,
} from "@customer-portal/domain/customer";
normalizeWhmcsClientResponse,
type NormalizedWhmcsClient,
} from "../utils/whmcs-client.utils";
@Injectable()
export class WhmcsClientService {
@ -57,7 +57,7 @@ export class WhmcsClientService {
* Get client details by ID
* Returns WhmcsClient (type inferred from domain mapper)
*/
async getClientDetails(clientId: number) {
async getClientDetails(clientId: number): Promise<NormalizedWhmcsClient> {
try {
// Try cache first
const cached = await this.cacheService.getClientData(clientId);
@ -76,12 +76,11 @@ export class WhmcsClientService {
throw new NotFoundException(`Client ${clientId} not found`);
}
const customer = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
await this.cacheService.setClientData(clientId, customer);
const normalized = normalizeWhmcsClientResponse(response);
await this.cacheService.setClientData(normalized.id, normalized);
this.logger.log(`Fetched client details for client ${clientId}`);
return customer;
return normalized;
} catch (error) {
this.logger.error(`Failed to fetch client details for client ${clientId}`, {
error: getErrorMessage(error),
@ -94,7 +93,7 @@ export class WhmcsClientService {
* Get client details by email
* Returns WhmcsClient (type inferred from domain mapper)
*/
async getClientDetailsByEmail(email: string) {
async getClientDetailsByEmail(email: string): Promise<NormalizedWhmcsClient> {
try {
const response = await this.connectionService.getClientDetailsByEmail(email);
@ -106,12 +105,11 @@ export class WhmcsClientService {
throw new NotFoundException(`Client with email ${email} not found`);
}
const customer = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
await this.cacheService.setClientData(customer.id, customer);
const normalized = normalizeWhmcsClientResponse(response);
await this.cacheService.setClientData(normalized.id, normalized);
this.logger.log(`Fetched client details by email: ${email}`);
return customer;
return normalized;
} catch (error) {
this.logger.error(`Failed to fetch client details by email: ${email}`, {
error: getErrorMessage(error),

View File

@ -0,0 +1,286 @@
import {
addressSchema,
userSchema,
type Address,
type User,
type UserAuth,
} from "@customer-portal/domain/customer";
import type {
WhmcsClient,
WhmcsClientResponse,
WhmcsCustomField,
WhmcsUser,
} from "@customer-portal/domain/customer/providers/whmcs/raw.types";
type RawCustomFields = WhmcsClient["customfields"];
type RawUsers = WhmcsClient["users"];
const truthyStrings = new Set(["1", "true", "yes", "y", "on"]);
const falsyStrings = new Set(["0", "false", "no", "n", "off"]);
const toNumber = (value: unknown): number | null => {
if (value === undefined || value === null) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const toOptionalString = (value: unknown): string | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === "string") return value;
return String(value);
};
const toNullableBoolean = (value: unknown): boolean | null | undefined => {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 0 ? false : true;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (truthyStrings.has(normalized)) return true;
if (falsyStrings.has(normalized)) return false;
}
return undefined;
};
const isCustomFieldObject = (value: unknown): value is WhmcsCustomField =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRecordOfStrings = (value: unknown): value is Record<string, string> =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.values(value).every(v => typeof v === "string");
const normalizeCustomFields = (
raw: RawCustomFields
): Record<string, string> | undefined => {
if (!raw) return undefined;
if (Array.isArray(raw)) {
const map = raw.reduce<Record<string, string>>((acc, field) => {
if (!field) return acc;
const idKey = toOptionalString(field.id)?.trim();
const nameKey = toOptionalString(field.name)?.trim();
const value =
field.value === undefined || field.value === null
? ""
: String(field.value);
if (idKey) acc[idKey] = value;
if (nameKey) acc[nameKey] = value;
return acc;
}, {});
return Object.keys(map).length ? map : undefined;
}
if (isRecordOfStrings(raw)) {
const stringRecord = raw as Record<string, string>;
const map = Object.entries(stringRecord).reduce<Record<string, string>>(
(acc, [key, value]) => {
const trimmedKey = key.trim();
if (!trimmedKey) return acc;
acc[trimmedKey] = value;
return acc;
},
{}
);
return Object.keys(map).length ? map : undefined;
}
if (isCustomFieldObject(raw) && "customfield" in raw) {
const nested = raw.customfield;
if (!nested) return undefined;
if (Array.isArray(nested)) return normalizeCustomFields(nested);
if (isCustomFieldObject(nested)) return normalizeCustomFields([nested]);
}
return undefined;
};
const normalizeUsers = (raw: RawUsers): NormalizedWhmcsUser[] | undefined => {
if (!raw) return undefined;
const rawUsers: WhmcsUser[] = Array.isArray(raw)
? raw
: Array.isArray(raw.user)
? raw.user
: raw.user
? [raw.user]
: [];
if (rawUsers.length === 0) return undefined;
const normalized = rawUsers
.map<NormalizedWhmcsUser | null>(user => {
if (!user) return null;
const id = toNumber(user.id);
const name = toOptionalString(user.name);
const email = toOptionalString(user.email);
if (id === null || !name || !email) return null;
return {
id,
name,
email,
is_owner: toNullableBoolean(user.is_owner) ?? undefined,
};
})
.filter((user): user is NormalizedWhmcsUser => user !== null);
return normalized.length ? normalized : undefined;
};
export interface NormalizedWhmcsUser {
id: number;
name: string;
email: string;
is_owner?: boolean;
}
export interface NormalizedWhmcsClient
extends Omit<
WhmcsClient,
| "id"
| "client_id"
| "owner_user_id"
| "userid"
| "customfields"
| "users"
| "allowSingleSignOn"
| "email_verified"
| "marketing_emails_opt_in"
| "defaultpaymethodid"
| "currency"
> {
id: number;
client_id?: number | null;
owner_user_id?: number | null;
userid?: number | null;
customfields?: Record<string, string>;
users?: NormalizedWhmcsUser[];
allowSingleSignOn?: boolean | null;
email_verified?: boolean | null;
marketing_emails_opt_in?: boolean | null;
defaultpaymethodid: number | null;
currency: number | null;
address?: Address;
raw: WhmcsClient;
}
export const deriveAddressFromClient = (
client: WhmcsClient
): Address | undefined => {
const address = addressSchema.parse({
address1: client.address1 ?? null,
address2: client.address2 ?? null,
city: client.city ?? null,
state: client.fullstate ?? client.state ?? null,
postcode: client.postcode ?? null,
country: client.country ?? null,
countryCode: client.countrycode ?? null,
phoneNumber:
client.phonenumberformatted ?? client.phonenumber ?? client.telephoneNumber ?? null,
phoneCountryCode: client.phonecc ?? null,
});
const hasValues = Object.values(address).some(
value => value !== undefined && value !== null && value !== ""
);
return hasValues ? address : undefined;
};
export const normalizeWhmcsClient = (
rawClient: WhmcsClient
): NormalizedWhmcsClient => {
const id = toNumber(rawClient.id);
if (id === null) {
throw new Error("WHMCS client ID missing or invalid.");
}
const clientId = toNumber(rawClient.client_id);
const ownerUserId = toNumber(rawClient.owner_user_id);
const userId = toNumber(rawClient.userid);
return {
...rawClient,
id,
client_id: clientId,
owner_user_id: ownerUserId,
userid: userId,
customfields: normalizeCustomFields(rawClient.customfields),
users: normalizeUsers(rawClient.users),
allowSingleSignOn: toNullableBoolean(rawClient.allowSingleSignOn) ?? null,
email_verified: toNullableBoolean(rawClient.email_verified) ?? null,
marketing_emails_opt_in:
toNullableBoolean(rawClient.marketing_emails_opt_in) ?? null,
defaultpaymethodid: toNumber(rawClient.defaultpaymethodid),
currency: toNumber(rawClient.currency),
address: deriveAddressFromClient(rawClient),
raw: rawClient,
};
};
export const normalizeWhmcsClientResponse = (
response: WhmcsClientResponse
): NormalizedWhmcsClient => normalizeWhmcsClient(response.client);
export const getCustomFieldValue = (
client: NormalizedWhmcsClient,
key: string | number
): string | undefined => {
if (!client.customfields) return undefined;
const lookupKey = typeof key === "number" ? String(key) : key;
const value = client.customfields[lookupKey];
if (value !== undefined) return value;
if (typeof key === "string") {
const numeric = Number.parseInt(key, 10);
if (!Number.isNaN(numeric)) {
return client.customfields[String(numeric)];
}
}
return undefined;
};
export const buildUserProfile = (
userAuth: UserAuth,
client: NormalizedWhmcsClient
): User => {
const payload = {
id: userAuth.id,
email: userAuth.email,
role: userAuth.role,
emailVerified: userAuth.emailVerified,
mfaEnabled: userAuth.mfaEnabled,
lastLoginAt: userAuth.lastLoginAt,
createdAt: userAuth.createdAt,
updatedAt: userAuth.updatedAt,
firstname: client.firstname ?? null,
lastname: client.lastname ?? null,
fullname: client.fullname ?? null,
companyname: client.companyname ?? null,
phonenumber:
client.phonenumberformatted ??
client.phonenumber ??
client.telephoneNumber ??
null,
language: client.language ?? null,
currency_code: client.currency_code ?? null,
address: client.address ?? undefined,
};
return userSchema.parse(payload);
};
export const getNumericClientId = (
client: NormalizedWhmcsClient
): number => client.id;

View File

@ -26,6 +26,7 @@ import type {
WhmcsCatalogProductListResponse,
} from "@customer-portal/domain/catalog";
import { Logger } from "nestjs-pino";
import { deriveAddressFromClient, type NormalizedWhmcsClient } from "./utils/whmcs-client.utils";
@Injectable()
export class WhmcsService {
@ -131,7 +132,7 @@ export class WhmcsService {
* Get client details by ID
* Returns internal WhmcsClient (type inferred)
*/
async getClientDetails(clientId: number) {
async getClientDetails(clientId: number): Promise<NormalizedWhmcsClient> {
return this.clientService.getClientDetails(clientId);
}
@ -139,7 +140,7 @@ export class WhmcsService {
* Get client details by email
* Returns internal WhmcsClient (type inferred)
*/
async getClientDetailsByEmail(email: string) {
async getClientDetailsByEmail(email: string): Promise<NormalizedWhmcsClient> {
return this.clientService.getClientDetailsByEmail(email);
}
@ -158,11 +159,7 @@ export class WhmcsService {
*/
async getClientAddress(clientId: number): Promise<Address> {
const customer = await this.clientService.getClientDetails(clientId);
if (!customer || typeof customer !== 'object') {
return {} as Address;
}
const custWithAddress = customer as any;
return (custWithAddress.address || {}) as Address;
return customer.address ?? deriveAddressFromClient(customer.raw) ?? ({} as Address);
}
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {

View File

@ -13,6 +13,7 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
import { getErrorMessage } from "@bff/core/utils/error.util";
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
import type { User } from "@customer-portal/domain/customer";
import { getCustomFieldValue, getNumericClientId } from "@bff/integrations/whmcs/utils/whmcs-client.utils";
// No direct Customer import - use inferred type from WHMCS service
@Injectable()
@ -63,8 +64,10 @@ export class WhmcsLinkWorkflowService {
throw new UnauthorizedException("Unable to verify account. Please try again later.");
}
const clientNumericId = getNumericClientId(clientDetails);
try {
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id);
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientNumericId);
if (existingMapping) {
throw new ConflictException("This billing account is already linked. Please sign in.");
}
@ -103,8 +106,9 @@ export class WhmcsLinkWorkflowService {
throw new UnauthorizedException("Unable to verify credentials. Please try again later.");
}
const customFields = clientDetails.customfields ?? {}; // Raw WHMCS field name
const customerNumber = customFields["198"]?.trim() ?? customFields["Customer Number"]?.trim();
const customerNumber =
getCustomFieldValue(clientDetails, "198")?.trim() ??
getCustomFieldValue(clientDetails, "Customer Number")?.trim();
if (!customerNumber) {
throw new BadRequestException(
@ -113,7 +117,7 @@ export class WhmcsLinkWorkflowService {
}
this.logger.log("Found Customer Number for WHMCS client", {
whmcsClientId: clientDetails.id,
whmcsClientId: clientNumericId,
hasCustomerNumber: !!customerNumber,
});
@ -134,16 +138,22 @@ export class WhmcsLinkWorkflowService {
const createdUser = await this.usersService.create({
email,
passwordHash: null,
firstName: clientDetails.firstname || "",
lastName: clientDetails.lastname || "",
company: clientDetails.companyname || "", // Raw WHMCS field name
phone: clientDetails.phonenumberformatted || clientDetails.phonenumber || clientDetails.telephoneNumber || "", // Raw WHMCS field names
firstName: String(clientDetails.firstname ?? ""),
lastName: String(clientDetails.lastname ?? ""),
company: String(clientDetails.companyname ?? ""), // Raw WHMCS field name
phone:
String(
clientDetails.phonenumberformatted ??
clientDetails.phonenumber ??
clientDetails.telephoneNumber ??
""
), // Raw WHMCS field names
emailVerified: true,
});
await this.mappingsService.createMapping({
userId: createdUser.id,
whmcsClientId: clientDetails.id,
whmcsClientId: clientNumericId,
sfAccountId: sfAccount.id,
});

View File

@ -9,7 +9,6 @@ import {
type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth";
import {
combineToUser,
Providers as CustomerProviders,
addressSchema,
type Address,
@ -21,6 +20,7 @@ import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/d
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { buildUserProfile } from "@bff/integrations/whmcs/utils/whmcs-client.utils";
// Use a subset of PrismaUser for auth-related updates only
type UserUpdateData = Partial<
@ -137,8 +137,7 @@ export class UsersService {
// Map Prisma user to UserAuth
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
// Domain combines UserAuth + WhmcsClient → User
return combineToUser(userAuth, whmcsClient as CustomerProviders.Whmcs.WhmcsClient);
return buildUserProfile(userAuth, whmcsClient);
} catch (error) {
this.logger.error("Failed to fetch client profile from WHMCS", {
error: getErrorMessage(error),

View File

@ -5,16 +5,22 @@ const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable standalone output only for production deployment
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
// Ensure workspace package resolves/transpiles correctly in monorepo
transpilePackages: ["@customer-portal/domain"],
experimental: {
externalDir: true,
},
// Ensure workspace packages are transpiled correctly
transpilePackages: [
"@customer-portal/domain",
"@customer-portal/logging",
"@customer-portal/validation",
],
// Tell Next to NOT bundle these server-only libs
serverExternalPackages: [
@ -102,11 +108,37 @@ const nextConfig = {
removeConsole: process.env.NODE_ENV === "production",
},
// Simple bundle optimization
// Experimental flags
experimental: {
externalDir: true,
optimizePackageImports: ["@heroicons/react", "lucide-react", "@tanstack/react-query"],
},
webpack(config) {
const workspaceRoot = path.resolve(__dirname, "..", "..");
config.resolve.alias = {
...config.resolve.alias,
"@customer-portal/domain": path.join(workspaceRoot, "packages/domain"),
"@customer-portal/logging": path.join(workspaceRoot, "packages/logging/src"),
"@customer-portal/validation": path.join(workspaceRoot, "packages/validation/src"),
};
const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"];
const existingExtensions = config.resolve.extensions || [];
config.resolve.extensions = [
...new Set([...preferredExtensions, ...existingExtensions]),
];
config.resolve.extensionAlias = {
...(config.resolve.extensionAlias || {}),
".js": [".ts", ".tsx", ".js"],
".mjs": [".mts", ".ts", ".tsx", ".mjs"],
};
config.module.rules.push({
test: /packages\/domain\/.*\.js$/,
type: "javascript/esm",
});
return config;
},
// Keep type checking enabled; monorepo paths provide types
typescript: { ignoreBuildErrors: false },

View File

@ -166,6 +166,42 @@ const statsSchema = z.record(
z.union([z.string(), z.number(), z.boolean()])
).optional();
const whmcsRawCustomFieldSchema = z
.object({
id: numberLike.optional(),
value: z.string().optional().nullable(),
name: z.string().optional(),
type: z.string().optional(),
})
.passthrough();
const whmcsRawCustomFieldsArraySchema = z.array(whmcsRawCustomFieldSchema);
const whmcsCustomFieldsSchema = z
.union([
z.record(z.string(), z.string()),
whmcsRawCustomFieldsArraySchema,
z
.object({
customfield: z
.union([whmcsRawCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
.optional(),
})
.passthrough(),
])
.optional();
const whmcsUsersSchema = z
.union([
z.array(subUserSchema),
z
.object({
user: z.union([subUserSchema, z.array(subUserSchema)]).optional(),
})
.passthrough(),
])
.optional();
/**
* WhmcsClient - Full WHMCS client data
*
@ -213,8 +249,8 @@ export const whmcsClientSchema = z.object({
// Relations
address: addressSchema.nullable().optional(),
email_preferences: emailPreferencesSchema.nullable().optional(), // snake_case from WHMCS
customfields: z.record(z.string(), z.string()).optional(),
users: z.array(subUserSchema).optional(),
customfields: whmcsCustomFieldsSchema,
users: whmcsUsersSchema,
stats: statsSchema.optional(),
}).transform(data => ({
...data,