2025-08-21 15:24:40 +09:00
|
|
|
import { getErrorMessage } from "../common/utils/error.util";
|
2025-09-02 13:52:13 +09:00
|
|
|
import type { UpdateAddressDto } from "./dto/update-address.dto";
|
|
|
|
|
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
|
2025-08-21 15:24:40 +09:00
|
|
|
import { Logger } from "nestjs-pino";
|
|
|
|
|
import { PrismaService } from "../common/prisma/prisma.service";
|
|
|
|
|
import { User, Activity } from "@customer-portal/shared";
|
2025-09-01 15:11:42 +09:00
|
|
|
import type { Subscription, Invoice } from "@customer-portal/shared";
|
2025-08-23 18:02:05 +09:00
|
|
|
import { User as PrismaUser } from "@prisma/client";
|
2025-08-21 15:24:40 +09:00
|
|
|
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
|
|
|
|
import { SalesforceService } from "../vendors/salesforce/salesforce.service";
|
2025-08-29 13:26:57 +09:00
|
|
|
import { WhmcsClientResponse } from "../vendors/whmcs/types/whmcs-api.types";
|
2025-08-28 16:57:57 +09:00
|
|
|
// Removed unused import: getSalesforceFieldMap
|
2025-08-21 15:24:40 +09:00
|
|
|
import { MappingsService } from "../mappings/mappings.service";
|
2025-08-29 13:26:57 +09:00
|
|
|
import { UpdateBillingDto } from "./dto/update-billing.dto";
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
// Enhanced type definitions for better type safety
|
2025-08-21 15:24:40 +09:00
|
|
|
export interface EnhancedUser extends Omit<User, "createdAt" | "updatedAt"> {
|
2025-08-20 18:02:50 +09:00
|
|
|
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;
|
|
|
|
|
};
|
2025-09-02 14:15:24 +09:00
|
|
|
salesforceHealthy?: boolean;
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
// Salesforce Account interface based on the data model
|
|
|
|
|
interface SalesforceAccount {
|
|
|
|
|
Id: string;
|
|
|
|
|
Name?: string;
|
|
|
|
|
PersonMailingStreet?: string;
|
|
|
|
|
PersonMailingCity?: string;
|
|
|
|
|
PersonMailingState?: string;
|
|
|
|
|
PersonMailingPostalCode?: string;
|
|
|
|
|
PersonMailingCountry?: string;
|
|
|
|
|
BillingStreet?: string;
|
|
|
|
|
BillingCity?: string;
|
|
|
|
|
BillingState?: string;
|
|
|
|
|
BillingPostalCode?: string;
|
|
|
|
|
BillingCountry?: string;
|
|
|
|
|
BuildingName__pc?: string;
|
|
|
|
|
BuildingName__c?: string;
|
|
|
|
|
RoomNumber__pc?: string;
|
|
|
|
|
RoomNumber__c?: string;
|
|
|
|
|
PersonMobilePhone?: string;
|
|
|
|
|
Mobile?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
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,
|
2025-08-22 17:02:49 +09:00
|
|
|
@Inject(Logger) private readonly logger: Logger
|
2025-08-20 18:02:50 +09:00
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
// Helper function to convert Prisma user to EnhancedUser type
|
2025-09-02 14:15:24 +09:00
|
|
|
private toEnhancedUser(
|
2025-09-02 16:09:17 +09:00
|
|
|
user: PrismaUser,
|
|
|
|
|
extras: Partial<EnhancedUser> = {},
|
2025-09-02 14:15:24 +09:00
|
|
|
salesforceHealthy: boolean = true
|
|
|
|
|
): EnhancedUser {
|
2025-08-20 18:02:50 +09:00
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
email: user.email,
|
2025-08-23 18:02:05 +09:00
|
|
|
firstName: user.firstName || undefined,
|
|
|
|
|
lastName: user.lastName || undefined,
|
|
|
|
|
company: user.company || undefined,
|
|
|
|
|
phone: user.phone || undefined,
|
2025-08-20 18:02:50 +09:00
|
|
|
mfaEnabled: !!user.mfaSecret, // Derive from mfaSecret existence
|
|
|
|
|
emailVerified: user.emailVerified,
|
|
|
|
|
createdAt: user.createdAt,
|
|
|
|
|
updatedAt: user.updatedAt,
|
2025-09-02 14:15:24 +09:00
|
|
|
salesforceHealthy,
|
2025-08-21 15:24:40 +09:00
|
|
|
...extras,
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function to convert Prisma user to shared User type
|
2025-08-23 18:02:05 +09:00
|
|
|
private toUser(user: PrismaUser): User {
|
2025-08-20 18:02:50 +09:00
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
email: user.email,
|
2025-08-23 18:02:05 +09:00
|
|
|
firstName: user.firstName || undefined,
|
|
|
|
|
lastName: user.lastName || undefined,
|
|
|
|
|
company: user.company || undefined,
|
|
|
|
|
phone: user.phone || undefined,
|
2025-08-20 18:02:50 +09:00
|
|
|
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();
|
2025-08-21 15:24:40 +09:00
|
|
|
if (!trimmed) throw new Error("Email is required");
|
2025-08-22 17:02:49 +09:00
|
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) throw new Error("Invalid email format");
|
2025-08-20 18:02:50 +09:00
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validateUserId(id: string): string {
|
|
|
|
|
const trimmed = id?.trim();
|
2025-08-21 15:24:40 +09:00
|
|
|
if (!trimmed) throw new Error("User ID is required");
|
|
|
|
|
if (
|
2025-08-22 17:02:49 +09:00
|
|
|
!/^[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)
|
2025-08-21 15:24:40 +09:00
|
|
|
) {
|
|
|
|
|
throw new Error("Invalid user ID format");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async findByEmail(email: string): Promise<User | null> {
|
|
|
|
|
const validEmail = this.validateEmail(email);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-21 15:24:40 +09:00
|
|
|
const user = await this.prisma.user.findUnique({
|
|
|
|
|
where: { email: validEmail },
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
return user ? this.toUser(user) : null;
|
|
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to find user by email", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new Error("Failed to find user");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Internal method for auth service - returns raw user with sensitive fields
|
2025-08-23 18:02:05 +09:00
|
|
|
async findByEmailInternal(email: string): Promise<PrismaUser | null> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const validEmail = this.validateEmail(email);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-21 15:24:40 +09:00
|
|
|
return await this.prisma.user.findUnique({
|
|
|
|
|
where: { email: validEmail },
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to find user by email (internal)", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new Error("Failed to find user");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
// Internal method for auth service - returns raw user by ID with sensitive fields
|
|
|
|
|
async findByIdInternal(id: string): Promise<PrismaUser | null> {
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
async findById(id: string): Promise<EnhancedUser | null> {
|
|
|
|
|
const validId = this.validateUserId(id);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-21 15:24:40 +09:00
|
|
|
const user = await this.prisma.user.findUnique({
|
|
|
|
|
where: { id: validId },
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!user) return null;
|
|
|
|
|
|
|
|
|
|
// Try to enhance with Salesforce data, fallback to basic user data
|
|
|
|
|
try {
|
|
|
|
|
return await this.getEnhancedProfile(validId);
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.warn("Failed to fetch Salesforce data, returning basic user data", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
userId: validId,
|
|
|
|
|
});
|
2025-09-02 14:15:24 +09:00
|
|
|
return this.toEnhancedUser(user, {}, false);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to find user by ID", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new Error("Failed to find user");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getEnhancedProfile(userId: string): Promise<EnhancedUser> {
|
|
|
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
2025-08-21 15:24:40 +09:00
|
|
|
if (!user) throw new Error("User not found");
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
2025-09-02 14:15:24 +09:00
|
|
|
if (!mapping?.sfAccountId) return this.toEnhancedUser(user, {}, true);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-09-02 14:15:24 +09:00
|
|
|
let salesforceHealthy = true;
|
2025-08-20 18:02:50 +09:00
|
|
|
try {
|
2025-08-23 18:02:05 +09:00
|
|
|
const account = (await this.salesforceService.getAccount(
|
|
|
|
|
mapping.sfAccountId
|
|
|
|
|
)) as SalesforceAccount | null;
|
2025-09-02 14:15:24 +09:00
|
|
|
if (!account) return this.toEnhancedUser(user, undefined, salesforceHealthy);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-09-02 16:09:17 +09:00
|
|
|
return this.toEnhancedUser(
|
|
|
|
|
user,
|
|
|
|
|
{
|
|
|
|
|
company: account.Name?.trim() || user.company || undefined,
|
|
|
|
|
email: user.email, // Keep original email for now
|
|
|
|
|
phone: user.phone || undefined, // Keep original phone for now
|
|
|
|
|
// Address temporarily disabled until field issues resolved
|
|
|
|
|
},
|
|
|
|
|
salesforceHealthy
|
|
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-09-02 14:15:24 +09:00
|
|
|
salesforceHealthy = false;
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to fetch Salesforce account data", {
|
|
|
|
|
error: getErrorMessage(error),
|
2025-09-02 14:15:24 +09:00
|
|
|
userId,
|
|
|
|
|
sfAccountId: mapping.sfAccountId,
|
2025-08-21 15:24:40 +09:00
|
|
|
});
|
2025-09-02 14:15:24 +09:00
|
|
|
return this.toEnhancedUser(user, undefined, salesforceHealthy);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
private hasAddress(_account: SalesforceAccount): boolean {
|
2025-08-20 18:02:50 +09:00
|
|
|
// Temporarily disabled until field mapping is resolved
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
private extractAddress(account: SalesforceAccount): {
|
|
|
|
|
street: string | null;
|
|
|
|
|
city: string | null;
|
|
|
|
|
state: string | null;
|
|
|
|
|
postalCode: string | null;
|
|
|
|
|
country: string | null;
|
|
|
|
|
buildingName: string | null;
|
|
|
|
|
roomNumber: string | null;
|
|
|
|
|
} {
|
2025-08-20 18:02:50 +09:00
|
|
|
// 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,
|
2025-08-22 17:02:49 +09:00
|
|
|
postalCode: account.PersonMailingPostalCode || account.BillingPostalCode || null,
|
2025-08-20 18:02:50 +09:00
|
|
|
country: account.PersonMailingCountry || account.BillingCountry || null,
|
|
|
|
|
buildingName: account.BuildingName__pc || account.BuildingName__c || null,
|
|
|
|
|
roomNumber: account.RoomNumber__pc || account.RoomNumber__c || null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
async create(userData: Partial<PrismaUser>): Promise<User> {
|
|
|
|
|
const validEmail = this.validateEmail(userData.email!);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const normalizedData = { ...userData, email: validEmail };
|
2025-08-21 15:24:40 +09:00
|
|
|
const createdUser = await this.prisma.user.create({
|
|
|
|
|
data: normalizedData,
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
return this.toUser(createdUser);
|
|
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to create user", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new Error("Failed to create user");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(id: string, userData: UserUpdateData): Promise<User> {
|
|
|
|
|
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)
|
2025-08-22 17:02:49 +09:00
|
|
|
this.syncToSalesforce(validId, userData).catch(error =>
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.warn("Failed to sync to Salesforce", {
|
|
|
|
|
error: getErrorMessage(error),
|
2025-08-22 17:02:49 +09:00
|
|
|
})
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return this.toUser(updatedUser);
|
|
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to update user", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new Error("Failed to update user");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
private sanitizeUserData(userData: UserUpdateData): Partial<PrismaUser> {
|
|
|
|
|
const sanitized: Partial<PrismaUser> = {};
|
2025-08-21 15:24:40 +09:00
|
|
|
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;
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Handle authentication-related fields
|
2025-08-22 17:02:49 +09:00
|
|
|
if (userData.passwordHash !== undefined) sanitized.passwordHash = userData.passwordHash;
|
2025-08-21 15:24:40 +09:00
|
|
|
if (userData.failedLoginAttempts !== undefined)
|
|
|
|
|
sanitized.failedLoginAttempts = userData.failedLoginAttempts;
|
2025-08-22 17:02:49 +09:00
|
|
|
if (userData.lastLoginAt !== undefined) sanitized.lastLoginAt = userData.lastLoginAt;
|
|
|
|
|
if (userData.lockedUntil !== undefined) sanitized.lockedUntil = userData.lockedUntil;
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
return sanitized;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
async syncToSalesforce(userId: string, userData: UserUpdateData): Promise<void> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
|
|
|
if (!mapping?.sfAccountId) return;
|
|
|
|
|
|
|
|
|
|
const salesforceUpdate = this.buildSalesforceUpdate(userData);
|
|
|
|
|
if (Object.keys(salesforceUpdate).length === 0) return;
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-22 17:02:49 +09:00
|
|
|
await this.salesforceService.updateAccount(mapping.sfAccountId, salesforceUpdate);
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.debug("Successfully synced to Salesforce", {
|
|
|
|
|
fieldsUpdated: Object.keys(salesforceUpdate),
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to sync to Salesforce", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
private buildSalesforceUpdate(userData: UserUpdateData): Partial<SalesforceAccount> {
|
2025-08-28 16:57:57 +09:00
|
|
|
// const fields = getSalesforceFieldMap(); // Unused variable
|
2025-08-23 18:02:05 +09:00
|
|
|
const update: Partial<SalesforceAccount> = {};
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
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) {
|
2025-08-28 16:57:57 +09:00
|
|
|
(update as Record<string, unknown>)["BuildingName__pc"] = addr.buildingName;
|
|
|
|
|
(update as Record<string, unknown>)["BuildingName__c"] = addr.buildingName;
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
if (addr.roomNumber !== undefined) {
|
2025-08-28 16:57:57 +09:00
|
|
|
(update as Record<string, unknown>)["RoomNumber__pc"] = addr.roomNumber;
|
|
|
|
|
(update as Record<string, unknown>)["RoomNumber__c"] = addr.roomNumber;
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
return update;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getUserSummary(userId: string) {
|
|
|
|
|
try {
|
|
|
|
|
// Verify user exists
|
|
|
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
|
|
|
if (!user) {
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new Error("User not found");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
2025-08-21 15:24:40 +09:00
|
|
|
currency: "JPY",
|
2025-08-20 18:02:50 +09:00
|
|
|
},
|
|
|
|
|
nextInvoice: null,
|
2025-08-21 15:24:40 +09:00
|
|
|
recentActivity: [],
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch live data from WHMCS in parallel
|
|
|
|
|
const [subscriptionsData, invoicesData] = await Promise.allSettled([
|
|
|
|
|
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
|
2025-08-21 15:24:40 +09:00
|
|
|
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
|
|
|
|
|
limit: 50,
|
|
|
|
|
}),
|
2025-08-20 18:02:50 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Process subscriptions
|
|
|
|
|
let activeSubscriptions = 0;
|
2025-08-23 18:02:05 +09:00
|
|
|
let recentSubscriptions: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
status: string;
|
|
|
|
|
registrationDate: string;
|
|
|
|
|
productName: string;
|
|
|
|
|
}> = [];
|
2025-08-21 15:24:40 +09:00
|
|
|
if (subscriptionsData.status === "fulfilled") {
|
2025-09-01 15:11:42 +09:00
|
|
|
const subscriptions: Subscription[] = subscriptionsData.value.subscriptions;
|
|
|
|
|
activeSubscriptions = subscriptions.filter(
|
|
|
|
|
(sub: Subscription) => sub.status === "Active"
|
|
|
|
|
).length;
|
2025-08-20 18:02:50 +09:00
|
|
|
recentSubscriptions = subscriptions
|
2025-09-01 15:11:42 +09:00
|
|
|
.filter((sub: Subscription) => sub.status === "Active")
|
2025-08-21 15:24:40 +09:00
|
|
|
.sort(
|
2025-09-01 15:11:42 +09:00
|
|
|
(a: Subscription, b: Subscription) =>
|
2025-08-22 17:02:49 +09:00
|
|
|
new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime()
|
2025-08-21 15:24:40 +09:00
|
|
|
)
|
2025-08-23 18:02:05 +09:00
|
|
|
.slice(0, 3)
|
2025-09-01 15:11:42 +09:00
|
|
|
.map((sub: Subscription) => ({
|
2025-08-23 18:02:05 +09:00
|
|
|
id: sub.id.toString(),
|
|
|
|
|
status: sub.status,
|
|
|
|
|
registrationDate: sub.registrationDate,
|
|
|
|
|
productName: sub.productName,
|
|
|
|
|
}));
|
2025-08-20 18:02:50 +09:00
|
|
|
} else {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error(
|
|
|
|
|
`Failed to fetch subscriptions for user ${userId}:`,
|
2025-08-22 17:02:49 +09:00
|
|
|
subscriptionsData.reason
|
2025-08-21 15:24:40 +09:00
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process invoices
|
|
|
|
|
let unpaidInvoices = 0;
|
|
|
|
|
let nextInvoice = null;
|
2025-08-23 18:02:05 +09:00
|
|
|
let recentInvoices: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
status: string;
|
|
|
|
|
dueDate?: string;
|
|
|
|
|
total: number;
|
|
|
|
|
number: string;
|
|
|
|
|
issuedAt?: string;
|
|
|
|
|
paidDate?: string;
|
|
|
|
|
}> = [];
|
2025-08-21 15:24:40 +09:00
|
|
|
if (invoicesData.status === "fulfilled") {
|
2025-09-01 15:11:42 +09:00
|
|
|
const invoices: Invoice[] = invoicesData.value.invoices;
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Count unpaid invoices
|
2025-08-21 15:24:40 +09:00
|
|
|
unpaidInvoices = invoices.filter(
|
2025-09-01 15:11:42 +09:00
|
|
|
(inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue"
|
2025-08-21 15:24:40 +09:00
|
|
|
).length;
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Find next due invoice
|
|
|
|
|
const upcomingInvoices = invoices
|
2025-09-01 15:11:42 +09:00
|
|
|
.filter(
|
|
|
|
|
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
|
|
|
|
|
)
|
|
|
|
|
.sort(
|
|
|
|
|
(a: Invoice, b: Invoice) =>
|
|
|
|
|
new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime()
|
|
|
|
|
);
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
if (upcomingInvoices.length > 0) {
|
|
|
|
|
const invoice = upcomingInvoices[0];
|
|
|
|
|
nextInvoice = {
|
2025-08-23 18:02:05 +09:00
|
|
|
id: invoice.id.toString(),
|
2025-08-20 18:02:50 +09:00
|
|
|
dueDate: invoice.dueDate,
|
|
|
|
|
amount: invoice.total,
|
2025-08-21 15:24:40 +09:00
|
|
|
currency: "JPY",
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Recent invoices for activity
|
|
|
|
|
recentInvoices = invoices
|
2025-08-21 15:24:40 +09:00
|
|
|
.sort(
|
2025-09-01 15:11:42 +09:00
|
|
|
(a: Invoice, b: Invoice) =>
|
|
|
|
|
new Date(b.issuedAt || "").getTime() - new Date(a.issuedAt || "").getTime()
|
2025-08-21 15:24:40 +09:00
|
|
|
)
|
2025-08-23 18:02:05 +09:00
|
|
|
.slice(0, 5)
|
2025-09-01 15:11:42 +09:00
|
|
|
.map((inv: Invoice) => ({
|
2025-08-23 18:02:05 +09:00
|
|
|
id: inv.id.toString(),
|
|
|
|
|
status: inv.status,
|
|
|
|
|
dueDate: inv.dueDate,
|
|
|
|
|
total: inv.total,
|
|
|
|
|
number: inv.number,
|
|
|
|
|
issuedAt: inv.issuedAt,
|
|
|
|
|
}));
|
2025-08-20 18:02:50 +09:00
|
|
|
} else {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error(`Failed to fetch invoices for user ${userId}`, {
|
|
|
|
|
reason: getErrorMessage(invoicesData.reason),
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build activity feed
|
|
|
|
|
const activities: Activity[] = [];
|
|
|
|
|
|
|
|
|
|
// Add invoice activities
|
2025-08-22 17:02:49 +09:00
|
|
|
recentInvoices.forEach(invoice => {
|
2025-08-21 15:24:40 +09:00
|
|
|
if (invoice.status === "Paid") {
|
2025-08-20 18:02:50 +09:00
|
|
|
activities.push({
|
|
|
|
|
id: `invoice-paid-${invoice.id}`,
|
2025-08-21 15:24:40 +09:00
|
|
|
type: "invoice_paid",
|
2025-08-20 18:02:50 +09:00
|
|
|
title: `Invoice #${invoice.number} paid`,
|
|
|
|
|
description: `Payment of ¥${invoice.total.toLocaleString()} processed`,
|
2025-08-22 17:02:49 +09:00
|
|
|
date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(),
|
2025-08-23 18:02:05 +09:00
|
|
|
relatedId: Number(invoice.id),
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
2025-08-22 17:02:49 +09:00
|
|
|
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
|
2025-08-20 18:02:50 +09:00
|
|
|
activities.push({
|
|
|
|
|
id: `invoice-created-${invoice.id}`,
|
2025-08-21 15:24:40 +09:00
|
|
|
type: "invoice_created",
|
2025-08-20 18:02:50 +09:00
|
|
|
title: `Invoice #${invoice.number} created`,
|
|
|
|
|
description: `Amount: ¥${invoice.total.toLocaleString()}`,
|
2025-08-23 18:02:05 +09:00
|
|
|
date: invoice.issuedAt || new Date().toISOString(),
|
|
|
|
|
relatedId: Number(invoice.id),
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add subscription activities
|
2025-08-22 17:02:49 +09:00
|
|
|
recentSubscriptions.forEach(subscription => {
|
2025-08-20 18:02:50 +09:00
|
|
|
activities.push({
|
|
|
|
|
id: `service-activated-${subscription.id}`,
|
2025-08-21 15:24:40 +09:00
|
|
|
type: "service_activated",
|
2025-08-20 18:02:50 +09:00
|
|
|
title: `${subscription.productName} activated`,
|
2025-08-21 15:24:40 +09:00
|
|
|
description: "Service successfully provisioned",
|
2025-08-20 18:02:50 +09:00
|
|
|
date: subscription.registrationDate,
|
2025-08-23 18:02:05 +09:00
|
|
|
relatedId: Number(subscription.id),
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Sort activities by date and take top 10
|
2025-08-22 17:02:49 +09:00
|
|
|
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
2025-08-20 18:02:50 +09:00
|
|
|
const recentActivity = activities.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
this.logger.log(`Generated dashboard summary for user ${userId}`, {
|
|
|
|
|
activeSubscriptions,
|
|
|
|
|
unpaidInvoices,
|
|
|
|
|
activitiesCount: recentActivity.length,
|
2025-08-21 15:24:40 +09:00
|
|
|
hasNextInvoice: !!nextInvoice,
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
stats: {
|
|
|
|
|
activeSubscriptions,
|
|
|
|
|
unpaidInvoices,
|
|
|
|
|
openCases: 0, // TODO: Implement support cases when ready
|
2025-08-21 15:24:40 +09:00
|
|
|
currency: "JPY",
|
2025-08-20 18:02:50 +09:00
|
|
|
},
|
|
|
|
|
nextInvoice,
|
2025-08-21 15:24:40 +09:00
|
|
|
recentActivity,
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error(`Failed to get user summary for ${userId}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new Error(`Failed to retrieve dashboard data: ${getErrorMessage(error)}`);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-08-28 16:57:57 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get billing information from WHMCS (authoritative source)
|
|
|
|
|
*/
|
|
|
|
|
async getBillingInfo(userId: string) {
|
|
|
|
|
try {
|
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
|
|
|
if (!mapping) {
|
|
|
|
|
throw new NotFoundException("User mapping not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get client details from WHMCS
|
|
|
|
|
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
2025-08-28 18:27:11 +09:00
|
|
|
|
2025-08-28 16:57:57 +09:00
|
|
|
return {
|
|
|
|
|
company: clientDetails.companyname || null,
|
|
|
|
|
email: clientDetails.email,
|
|
|
|
|
phone: clientDetails.phonenumber || null,
|
|
|
|
|
address: {
|
|
|
|
|
street: clientDetails.address1 || null,
|
2025-08-29 13:26:57 +09:00
|
|
|
streetLine2: clientDetails.address2 || null,
|
2025-08-28 16:57:57 +09:00
|
|
|
city: clientDetails.city || null,
|
|
|
|
|
state: clientDetails.state || null,
|
|
|
|
|
postalCode: clientDetails.postcode || null,
|
|
|
|
|
country: clientDetails.country || null,
|
|
|
|
|
},
|
|
|
|
|
isComplete: !!(
|
|
|
|
|
clientDetails.address1 &&
|
|
|
|
|
clientDetails.city &&
|
|
|
|
|
clientDetails.state &&
|
|
|
|
|
clientDetails.postcode &&
|
|
|
|
|
clientDetails.country
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error(`Failed to get billing info for ${userId}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new Error(`Failed to retrieve billing information: ${getErrorMessage(error)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update billing information in WHMCS (authoritative source)
|
|
|
|
|
*/
|
2025-08-29 13:26:57 +09:00
|
|
|
async updateBillingInfo(userId: string, billingData: UpdateBillingDto): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
// Get user mapping
|
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
|
|
|
if (!mapping) {
|
|
|
|
|
throw new NotFoundException("User mapping not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prepare WHMCS update data
|
|
|
|
|
const whmcsUpdateData: Partial<WhmcsClientResponse["client"]> = {};
|
|
|
|
|
|
|
|
|
|
if (billingData.street !== undefined) {
|
|
|
|
|
whmcsUpdateData.address1 = billingData.street;
|
|
|
|
|
}
|
|
|
|
|
if (billingData.streetLine2 !== undefined) {
|
|
|
|
|
whmcsUpdateData.address2 = billingData.streetLine2;
|
|
|
|
|
}
|
|
|
|
|
if (billingData.city !== undefined) {
|
|
|
|
|
whmcsUpdateData.city = billingData.city;
|
|
|
|
|
}
|
|
|
|
|
if (billingData.state !== undefined) {
|
|
|
|
|
whmcsUpdateData.state = billingData.state;
|
|
|
|
|
}
|
|
|
|
|
if (billingData.postalCode !== undefined) {
|
|
|
|
|
whmcsUpdateData.postcode = billingData.postalCode;
|
|
|
|
|
}
|
|
|
|
|
if (billingData.country !== undefined) {
|
|
|
|
|
whmcsUpdateData.country = billingData.country;
|
|
|
|
|
}
|
|
|
|
|
if (billingData.phone !== undefined) {
|
|
|
|
|
whmcsUpdateData.phonenumber = billingData.phone;
|
|
|
|
|
}
|
|
|
|
|
if (billingData.company !== undefined) {
|
|
|
|
|
whmcsUpdateData.companyname = billingData.company;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
// No-op if nothing to update
|
|
|
|
|
if (Object.keys(whmcsUpdateData).length === 0) {
|
|
|
|
|
this.logger.debug({ userId }, "No billing fields provided; skipping WHMCS update");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 14:05:33 +09:00
|
|
|
// Update in WHMCS (authoritative source)
|
2025-08-29 13:26:57 +09:00
|
|
|
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData);
|
|
|
|
|
|
|
|
|
|
this.logger.log({ userId }, "Successfully updated billing information in WHMCS");
|
|
|
|
|
} catch (error) {
|
2025-09-02 13:52:13 +09:00
|
|
|
const msg = getErrorMessage(error);
|
|
|
|
|
this.logger.error({ userId, error: msg }, "Failed to update billing information");
|
2025-08-29 14:05:33 +09:00
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
// Surface API error details when available as 400 instead of 500
|
|
|
|
|
if (msg.includes("WHMCS API Error")) {
|
|
|
|
|
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
|
|
|
|
|
}
|
|
|
|
|
if (msg.includes("HTTP ")) {
|
|
|
|
|
throw new BadRequestException("Upstream billing system error. Please try again.");
|
|
|
|
|
}
|
|
|
|
|
if (msg.includes("Missing required WHMCS configuration")) {
|
|
|
|
|
throw new BadRequestException("Billing system not configured. Please contact support.");
|
2025-08-29 14:05:33 +09:00
|
|
|
}
|
2025-09-02 13:52:13 +09:00
|
|
|
|
|
|
|
|
throw new BadRequestException("Unable to update billing information.");
|
2025-08-29 13:26:57 +09:00
|
|
|
}
|
2025-08-28 16:57:57 +09:00
|
|
|
}
|
2025-09-02 13:52:13 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update only address fields in WHMCS (alias used by checkout)
|
|
|
|
|
*/
|
|
|
|
|
async updateAddress(userId: string, address: UpdateAddressDto): Promise<void> {
|
|
|
|
|
// Reuse the billing updater since WHMCS stores address on the client record
|
|
|
|
|
return this.updateBillingInfo(userId, address as unknown as UpdateBillingDto);
|
|
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|