From 8a90003075d587d32b93b20d7a59df646299f932 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 20 Oct 2025 16:26:47 +0900 Subject: [PATCH] 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. --- .../whmcs/cache/whmcs-cache.service.ts | 8 +- .../whmcs/services/whmcs-client.service.ts | 24 +- .../whmcs/utils/whmcs-client.utils.ts | 286 ++++++++++++++++++ .../src/integrations/whmcs/whmcs.service.ts | 11 +- .../workflows/whmcs-link-workflow.service.ts | 28 +- apps/bff/src/modules/users/users.service.ts | 5 +- apps/portal/next.config.mjs | 44 ++- packages/domain/customer/schema.ts | 40 ++- 8 files changed, 402 insertions(+), 44 deletions(-) create mode 100644 apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts diff --git a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts index f52a6d23..51d081bc 100644 --- a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts @@ -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 { const key = this.buildClientKey(clientId); - return this.get(key, "client"); + return this.get(key, "client"); } /** * Cache client data */ - async setClientData(clientId: number, data: ReturnType) { + async setClientData(clientId: number, data: NormalizedWhmcsClient) { const key = this.buildClientKey(clientId); await this.set(key, data, "client", [`client:${clientId}`]); } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts index ebbf5d04..c29e3f92 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts @@ -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 { 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 { 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), diff --git a/apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts b/apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts new file mode 100644 index 00000000..90a65b2f --- /dev/null +++ b/apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts @@ -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 => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every(v => typeof v === "string"); + +const normalizeCustomFields = ( + raw: RawCustomFields +): Record | undefined => { + if (!raw) return undefined; + + if (Array.isArray(raw)) { + const map = raw.reduce>((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; + const map = Object.entries(stringRecord).reduce>( + (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(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; + 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; diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 5e112d93..5b4c2275 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -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 { 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 { return this.clientService.getClientDetailsByEmail(email); } @@ -158,11 +159,7 @@ export class WhmcsService { */ async getClientAddress(clientId: number): Promise
{ 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
): Promise { diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index 52bf11d5..94ed376f 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -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, }); diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index e8ba208a..83a2df27 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -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), diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index ac0e19bc..0bef2fa2 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -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 }, diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts index 19085f63..8ff6024c 100644 --- a/packages/domain/customer/schema.ts +++ b/packages/domain/customer/schema.ts @@ -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,