import { Injectable, Inject, NotFoundException, BadRequestException, ConflictException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import type { User as PrismaUser } from "@prisma/client"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { addressSchema, combineToUser, type Address, type User, } from "@customer-portal/domain/customer"; 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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { validateUuidV4OrThrow } from "@customer-portal/domain/common"; import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; import { UserAuthRepository } from "./user-auth.repository.js"; @Injectable() export class UserProfileService { constructor( private readonly userAuthRepository: UserAuthRepository, private readonly mappingsService: MappingsService, private readonly whmcsService: WhmcsService, private readonly salesforceService: SalesforceService, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} async findById(userId: string): Promise { const validId = validateUuidV4OrThrow(userId); const user = await this.userAuthRepository.findById(validId); if (!user) { return null; } return this.getProfileForUser(user); } async getProfile(userId: string): Promise { const validId = validateUuidV4OrThrow(userId); const user = await this.userAuthRepository.findById(validId); if (!user) { throw new NotFoundException("User not found"); } return this.getProfileForUser(user); } async getAddress(userId: string): Promise
{ const profile = await this.getProfile(userId); return profile.address ?? null; } async updateAddress(userId: string, addressUpdate: Partial
): Promise
{ const validId = validateUuidV4OrThrow(userId); 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 withErrorHandling( async () => { await this.whmcsService.updateClientAddress(whmcsClientId, parsed); await this.whmcsService.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.whmcsService.getClientAddress(whmcsClientId); return addressSchema.parse(refreshedAddress ?? {}); }, this.logger, { context: `Update address for user ${validId}`, fallbackMessage: "Unable to update address", } ); } async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise { const validId = validateUuidV4OrThrow(userId); const parsed = updateCustomerProfileRequestSchema.parse(update); return withErrorHandling( 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("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("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.whmcsService.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.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdate); } this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS"); return this.getProfile(validId); }, this.logger, { context: `Update profile for user ${validId}`, fallbackMessage: "Unable to update profile", } ); } async getUserSummary(userId: string): Promise { return withErrorHandling( async () => { const user = await this.userAuthRepository.findById(userId); if (!user) { throw new NotFoundException("User not found"); } const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { this.logger.warn(`No WHMCS mapping found for user ${userId}`); const currency = "JPY"; const summary: DashboardSummary = { stats: { activeSubscriptions: 0, unpaidInvoices: 0, openCases: 0, currency, }, nextInvoice: null, recentActivity: [], }; return summary; } const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([ this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId), this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }), this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { status: "Unpaid", limit: 1, }), ]); let activeSubscriptions = 0; let recentSubscriptions: Array<{ id: number; status: string; registrationDate: string; productName: string; }> = []; if (subscriptionsData.status === "fulfilled") { const subscriptions: Subscription[] = subscriptionsData.value.subscriptions; activeSubscriptions = subscriptions.filter(sub => sub.status === "Active").length; recentSubscriptions = subscriptions .filter(sub => sub.status === "Active") .sort((a, b) => { const aTime = a.registrationDate ? new Date(a.registrationDate).getTime() : Number.NEGATIVE_INFINITY; const bTime = b.registrationDate ? new Date(b.registrationDate).getTime() : Number.NEGATIVE_INFINITY; return bTime - aTime; }) .slice(0, 3) .map(sub => ({ id: sub.id, status: sub.status, registrationDate: sub.registrationDate, productName: sub.productName, })); } else { this.logger.error( `Failed to fetch subscriptions for user ${userId}:`, subscriptionsData.reason ); } let unpaidInvoices = 0; let nextInvoice: NextInvoice | null = null; let recentInvoices: Array<{ id: number; status: string; dueDate?: string; total: number; number: string; issuedAt?: string; paidDate?: string; currency?: string | null; }> = []; // Process unpaid invoices count if (unpaidInvoicesData.status === "fulfilled") { unpaidInvoices = unpaidInvoicesData.value.pagination.totalItems; } else { this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, { reason: getErrorMessage(unpaidInvoicesData.reason), }); } if (invoicesData.status === "fulfilled") { const invoices: Invoice[] = invoicesData.value.invoices; // Fallback if unpaid invoices call failed, though inaccurate for total count > 10 if (unpaidInvoicesData.status === "rejected") { unpaidInvoices = invoices.filter( inv => inv.status === "Unpaid" || inv.status === "Overdue" ).length; } const upcomingInvoices = invoices .filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate) .sort((a, b) => { const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY; const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY; return aTime - bTime; }); if (upcomingInvoices.length > 0) { const invoice = upcomingInvoices[0]; nextInvoice = { id: invoice.id, dueDate: invoice.dueDate!, amount: invoice.total, currency: invoice.currency ?? "JPY", }; } recentInvoices = invoices .sort((a, b) => { const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY; const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : 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, })); } else { this.logger.error(`Failed to fetch invoices for user ${userId}`, { reason: getErrorMessage(invoicesData.reason), }); } const activities: Activity[] = []; recentInvoices.forEach(invoice => { if (invoice.status === "Paid") { const metadata: Record = { amount: invoice.total, currency: invoice.currency ?? "JPY", }; if (invoice.dueDate) metadata.dueDate = invoice.dueDate; if (invoice.number) metadata.invoiceNumber = invoice.number; 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, }); } else if (invoice.status === "Unpaid" || invoice.status === "Overdue") { const metadata: Record = { amount: invoice.total, currency: invoice.currency ?? "JPY", status: invoice.status, }; if (invoice.dueDate) metadata.dueDate = invoice.dueDate; if (invoice.number) metadata.invoiceNumber = invoice.number; 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, }); } }); recentSubscriptions.forEach(subscription => { const metadata: Record = { productName: subscription.productName, status: subscription.status, }; if (subscription.registrationDate) { metadata.registrationDate = subscription.registrationDate; } activities.push({ id: `service-activated-${subscription.id}`, type: "service_activated", title: `${subscription.productName} activated`, description: "Service successfully provisioned", date: subscription.registrationDate, relatedId: subscription.id, metadata, }); }); activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); const recentActivity = activities.slice(0, 10); this.logger.log(`Generated dashboard summary for user ${userId}`, { activeSubscriptions, unpaidInvoices, activitiesCount: recentActivity.length, hasNextInvoice: !!nextInvoice, }); let currency = "JPY"; try { const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const resolvedCurrency = typeof client.currency_code === "string" && client.currency_code.trim().length > 0 ? client.currency_code : null; if (resolvedCurrency) { currency = resolvedCurrency; } } catch (error) { this.logger.warn("Could not fetch currency from WHMCS client", { userId, error: getErrorMessage(error), }); } const summary: DashboardSummary = { stats: { activeSubscriptions, unpaidInvoices, openCases: 0, currency, }, nextInvoice, recentActivity, }; return dashboardSummarySchema.parse(summary); }, this.logger, { context: `Get user summary for ${userId}`, fallbackMessage: "Unable to retrieve dashboard summary", } ); } private async getProfileForUser(user: PrismaUser): Promise { const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(user.id); return withErrorHandling( async () => { const whmcsClient = await this.whmcsService.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, }; }, this.logger, { context: `Fetch client profile from WHMCS for user ${user.id}`, fallbackMessage: "Unable to retrieve customer profile from billing system", } ); } }