Assist_Design/apps/bff/src/modules/users/users.service.ts

539 lines
18 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
2025-08-21 15:24:40 +09:00
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,
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";
import { buildUserProfile } from "@bff/integrations/whmcs/utils/whmcs-client.utils";
// Use a subset of PrismaUser for auth-related updates only
type UserUpdateData = Partial<
Pick<
PrismaUser,
| "passwordHash"
| "failedLoginAttempts"
| "lastLoginAt"
| "lockedUntil"
>
>;
@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
) {}
/**
* Find user by email - returns authenticated user with full profile from WHMCS
*/
async findByEmail(email: string): Promise<User | null> {
const validEmail = normalizeAndValidateEmail(email);
try {
2025-08-21 15:24:40 +09:00
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) {
2025-08-21 15:24:40 +09:00
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<PrismaUser | null> {
const validEmail = normalizeAndValidateEmail(email);
try {
2025-08-21 15:24:40 +09:00
return await this.prisma.user.findUnique({
where: { email: validEmail },
});
} 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 BadRequestException("Unable to retrieve user information");
}
}
// Internal method for auth service - returns raw user by ID with sensitive fields
async findByIdInternal(id: string): Promise<PrismaUser | null> {
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<User | null> {
const validId = validateUuidV4OrThrow(id);
try {
2025-08-21 15:24:40 +09:00
const user = await this.prisma.user.findUnique({
where: { id: validId },
});
if (!user) return null;
return await this.getProfile(validId);
} catch (error) {
2025-08-21 15:24:40 +09:00
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<User> {
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 buildUserProfile(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<Address | null> {
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<Address>): Promise<Address> {
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<PrismaUser>): Promise<User> {
const validEmail = normalizeAndValidateEmail(userData.email!);
try {
const normalizedData = { ...userData, email: validEmail };
2025-08-21 15:24:40 +09:00
const createdUser = await this.prisma.user.create({
data: normalizedData,
});
// Return full profile from WHMCS
return this.getProfile(createdUser.id);
} catch (error) {
2025-08-21 15:24:40 +09:00
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<User> {
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) {
2025-08-21 15:24:40 +09:00
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<User> {
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<PrismaUser> {
const sanitized: Partial<PrismaUser> = {};
// Handle authentication-related fields only
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
return sanitized;
}
async getUserSummary(userId: string): Promise<DashboardSummary> {
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 });
}
const summary: DashboardSummary = {
stats: {
activeSubscriptions: 0,
unpaidInvoices: 0,
openCases: 0,
currency,
},
nextInvoice: null,
2025-08-21 15:24:40 +09:00
recentActivity: [],
};
return summary;
}
// 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,
}),
]);
// Process subscriptions
let activeSubscriptions = 0;
2025-08-23 18:02:05 +09:00
let recentSubscriptions: Array<{
id: number;
2025-08-23 18:02:05 +09:00
status: string;
registrationDate: string;
productName: string;
}> = [];
2025-08-21 15:24:40 +09:00
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;
})
2025-08-23 18:02:05 +09:00
.slice(0, 3)
.map((sub: Subscription) => ({
id: sub.id,
2025-08-23 18:02:05 +09:00
status: sub.status,
registrationDate: sub.registrationDate,
productName: sub.productName,
}));
} 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
);
}
// Process invoices
let unpaidInvoices = 0;
let nextInvoice: NextInvoice | null = null;
2025-08-23 18:02:05 +09:00
let recentInvoices: Array<{
id: number;
2025-08-23 18:02:05 +09:00
status: string;
dueDate?: string;
total: number;
number: string;
issuedAt?: string;
paidDate?: string;
}> = [];
2025-08-21 15:24:40 +09:00
if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices;
2025-08-21 15:24:40 +09:00
// Count unpaid invoices
2025-08-21 15:24:40 +09:00
unpaidInvoices = invoices.filter(
(inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue"
2025-08-21 15:24:40 +09:00
).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;
});
2025-08-21 15:24:40 +09:00
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;
})
2025-08-23 18:02:05 +09:00
.slice(0, 5)
.map((inv: Invoice) => ({
id: inv.id,
2025-08-23 18:02:05 +09:00
status: inv.status,
dueDate: inv.dueDate,
total: inv.total,
number: inv.number,
issuedAt: inv.issuedAt,
}));
} else {
2025-08-22 17:02:49 +09:00
this.logger.error(`Failed to fetch invoices for user ${userId}`, {
reason: getErrorMessage(invoicesData.reason),
});
}
// 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") {
activities.push({
id: `invoice-paid-${invoice.id}`,
2025-08-21 15:24:40 +09:00
type: "invoice_paid",
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(),
relatedId: invoice.id,
});
2025-08-22 17:02:49 +09:00
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
activities.push({
id: `invoice-created-${invoice.id}`,
2025-08-21 15:24:40 +09:00
type: "invoice_created",
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: invoice.id,
});
}
});
// Add subscription activities
2025-08-22 17:02:49 +09:00
recentSubscriptions.forEach(subscription => {
activities.push({
id: `service-activated-${subscription.id}`,
2025-08-21 15:24:40 +09:00
type: "service_activated",
title: `${subscription.productName} activated`,
2025-08-21 15:24:40 +09:00
description: "Service successfully provisioned",
date: subscription.registrationDate,
relatedId: subscription.id,
});
});
// 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());
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,
});
// Get currency from client data
let currency = "JPY"; // Default
try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
if (client && typeof client === 'object' && 'currency_code' in client) {
const currency_code = (client as any).currency_code;
if (currency_code) {
currency = currency_code;
}
}
} catch (error) {
this.logger.warn("Could not fetch currency from WHMCS client", { userId });
}
const summary: DashboardSummary = {
stats: {
activeSubscriptions,
unpaidInvoices,
openCases: 0, // Support cases not implemented yet
currency,
},
nextInvoice,
2025-08-21 15:24:40 +09:00
recentActivity,
};
return dashboardSummarySchema.parse(summary);
} 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 BadRequestException("Unable to retrieve dashboard summary");
2025-08-28 16:57:57 +09:00
}
}
}