import { getErrorMessage } from "../common/utils/error.util"; import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { PrismaService } from "../common/prisma/prisma.service"; import { User, Activity } from "@customer-portal/shared"; import { WhmcsService } from "../vendors/whmcs/whmcs.service"; import { SalesforceService } from "../vendors/salesforce/salesforce.service"; import { MappingsService } from "../mappings/mappings.service"; // Enhanced type definitions for better type safety export interface EnhancedUser extends Omit { createdAt: Date; updatedAt: Date; mailingAddress?: { street?: string | null; city?: string | null; state?: string | null; postalCode?: string | null; country?: string | null; buildingName?: string | null; roomNumber?: string | null; }; } interface UserUpdateData { firstName?: string; lastName?: string; company?: string; phone?: string; passwordHash?: string; failedLoginAttempts?: number; lastLoginAt?: Date; lockedUntil?: Date | null; mailingAddress?: { street?: string; city?: string; state?: string; postalCode?: string; country?: string; buildingName?: string; roomNumber?: string; }; } @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 EnhancedUser type private toEnhancedUser(user: any, extras: Partial = {}): EnhancedUser { return { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, company: user.company, phone: user.phone, mfaEnabled: !!user.mfaSecret, // Derive from mfaSecret existence emailVerified: user.emailVerified, createdAt: user.createdAt, updatedAt: user.updatedAt, ...extras, }; } // Helper function to convert Prisma user to shared User type private toUser(user: any): User { return { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, company: user.company, phone: user.phone, mfaEnabled: !!user.mfaSecret, // Derive from mfaSecret existence emailVerified: user.emailVerified, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), }; } private validateEmail(email: string): string { const trimmed = email?.toLowerCase().trim(); if (!trimmed) throw new Error("Email is required"); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) throw new Error("Invalid email format"); return trimmed; } private validateUserId(id: string): string { const trimmed = id?.trim(); if (!trimmed) throw new Error("User ID is required"); if ( !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed) ) { throw new Error("Invalid user ID format"); } return trimmed; } async findByEmail(email: string): Promise { const validEmail = this.validateEmail(email); try { const user = await this.prisma.user.findUnique({ where: { email: validEmail }, }); return user ? this.toUser(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"); } } 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; // Try to enhance with Salesforce data, fallback to basic user data try { return await this.getEnhancedProfile(validId); } catch (error) { this.logger.warn("Failed to fetch Salesforce data, returning basic user data", { error: getErrorMessage(error), userId: validId, }); return this.toEnhancedUser(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); if (!mapping?.sfAccountId) return this.toEnhancedUser(user); try { const account = await this.salesforceService.getAccount(mapping.sfAccountId); if (!account) return this.toEnhancedUser(user); return this.toEnhancedUser(user, { company: account.Name?.trim() || user.company, email: user.email, // Keep original email for now phone: user.phone || undefined, // Keep original phone for now // Address temporarily disabled until field issues resolved }); } catch (error) { this.logger.error("Failed to fetch Salesforce account data", { error: getErrorMessage(error), }); return this.toEnhancedUser(user); } } private hasAddress(_account: any): boolean { // Temporarily disabled until field mapping is resolved return false; } private extractAddress(account: any): any { // Prefer Person Account fields (Contact), fallback to Business Account fields return { street: account.PersonMailingStreet || account.BillingStreet || null, city: account.PersonMailingCity || account.BillingCity || null, state: account.PersonMailingState || account.BillingState || null, postalCode: account.PersonMailingPostalCode || account.BillingPostalCode || null, country: account.PersonMailingCountry || account.BillingCountry || null, buildingName: account.BuildingName__pc || account.BuildingName__c || null, roomNumber: account.RoomNumber__pc || account.RoomNumber__c || null, }; } async create(userData: any): Promise { const validEmail = this.validateEmail(userData.email); try { const normalizedData = { ...userData, email: validEmail }; const createdUser = await this.prisma.user.create({ data: normalizedData, }); return this.toUser(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, }); // Try to sync to Salesforce (non-blocking) this.syncToSalesforce(validId, userData).catch(error => this.logger.warn("Failed to sync to Salesforce", { error: getErrorMessage(error), }) ); return this.toUser(updatedUser); } catch (error) { this.logger.error("Failed to update user", { error: getErrorMessage(error), }); throw new Error("Failed to update user"); } } private sanitizeUserData(userData: UserUpdateData): any { const sanitized: any = {}; 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 syncToSalesforce(userId: string, userData: UserUpdateData): Promise { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.sfAccountId) return; const salesforceUpdate = this.buildSalesforceUpdate(userData); if (Object.keys(salesforceUpdate).length === 0) return; try { await this.salesforceService.updateAccount(mapping.sfAccountId, salesforceUpdate); this.logger.debug("Successfully synced to Salesforce", { fieldsUpdated: Object.keys(salesforceUpdate), }); } catch (error) { this.logger.error("Failed to sync to Salesforce", { error: getErrorMessage(error), }); throw error; } } private buildSalesforceUpdate(userData: UserUpdateData): any { const update: any = {}; if (userData.company !== undefined) update.Name = userData.company; if (userData.phone !== undefined) { // Update both mobile fields for maximum compatibility update.PersonMobilePhone = userData.phone; update.Mobile = userData.phone; } if (userData.mailingAddress) { const addr = userData.mailingAddress; // Update both Person Account and Business Account address fields if (addr.street !== undefined) { update.PersonMailingStreet = addr.street; update.BillingStreet = addr.street; } if (addr.city !== undefined) { update.PersonMailingCity = addr.city; update.BillingCity = addr.city; } if (addr.state !== undefined) { update.PersonMailingState = addr.state; update.BillingState = addr.state; } if (addr.postalCode !== undefined) { update.PersonMailingPostalCode = addr.postalCode; update.BillingPostalCode = addr.postalCode; } if (addr.country !== undefined) { update.PersonMailingCountry = addr.country; update.BillingCountry = addr.country; } if (addr.buildingName !== undefined) { update.BuildingName__pc = addr.buildingName; update.BuildingName__c = addr.buildingName; } if (addr.roomNumber !== undefined) { update.RoomNumber__pc = addr.roomNumber; update.RoomNumber__c = addr.roomNumber; } } return update; } 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: any[] = []; if (subscriptionsData.status === "fulfilled") { const subscriptions = subscriptionsData.value.subscriptions; activeSubscriptions = subscriptions.filter((sub: any) => sub.status === "Active").length; recentSubscriptions = subscriptions .filter((sub: any) => sub.status === "Active") .sort( (a: any, b: any) => new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime() ) .slice(0, 3); } else { this.logger.error( `Failed to fetch subscriptions for user ${userId}:`, subscriptionsData.reason ); } // Process invoices let unpaidInvoices = 0; let nextInvoice = null; let recentInvoices: any[] = []; if (invoicesData.status === "fulfilled") { const invoices = invoicesData.value.invoices; // Count unpaid invoices unpaidInvoices = invoices.filter( (inv: any) => inv.status === "Unpaid" || inv.status === "Overdue" ).length; // Find next due invoice const upcomingInvoices = invoices .filter( (inv: any) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate ) .sort((a: any, b: any) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()); if (upcomingInvoices.length > 0) { const invoice = upcomingInvoices[0]; nextInvoice = { id: invoice.id, dueDate: invoice.dueDate, amount: invoice.total, currency: "JPY", }; } // Recent invoices for activity recentInvoices = invoices .sort( (a: any, b: any) => new Date(b.issuedAt || "").getTime() - new Date(a.issuedAt || "").getTime() ) .slice(0, 5); } 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: 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 || invoice.updatedAt || new Date().toISOString(), relatedId: 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: 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)}`); } } }