import { getErrorMessage } from "@bff/core/utils/error.util"; import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@bff/core/utils/validation.util"; import type { UpdateAddressRequest } from "@customer-portal/domain"; import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { PrismaService } from "@bff/infra/database/prisma.service"; import { User, Activity, Address, type AuthenticatedUser } from "@customer-portal/domain"; import type { Subscription, Invoice } from "@customer-portal/domain"; import type { User as PrismaUser } from "@prisma/client"; 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 { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; // Use a subset of PrismaUser for updates type UserUpdateData = Partial< Pick< PrismaUser, | "firstName" | "lastName" | "company" | "phone" | "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil" > >; @Injectable() export class UsersService { constructor( private prisma: PrismaService, private whmcsService: WhmcsService, private salesforceService: SalesforceService, private mappingsService: MappingsService, @Inject(Logger) private readonly logger: Logger ) {} // Helper function to convert Prisma user to domain User type private toDomainUser(user: PrismaUser): User { return { id: user.id, email: user.email, firstName: user.firstName || undefined, lastName: user.lastName || undefined, company: user.company || undefined, phone: user.phone || undefined, mfaEnabled: user.mfaSecret !== null, emailVerified: user.emailVerified, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), }; } private validateEmail(email: string): string { return normalizeAndValidateEmail(email); } private validateUserId(id: string): string { return validateUuidV4OrThrow(id); } async findByEmail(email: string): Promise { const validEmail = this.validateEmail(email); try { const user = await this.prisma.user.findUnique({ where: { email: validEmail }, }); return user ? this.toDomainUser(user) : null; } catch (error) { this.logger.error("Failed to find user by email", { error: getErrorMessage(error), }); throw new Error("Failed to find user"); } } // Internal method for auth service - returns raw user with sensitive fields async findByEmailInternal(email: string): Promise { const validEmail = this.validateEmail(email); try { return await this.prisma.user.findUnique({ where: { email: validEmail }, }); } catch (error) { this.logger.error("Failed to find user by email (internal)", { error: getErrorMessage(error), }); throw new Error("Failed to find user"); } } // Internal method for auth service - returns raw user by ID with sensitive fields async findByIdInternal(id: string): Promise { const validId = this.validateUserId(id); try { return await this.prisma.user.findUnique({ where: { id: validId } }); } catch (error) { this.logger.error("Failed to find user by ID (internal)", { error: getErrorMessage(error), }); throw new Error("Failed to find user"); } } async findById(id: string): Promise { const validId = this.validateUserId(id); try { const user = await this.prisma.user.findUnique({ where: { id: validId }, }); if (!user) return null; // Enhance profile (prefer WHMCS values), fallback to basic user data try { return await this.getEnhancedProfile(validId); } catch (error) { this.logger.warn("Failed to enhance profile, returning basic user data", { error: getErrorMessage(error), userId: validId, }); return mapPrismaUserToUserProfile(user); } } catch (error) { this.logger.error("Failed to find user by ID", { error: getErrorMessage(error), }); throw new Error("Failed to find user"); } } async getEnhancedProfile(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new Error("User not found"); const mapping = await this.mappingsService.findByUserId(userId); // Start with portal DB values let firstName: string | undefined = user.firstName || undefined; let lastName: string | undefined = user.lastName || undefined; let company: string | undefined = user.company || undefined; let phone: string | undefined = user.phone || undefined; let email: string = user.email; // Prefer WHMCS client details for profile (including email) if (mapping?.whmcsClientId) { try { const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); if (client) { firstName = client.firstname || firstName; lastName = client.lastname || lastName; company = client.companyname || company; phone = client.phonenumber || phone; email = client.email || email; } } catch (err) { this.logger.warn("WHMCS client details unavailable for profile enrichment", { error: getErrorMessage(err), userId, whmcsClientId: mapping.whmcsClientId, }); } } // Check Salesforce health flag (do not override fields) if (mapping?.sfAccountId) { try { await this.salesforceService.getAccount(mapping.sfAccountId); } catch (error) { this.logger.error("Failed to fetch Salesforce account data", { error: getErrorMessage(error), userId, sfAccountId: mapping.sfAccountId, }); } } // Create enhanced user object with Salesforce data const enhancedUser: PrismaUser = { ...user, firstName: firstName || user.firstName, lastName: lastName || user.lastName, company: company || user.company, phone: phone || user.phone, email: email || user.email, }; return mapPrismaUserToUserProfile(enhancedUser); } async create(userData: Partial): Promise { const validEmail = this.validateEmail(userData.email!); try { const normalizedData = { ...userData, email: validEmail }; const createdUser = await this.prisma.user.create({ data: normalizedData, }); return this.toDomainUser(createdUser); } catch (error) { this.logger.error("Failed to create user", { error: getErrorMessage(error), }); throw new Error("Failed to create user"); } } async update(id: string, userData: UserUpdateData): Promise { const validId = this.validateUserId(id); const sanitizedData = this.sanitizeUserData(userData); try { const updatedUser = await this.prisma.user.update({ where: { id: validId }, data: sanitizedData, }); // Do not mutate Salesforce Account from the portal. Salesforce remains authoritative. return this.toDomainUser(updatedUser); } catch (error) { this.logger.error("Failed to update user", { error: getErrorMessage(error), }); throw new Error("Failed to update user"); } } private sanitizeUserData(userData: UserUpdateData): Partial { const sanitized: Partial = {}; if (userData.firstName !== undefined) sanitized.firstName = userData.firstName?.trim().substring(0, 50) || null; if (userData.lastName !== undefined) sanitized.lastName = userData.lastName?.trim().substring(0, 50) || null; if (userData.company !== undefined) sanitized.company = userData.company?.trim().substring(0, 100) || null; if (userData.phone !== undefined) sanitized.phone = userData.phone?.trim().substring(0, 20) || null; // Handle authentication-related fields if (userData.passwordHash !== undefined) sanitized.passwordHash = userData.passwordHash; if (userData.failedLoginAttempts !== undefined) sanitized.failedLoginAttempts = userData.failedLoginAttempts; if (userData.lastLoginAt !== undefined) sanitized.lastLoginAt = userData.lastLoginAt; if (userData.lockedUntil !== undefined) sanitized.lockedUntil = userData.lockedUntil; return sanitized; } async getUserSummary(userId: string) { try { // Verify user exists const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new Error("User not found"); } // Check if user has WHMCS mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { this.logger.warn(`No WHMCS mapping found for user ${userId}`); return { stats: { activeSubscriptions: 0, unpaidInvoices: 0, openCases: 0, currency: "JPY", }, nextInvoice: null, recentActivity: [], }; } // Fetch live data from WHMCS in parallel const [subscriptionsData, invoicesData] = await Promise.allSettled([ this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId), this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50, }), ]); // Process subscriptions let activeSubscriptions = 0; let recentSubscriptions: Array<{ id: string; status: string; registrationDate: string; productName: string; }> = []; if (subscriptionsData.status === "fulfilled") { const subscriptions: Subscription[] = subscriptionsData.value.subscriptions; activeSubscriptions = subscriptions.filter( (sub: Subscription) => sub.status === "Active" ).length; recentSubscriptions = subscriptions .filter((sub: Subscription) => sub.status === "Active") .sort((a: Subscription, b: Subscription) => { 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: Subscription) => ({ id: sub.id.toString(), status: sub.status, registrationDate: sub.registrationDate, productName: sub.productName, })); } else { this.logger.error( `Failed to fetch subscriptions for user ${userId}:`, subscriptionsData.reason ); } // Process invoices let unpaidInvoices = 0; let nextInvoice = null; let recentInvoices: Array<{ id: string; status: string; dueDate?: string; total: number; number: string; issuedAt?: string; paidDate?: string; }> = []; if (invoicesData.status === "fulfilled") { const invoices: Invoice[] = invoicesData.value.invoices; // Count unpaid invoices unpaidInvoices = invoices.filter( (inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue" ).length; // Find next due invoice const upcomingInvoices = invoices .filter( (inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate ) .sort((a: Invoice, b: Invoice) => { 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.toString(), dueDate: invoice.dueDate, amount: invoice.total, currency: "JPY", }; } // Recent invoices for activity recentInvoices = invoices .sort((a: Invoice, b: Invoice) => { 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: Invoice) => ({ id: inv.id.toString(), status: inv.status, dueDate: inv.dueDate, total: inv.total, number: inv.number, issuedAt: inv.issuedAt, })); } else { this.logger.error(`Failed to fetch invoices for user ${userId}`, { reason: getErrorMessage(invoicesData.reason), }); } // Build activity feed const activities: Activity[] = []; // Add invoice activities recentInvoices.forEach(invoice => { 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: Number(invoice.id), }); } 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: Number(invoice.id), }); } }); // Add subscription activities recentSubscriptions.forEach(subscription => { activities.push({ id: `service-activated-${subscription.id}`, type: "service_activated", title: `${subscription.productName} activated`, description: "Service successfully provisioned", date: subscription.registrationDate, relatedId: Number(subscription.id), }); }); // Sort activities by date and take top 10 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, }); return { stats: { activeSubscriptions, unpaidInvoices, openCases: 0, // TODO: Implement support cases when ready currency: "JPY", }, nextInvoice, recentActivity, }; } catch (error) { this.logger.error(`Failed to get user summary for ${userId}`, { error: getErrorMessage(error), }); throw new Error(`Failed to retrieve dashboard data: ${getErrorMessage(error)}`); } } /** * Get address information from WHMCS (authoritative source) */ async getAddress(userId: string): Promise
{ try { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping) { throw new NotFoundException("User mapping not found"); } // Delegate to vendor service return await this.whmcsService.getClientAddress(mapping.whmcsClientId); } catch (error) { this.logger.error(`Failed to get address for ${userId}`, { error: getErrorMessage(error), }); throw new Error(`Failed to retrieve address: ${getErrorMessage(error)}`); } } /** * Update address in WHMCS (authoritative for client record address fields) */ async updateAddress(userId: string, address: UpdateAddressRequest): Promise { try { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping) { throw new NotFoundException("User mapping not found"); } await this.whmcsService.updateClientAddress(mapping.whmcsClientId, address); this.logger.log({ userId }, "Successfully updated address in WHMCS"); } catch (error) { const msg = getErrorMessage(error); this.logger.error({ userId, error: msg }, "Failed to update address in WHMCS"); if (msg.includes("WHMCS API Error")) { throw new BadRequestException(msg.replace("WHMCS API Error: ", "")); } if (msg.includes("HTTP ")) { throw new BadRequestException("Upstream WHMCS error. Please try again."); } if (msg.includes("Missing required WHMCS configuration")) { throw new BadRequestException("Billing system not configured. Please contact support."); } throw new BadRequestException("Unable to update address."); } } }