import { Injectable, Inject, NotFoundException, BadRequestException, ConflictException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { randomUUID } from "crypto"; import type { User as PrismaUser } from "@prisma/client"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { addressSchema, combineToUser, type Address, type User, } from "@customer-portal/domain/customer"; import { type BilingualAddress, prepareWhmcsAddressFields, prepareSalesforceContactAddressFields, } from "@customer-portal/domain/address"; import { getCustomFieldValue, mapPrismaUserToUserAuth, } from "@customer-portal/domain/customer/providers"; import { updateCustomerProfileRequestSchema, type UpdateCustomerProfileRequest, } from "@customer-portal/domain/auth"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Invoice } from "@customer-portal/domain/billing"; import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard"; import { dashboardSummarySchema } from "@customer-portal/domain/dashboard"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js"; import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js"; import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js"; import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js"; import { UserAuthRepository } from "./user-auth.repository.js"; import { AddressReconcileQueueService } from "../queue/address-reconcile.queue.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; const ERROR_INVALID_USER_ID = "Invalid user ID format"; const ERROR_USER_NOT_FOUND = "User not found"; const ERROR_USER_MAPPING_NOT_FOUND = "User mapping not found"; const DEFAULT_CURRENCY = "JPY"; interface RecentSubscription { id: number; status: string; registrationDate: string; productName: string; } interface RecentInvoice { id: number; status: string; dueDate?: string | undefined; total: number; number: string; issuedAt?: string | undefined; paidDate?: string | undefined; currency?: string | null | undefined; } function getDateTimestamp(dateStr: string | undefined | null, fallback: number): number { return dateStr ? new Date(dateStr).getTime() : fallback; } function processSubscriptionsData(subscriptions: Subscription[]): { activeCount: number; recentSubscriptions: RecentSubscription[]; } { const activeSubscriptions = subscriptions.filter(sub => sub.status === "Active"); const activeCount = activeSubscriptions.length; const recentSubscriptions = activeSubscriptions .sort((a, b) => { const aTime = getDateTimestamp(a.registrationDate, Number.NEGATIVE_INFINITY); const bTime = getDateTimestamp(b.registrationDate, Number.NEGATIVE_INFINITY); return bTime - aTime; }) .slice(0, 3) .map(sub => ({ id: sub.id, status: sub.status, registrationDate: sub.registrationDate, productName: sub.productName, })); return { activeCount, recentSubscriptions }; } function findNextInvoice(invoices: Invoice[]): NextInvoice | null { const upcomingInvoices = invoices .filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate) .sort((a, b) => { const aTime = getDateTimestamp(a.dueDate, Number.POSITIVE_INFINITY); const bTime = getDateTimestamp(b.dueDate, Number.POSITIVE_INFINITY); return aTime - bTime; }); const invoice = upcomingInvoices[0]; if (!invoice?.dueDate) { return null; } return { id: invoice.id, dueDate: invoice.dueDate, amount: invoice.total, currency: invoice.currency ?? DEFAULT_CURRENCY, }; } function processInvoicesData(invoices: Invoice[]): RecentInvoice[] { return invoices .sort((a, b) => { const aTime = getDateTimestamp(a.issuedAt, Number.NEGATIVE_INFINITY); const bTime = getDateTimestamp(b.issuedAt, Number.NEGATIVE_INFINITY); return bTime - aTime; }) .slice(0, 5) .map(inv => ({ id: inv.id, status: inv.status, dueDate: inv.dueDate, total: inv.total, number: inv.number, issuedAt: inv.issuedAt, currency: inv.currency ?? null, })); } function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] { const activities: Activity[] = []; for (const invoice of invoices) { const baseMetadata: Record = { amount: invoice.total, currency: invoice.currency ?? DEFAULT_CURRENCY, }; if (invoice.dueDate) { baseMetadata["dueDate"] = invoice.dueDate; } if (invoice.number) { baseMetadata["invoiceNumber"] = invoice.number; } if (invoice.status === "Paid") { activities.push({ id: `invoice-paid-${invoice.id}`, type: "invoice_paid", title: `Invoice #${invoice.number} paid`, description: `Payment of ¥${invoice.total.toLocaleString()} processed`, date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(), relatedId: invoice.id, metadata: baseMetadata, }); } else if (invoice.status === "Unpaid" || invoice.status === "Overdue") { activities.push({ id: `invoice-created-${invoice.id}`, type: "invoice_created", title: `Invoice #${invoice.number} created`, description: `Amount: ¥${invoice.total.toLocaleString()}`, date: invoice.issuedAt || new Date().toISOString(), relatedId: invoice.id, metadata: { ...baseMetadata, status: invoice.status }, }); } } return activities; } function buildSubscriptionActivities(subscriptions: RecentSubscription[]): Activity[] { return subscriptions.map(subscription => { const metadata: Record = { productName: subscription.productName, status: subscription.status, }; if (subscription.registrationDate) { metadata["registrationDate"] = subscription.registrationDate; } return { id: `service-activated-${subscription.id}`, type: "service_activated", title: `${subscription.productName} activated`, description: "Service successfully provisioned", date: subscription.registrationDate, relatedId: subscription.id, metadata, }; }); } /** * User Profile Aggregator * * Combines user data from multiple sources (Portal DB, WHMCS, Salesforce) * to build comprehensive user profiles. Handles profile reads and * coordinated updates across systems. */ @Injectable() export class UserProfileAggregator { private readonly userAuthRepository: UserAuthRepository; private readonly mappingsService: MappingsService; private readonly whmcsClientService: WhmcsClientService; private readonly whmcsInvoiceService: WhmcsInvoiceService; private readonly whmcsSubscriptionService: WhmcsSubscriptionService; private readonly salesforceService: SalesforceFacade; private readonly configService: ConfigService; private readonly addressReconcileQueue: AddressReconcileQueueService; private readonly auditService: AuditService; private readonly logger: Logger; // eslint-disable-next-line max-params constructor( userAuthRepository: UserAuthRepository, mappingsService: MappingsService, whmcsClientService: WhmcsClientService, whmcsInvoiceService: WhmcsInvoiceService, whmcsSubscriptionService: WhmcsSubscriptionService, salesforceService: SalesforceFacade, configService: ConfigService, addressReconcileQueue: AddressReconcileQueueService, auditService: AuditService, @Inject(Logger) logger: Logger ) { this.userAuthRepository = userAuthRepository; this.mappingsService = mappingsService; this.whmcsClientService = whmcsClientService; this.whmcsInvoiceService = whmcsInvoiceService; this.whmcsSubscriptionService = whmcsSubscriptionService; this.salesforceService = salesforceService; this.configService = configService; this.addressReconcileQueue = addressReconcileQueue; this.auditService = auditService; this.logger = logger; } async findById(userId: string): Promise { const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID); const user = await this.userAuthRepository.findById(validId); if (!user) { return null; } return this.getProfileForUser(user); } async getProfile(userId: string): Promise { const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID); const user = await this.userAuthRepository.findById(validId); if (!user) { throw new NotFoundException(ERROR_USER_NOT_FOUND); } return this.getProfileForUser(user); } async getAddress(userId: string): Promise
{ const profile = await this.getProfile(userId); return profile.address ?? null; } // eslint-disable-next-line no-restricted-syntax -- Mutation is intentional for this aggregator async updateAddress(userId: string, addressUpdate: Partial
): Promise
{ const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID); const parsed = addressSchema.partial().parse(addressUpdate ?? {}); const hasUpdates = Object.values(parsed).some(value => value !== undefined); if (!hasUpdates) { throw new BadRequestException("No address fields provided for update"); } const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(validId); return safeOperation( async () => { await this.whmcsClientService.updateClientAddress(whmcsClientId, parsed); await this.whmcsClientService.invalidateUserCache(validId); this.logger.log("Successfully updated customer address in WHMCS", { userId: validId, whmcsClientId, }); const refreshedProfile = await this.getProfile(validId); if (refreshedProfile.address) { return refreshedProfile.address; } const refreshedAddress = await this.whmcsClientService.getClientAddress(whmcsClientId); return addressSchema.parse(refreshedAddress ?? {}); }, { criticality: OperationCriticality.CRITICAL, context: `Update address for user ${validId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to update address", } ); } /** * Update address with bilingual data (Japanese + English) * Dual-write: English to WHMCS, Japanese to Salesforce * * @param userId - User ID * @param bilingualAddress - Address data with both Japanese and English fields * @returns Updated address (from WHMCS, source of truth) */ // eslint-disable-next-line no-restricted-syntax -- Mutation is intentional for this aggregator async updateBilingualAddress( userId: string, bilingualAddress: BilingualAddress ): Promise
{ const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID); return safeOperation( async () => { const mapping = await this.mappingsService.findByUserId(validId); if (!mapping?.whmcsClientId) { throw new NotFoundException(ERROR_USER_MAPPING_NOT_FOUND); } // 1. Update WHMCS with English address (source of truth) const whmcsFields = prepareWhmcsAddressFields(bilingualAddress); await this.whmcsClientService.updateClientAddress(mapping.whmcsClientId, whmcsFields); await this.whmcsClientService.invalidateUserCache(validId); this.logger.log("Successfully updated customer address in WHMCS", { userId: validId, whmcsClientId: mapping.whmcsClientId, }); // 2. Update Salesforce with Japanese address (secondary, non-blocking) if (mapping.sfAccountId) { await this.syncSalesforceAddress( validId, mapping.sfAccountId, mapping.whmcsClientId, bilingualAddress ); } else { this.logger.debug("No Salesforce mapping found, skipping Japanese address sync", { userId: validId, }); } return this.fetchRefreshedAddress(validId, mapping.whmcsClientId); }, { criticality: OperationCriticality.CRITICAL, context: `Update bilingual address for user ${validId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to update address", } ); } private async syncSalesforceAddress( userId: string, sfAccountId: string, whmcsClientId: number, bilingualAddress: BilingualAddress ): Promise { const sfFields = prepareSalesforceContactAddressFields(bilingualAddress); const correlationId = randomUUID(); const addressData = { mailingStreet: sfFields.MailingStreet, mailingCity: sfFields.MailingCity, mailingState: sfFields.MailingState, mailingPostalCode: sfFields.MailingPostalCode, mailingCountry: sfFields.MailingCountry, buildingName: sfFields.BuildingName__c, roomNumber: sfFields.RoomNumber__c, }; try { await this.salesforceService.updateContactAddress(sfAccountId, addressData); this.logger.log("Successfully updated Japanese address in Salesforce", { userId, sfAccountId, correlationId, }); await this.auditService.log({ userId, action: AuditAction.ADDRESS_UPDATE, resource: "address", details: { sfAccountId, whmcsClientId, correlationId }, success: true, }); } catch (sfError) { const errorMessage = extractErrorMessage(sfError); this.logger.warn("Failed to update Salesforce address - queueing reconciliation", { userId, sfAccountId, error: errorMessage, correlationId, }); await this.addressReconcileQueue.enqueueReconciliation({ userId, sfAccountId, address: addressData, originalError: errorMessage, correlationId, }); await this.auditService.log({ userId, action: AuditAction.ADDRESS_RECONCILE_QUEUED, resource: "address", details: { sfAccountId, whmcsClientId, originalError: errorMessage, correlationId }, success: true, }); } } private async fetchRefreshedAddress(userId: string, whmcsClientId: number): Promise
{ const refreshedProfile = await this.getProfile(userId); if (refreshedProfile.address) { return refreshedProfile.address; } const refreshedAddress = await this.whmcsClientService.getClientAddress(whmcsClientId); return addressSchema.parse(refreshedAddress ?? {}); } // eslint-disable-next-line no-restricted-syntax -- Mutation is intentional for this aggregator async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise { const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID); const parsed = updateCustomerProfileRequestSchema.parse(update); return safeOperation( async () => { // Explicitly disallow name changes from portal if (parsed.firstname !== undefined || parsed.lastname !== undefined) { throw new BadRequestException("Name cannot be changed from the portal."); } const mapping = await this.mappingsService.findByUserId(validId); if (!mapping) { throw new NotFoundException(ERROR_USER_MAPPING_NOT_FOUND); } // Email changes must update both Portal DB and WHMCS, and must be unique in Portal. if (parsed.email) { const currentUser = await this.userAuthRepository.findById(validId); if (!currentUser) { throw new NotFoundException(ERROR_USER_NOT_FOUND); } const newEmail = parsed.email; const existing = await this.userAuthRepository.findByEmail(newEmail); if (existing && existing.id !== validId) { throw new ConflictException("That email address is already in use."); } // Update WHMCS first (source of truth for billing profile), then update Portal DB. await this.whmcsClientService.updateClient(mapping.whmcsClientId, { email: newEmail }); await this.userAuthRepository.updateEmail(validId, newEmail); } // Allow phone/company/language updates through to WHMCS // Exclude email/firstname/lastname from WHMCS update (handled separately above or disallowed) const { email, firstname, lastname, ...whmcsUpdate } = parsed; void email; // Email is handled above in a separate flow void firstname; // Name changes are explicitly disallowed void lastname; if (Object.keys(whmcsUpdate).length > 0) { await this.whmcsClientService.updateClient(mapping.whmcsClientId, whmcsUpdate); } this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS"); return this.getProfile(validId); }, { criticality: OperationCriticality.CRITICAL, context: `Update profile for user ${validId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to update profile", } ); } async getUserSummary(userId: string): Promise { return safeOperation(async () => this.buildUserSummary(userId), { criticality: OperationCriticality.CRITICAL, context: `Get user summary for ${userId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to retrieve dashboard summary", }); } private async buildUserSummary(userId: string): Promise { const user = await this.userAuthRepository.findById(userId); if (!user) { throw new NotFoundException(ERROR_USER_NOT_FOUND); } const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { this.logger.warn(`No WHMCS mapping found for user ${userId}`); return this.buildEmptySummary(); } const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([ this.whmcsSubscriptionService.getSubscriptions(mapping.whmcsClientId, userId), this.whmcsInvoiceService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }), this.whmcsInvoiceService.getInvoices(mapping.whmcsClientId, userId, { status: "Unpaid", limit: 1, }), ]); const { activeCount, recentSubscriptions } = this.extractSubscriptionData( subscriptionsData, userId ); const { unpaidCount, nextInvoice, recentInvoices } = this.extractInvoiceData( invoicesData, unpaidInvoicesData, userId ); const activities = [ ...buildInvoiceActivities(recentInvoices), ...buildSubscriptionActivities(recentSubscriptions), ] .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) .slice(0, 10); this.logger.log(`Generated dashboard summary for user ${userId}`, { activeSubscriptions: activeCount, unpaidInvoices: unpaidCount, activitiesCount: activities.length, hasNextInvoice: !!nextInvoice, }); const currency = await this.fetchClientCurrency(mapping.whmcsClientId, userId); return dashboardSummarySchema.parse({ stats: { activeSubscriptions: activeCount, unpaidInvoices: unpaidCount, openCases: 0, currency, }, nextInvoice, recentActivity: activities, }); } private buildEmptySummary(): DashboardSummary { return { stats: { activeSubscriptions: 0, unpaidInvoices: 0, openCases: 0, currency: DEFAULT_CURRENCY, }, nextInvoice: null, recentActivity: [], }; } private extractSubscriptionData( subscriptionsData: PromiseSettledResult<{ subscriptions: Subscription[] }>, userId: string ): { activeCount: number; recentSubscriptions: RecentSubscription[] } { if (subscriptionsData.status === "fulfilled") { return processSubscriptionsData(subscriptionsData.value.subscriptions); } this.logger.error( `Failed to fetch subscriptions for user ${userId}:`, subscriptionsData.reason ); return { activeCount: 0, recentSubscriptions: [] }; } private extractInvoiceData( invoicesData: PromiseSettledResult<{ invoices: Invoice[]; pagination: { totalItems: number } }>, unpaidInvoicesData: PromiseSettledResult<{ pagination: { totalItems: number } }>, userId: string ): { unpaidCount: number; nextInvoice: NextInvoice | null; recentInvoices: RecentInvoice[] } { let unpaidCount = 0; if (unpaidInvoicesData.status === "fulfilled") { unpaidCount = unpaidInvoicesData.value.pagination.totalItems; } else { this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, { reason: extractErrorMessage(unpaidInvoicesData.reason), }); } if (invoicesData.status !== "fulfilled") { this.logger.error(`Failed to fetch invoices for user ${userId}`, { reason: extractErrorMessage(invoicesData.reason), }); return { unpaidCount, nextInvoice: null, recentInvoices: [] }; } const invoices = invoicesData.value.invoices; if (unpaidInvoicesData.status === "rejected") { unpaidCount = invoices.filter( inv => inv.status === "Unpaid" || inv.status === "Overdue" ).length; } return { unpaidCount, nextInvoice: findNextInvoice(invoices), recentInvoices: processInvoicesData(invoices), }; } private async fetchClientCurrency(whmcsClientId: number, userId: string): Promise { try { const client = await this.whmcsClientService.getClientDetails(whmcsClientId); const resolvedCurrency = typeof client.currency_code === "string" && client.currency_code.trim().length > 0 ? client.currency_code : null; return resolvedCurrency ?? DEFAULT_CURRENCY; } catch (error) { this.logger.warn("Could not fetch currency from WHMCS client", { userId, error: extractErrorMessage(error), }); return DEFAULT_CURRENCY; } } private async getProfileForUser(user: PrismaUser): Promise { const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(user.id); return safeOperation( async () => { const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId); const userAuth = mapPrismaUserToUserAuth(user); const base = combineToUser(userAuth, whmcsClient); // Portal-visible identifiers (read-only). These are stored in WHMCS custom fields. const customerNumberFieldId = this.configService.get( "WHMCS_CUSTOMER_NUMBER_FIELD_ID", "198" ); const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); const rawSfNumber = customerNumberFieldId ? getCustomFieldValue(whmcsClient.customfields, customerNumberFieldId) : undefined; const rawDob = dobFieldId ? getCustomFieldValue(whmcsClient.customfields, dobFieldId) : undefined; const rawGender = genderFieldId ? getCustomFieldValue(whmcsClient.customfields, genderFieldId) : undefined; const sfNumber = rawSfNumber?.trim() ? rawSfNumber.trim() : null; const dateOfBirth = rawDob?.trim() ? rawDob.trim() : null; const gender = rawGender?.trim() ? rawGender.trim() : null; return { ...base, sfNumber, dateOfBirth, gender, }; }, { criticality: OperationCriticality.CRITICAL, context: `Fetch client profile from WHMCS for user ${user.id}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to retrieve customer profile from billing system", } ); } }