Assist_Design/apps/bff/src/users/users.service.ts
2025-08-21 15:24:40 +09:00

535 lines
17 KiB
TypeScript

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<User, "createdAt" | "updatedAt"> {
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;
};
salesforceAccountId?: string;
}
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> = {},
): 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<User | null> {
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<any | null> {
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<EnhancedUser | null> {
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",
);
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<EnhancedUser> {
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
salesforceAccountId: account.Id,
// 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<User> {
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<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)
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<void> {
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}:`,
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);
throw new Error(
`Failed to retrieve dashboard data: ${getErrorMessage(error)}`,
);
}
}
}