import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { User as PrismaUser } from "@prisma/client"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common"; import { PrismaService } from "@bff/infra/database/prisma.service"; import { updateCustomerProfileRequestSchema, type UpdateCustomerProfileRequest, } from "@customer-portal/domain/auth"; import { Providers as CustomerProviders, addressSchema, combineToUser, type Address, type User, } from "@customer-portal/domain/customer"; 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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; // Use a subset of PrismaUser for auth-related updates only type UserUpdateData = Partial< Pick >; @Injectable() export class UsersService { constructor( private prisma: PrismaService, private whmcsService: WhmcsService, private salesforceService: SalesforceService, private mappingsService: MappingsService, @Inject(Logger) private readonly logger: Logger ) {} /** * Find user by email - returns authenticated user with full profile from WHMCS */ async findByEmail(email: string): Promise { const validEmail = normalizeAndValidateEmail(email); try { const user = await this.prisma.user.findUnique({ where: { email: validEmail }, }); if (!user) return null; // Return full profile with WHMCS data return this.getProfile(user.id); } catch (error) { this.logger.error("Failed to find user by email", { error: getErrorMessage(error), }); throw new BadRequestException("Unable to retrieve user profile"); } } // Internal method for auth service - returns raw user with sensitive fields async findByEmailInternal(email: string): Promise { const validEmail = normalizeAndValidateEmail(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 BadRequestException("Unable to retrieve user information"); } } // Internal method for auth service - returns raw user by ID with sensitive fields async findByIdInternal(id: string): Promise { const validId = validateUuidV4OrThrow(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 BadRequestException("Unable to retrieve user information"); } } /** * Get user profile - primary method for fetching authenticated user with full WHMCS data */ async findById(id: string): Promise { const validId = validateUuidV4OrThrow(id); try { const user = await this.prisma.user.findUnique({ where: { id: validId }, }); if (!user) return null; return await this.getProfile(validId); } catch (error) { this.logger.error("Failed to find user by ID", { error: getErrorMessage(error), }); throw new BadRequestException("Unable to retrieve user profile"); } } /** * Get complete customer profile from WHMCS (single source of truth) * Includes profile fields + address + auth state */ async getProfile(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException("User not found"); const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } try { // Get WHMCS client data (source of truth for profile) const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId); // Map Prisma user to UserAuth const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user); return combineToUser(userAuth, whmcsClient); } catch (error) { this.logger.error("Failed to fetch client profile from WHMCS", { error: getErrorMessage(error), userId, whmcsClientId: mapping.whmcsClientId, }); throw new BadRequestException("Unable to retrieve customer profile from billing system"); } } /** * Get only the customer's address information */ async getAddress(userId: string): Promise
{ const validId = validateUuidV4OrThrow(userId); const profile = await this.getProfile(validId); return profile.address ?? null; } /** * Update customer address in WHMCS */ 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 mapping = await this.mappingsService.findByUserId(validId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } try { await this.whmcsService.updateClientAddress(mapping.whmcsClientId, parsed); await this.whmcsService.invalidateUserCache(validId); this.logger.log("Successfully updated customer address in WHMCS", { userId: validId, whmcsClientId: mapping.whmcsClientId, }); const refreshedProfile = await this.getProfile(validId); if (refreshedProfile.address) { return refreshedProfile.address; } const refreshedAddress = await this.whmcsService.getClientAddress(mapping.whmcsClientId); return addressSchema.parse(refreshedAddress ?? {}); } catch (error) { const msg = getErrorMessage(error); this.logger.error( { userId: validId, whmcsClientId: mapping.whmcsClientId, error: msg }, "Failed to update customer 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."); } } /** * Create user (auth state only in portal DB) */ async create(userData: Partial): Promise { const validEmail = normalizeAndValidateEmail(userData.email!); try { const normalizedData = { ...userData, email: validEmail }; const createdUser = await this.prisma.user.create({ data: normalizedData, }); // Return full profile from WHMCS return this.getProfile(createdUser.id); } catch (error) { this.logger.error("Failed to create user", { error: getErrorMessage(error), }); throw new BadRequestException("Unable to create user account"); } } /** * Update user auth state (password, login attempts, etc.) * For profile updates, use updateProfile instead */ async update(id: string, userData: UserUpdateData): Promise { const validId = validateUuidV4OrThrow(id); const sanitizedData = this.sanitizeUserData(userData); try { await this.prisma.user.update({ where: { id: validId }, data: sanitizedData, }); // Return fresh profile from WHMCS return this.getProfile(validId); } catch (error) { this.logger.error("Failed to update user", { error: getErrorMessage(error), }); throw new BadRequestException("Unable to update user information"); } } /** * Update customer profile in WHMCS (single source of truth) * Can update profile fields AND/OR address fields in one call */ async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise { const validId = validateUuidV4OrThrow(userId); const parsed = updateCustomerProfileRequestSchema.parse(update); try { const mapping = await this.mappingsService.findByUserId(validId); if (!mapping) { throw new NotFoundException("User mapping not found"); } // Update in WHMCS (all fields optional) await this.whmcsService.updateClient(mapping.whmcsClientId, parsed); this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS"); // Return fresh profile return this.getProfile(validId); } catch (error) { const msg = getErrorMessage(error); this.logger.error( { userId: validId, error: msg }, "Failed to update customer profile 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 profile."); } } private sanitizeUserData(userData: UserUpdateData): Partial { const sanitized: Partial = {}; // Handle authentication-related fields only 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): Promise { try { // Verify user exists const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new NotFoundException("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}`); // Get currency from WHMCS profile if available let currency = "JPY"; // Default try { const profile = await this.getProfile(userId); currency = profile.currency_code || currency; } catch (error) { this.logger.warn("Could not fetch currency from profile", { userId, error: getErrorMessage(error), }); } const summary: DashboardSummary = { stats: { activeSubscriptions: 0, unpaidInvoices: 0, openCases: 0, currency, }, nextInvoice: null, recentActivity: [], }; return summary; } // 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: number; 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, 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: NextInvoice | null = null; let recentInvoices: Array<{ id: number; status: string; dueDate?: string; total: number; number: string; issuedAt?: string; paidDate?: string; currency?: string | null; }> = []; 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, dueDate: invoice.dueDate!, amount: invoice.total, currency: invoice.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, 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), }); } // Build activity feed const activities: Activity[] = []; // Add invoice activities recentInvoices.forEach(invoice => { if (invoice.status === "Paid") { const metadata = { amount: invoice.total, currency: invoice.currency ?? "JPY", } as Record; 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 = { amount: invoice.total, currency: invoice.currency ?? "JPY", } as Record; if (invoice.dueDate) metadata.dueDate = invoice.dueDate; if (invoice.number) metadata.invoiceNumber = invoice.number; metadata.status = invoice.status; 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, }); } }); // Add subscription activities recentSubscriptions.forEach(subscription => { const metadata = { productName: subscription.productName, status: subscription.status, } as Record; 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, }); }); // 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, }); // Get currency from client data let currency = "JPY"; // Default 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, // Support cases not implemented yet currency, }, nextInvoice, recentActivity, }; return dashboardSummarySchema.parse(summary); } catch (error) { this.logger.error(`Failed to get user summary for ${userId}`, { error: getErrorMessage(error), }); throw new BadRequestException("Unable to retrieve dashboard summary"); } } }