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,