Assist_Design/apps/bff/src/modules/users/infra/user-profile.service.ts

458 lines
17 KiB
TypeScript
Raw Normal View History

import {
Injectable,
Inject,
NotFoundException,
BadRequestException,
ConflictException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
2025-08-21 15:24:40 +09:00
import { Logger } from "nestjs-pino";
import type { User as PrismaUser } from "@prisma/client";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import {
addressSchema,
combineToUser,
type Address,
type User,
} from "@customer-portal/domain/customer";
import {
getCustomFieldValue,
mapPrismaUserToUserAuth,
} from "@customer-portal/domain/customer/providers";
import {
updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth";
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 { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
import { UserAuthRepository } from "./user-auth.repository.js";
@Injectable()
export class UserProfileService {
constructor(
private readonly userAuthRepository: UserAuthRepository,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService,
private readonly configService: ConfigService,
2025-08-22 17:02:49 +09:00
@Inject(Logger) private readonly logger: Logger
) {}
async findById(userId: string): Promise<User | null> {
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
const user = await this.userAuthRepository.findById(validId);
if (!user) {
return null;
}
return this.getProfileForUser(user);
}
async getProfile(userId: string): Promise<User> {
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
const user = await this.userAuthRepository.findById(validId);
if (!user) {
throw new NotFoundException("User not found");
}
return this.getProfileForUser(user);
}
async getAddress(userId: string): Promise<Address | null> {
const profile = await this.getProfile(userId);
return profile.address ?? null;
}
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> {
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
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 whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(validId);
return withErrorHandling(
async () => {
await this.whmcsService.updateClientAddress(whmcsClientId, parsed);
await this.whmcsService.invalidateUserCache(validId);
this.logger.log("Successfully updated customer address in WHMCS", {
userId: validId,
whmcsClientId,
});
const refreshedProfile = await this.getProfile(validId);
if (refreshedProfile.address) {
return refreshedProfile.address;
}
const refreshedAddress = await this.whmcsService.getClientAddress(whmcsClientId);
return addressSchema.parse(refreshedAddress ?? {});
},
this.logger,
{
context: `Update address for user ${validId}`,
fallbackMessage: "Unable to update address",
}
);
}
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
const parsed = updateCustomerProfileRequestSchema.parse(update);
return withErrorHandling(
async () => {
// Explicitly disallow name changes from portal
if (parsed.firstname !== undefined || parsed.lastname !== undefined) {
throw new BadRequestException("Name cannot be changed from the portal.");
}
const mapping = await this.mappingsService.findByUserId(validId);
if (!mapping) {
throw new NotFoundException("User mapping not found");
}
// Email changes must update both Portal DB and WHMCS, and must be unique in Portal.
if (parsed.email) {
const currentUser = await this.userAuthRepository.findById(validId);
if (!currentUser) {
throw new NotFoundException("User not found");
}
const newEmail = parsed.email;
const existing = await this.userAuthRepository.findByEmail(newEmail);
if (existing && existing.id !== validId) {
throw new ConflictException("That email address is already in use.");
}
// Update WHMCS first (source of truth for billing profile), then update Portal DB.
await this.whmcsService.updateClient(mapping.whmcsClientId, { email: newEmail });
await this.userAuthRepository.updateEmail(validId, newEmail);
}
// Allow phone/company/language updates through to WHMCS
// Exclude email/firstname/lastname from WHMCS update (handled separately above or disallowed)
const { email, firstname, lastname, ...whmcsUpdate } = parsed;
void email; // Email is handled above in a separate flow
void firstname; // Name changes are explicitly disallowed
void lastname;
if (Object.keys(whmcsUpdate).length > 0) {
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdate);
}
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
return this.getProfile(validId);
},
this.logger,
{
context: `Update profile for user ${validId}`,
fallbackMessage: "Unable to update profile",
}
);
}
async getUserSummary(userId: string): Promise<DashboardSummary> {
return withErrorHandling(
async () => {
const user = await this.userAuthRepository.findById(userId);
if (!user) {
throw new NotFoundException("User not found");
}
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
this.logger.warn(`No WHMCS mapping found for user ${userId}`);
const currency = "JPY";
const summary: DashboardSummary = {
stats: {
activeSubscriptions: 0,
unpaidInvoices: 0,
openCases: 0,
currency,
},
nextInvoice: null,
recentActivity: [],
};
return summary;
}
2025-08-21 15:24:40 +09:00
const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
status: "Unpaid",
limit: 1,
}),
]);
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 => sub.status === "Active").length;
recentSubscriptions = subscriptions
.filter(sub => sub.status === "Active")
.sort((a, b) => {
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 => ({
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
);
}
2025-08-21 15:24:40 +09:00
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;
}> = [];
// Process unpaid invoices count
if (unpaidInvoicesData.status === "fulfilled") {
unpaidInvoices = unpaidInvoicesData.value.pagination.totalItems;
} else {
this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, {
reason: extractErrorMessage(unpaidInvoicesData.reason),
});
}
2025-08-21 15:24:40 +09:00
if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices;
// Fallback if unpaid invoices call failed, though inaccurate for total count > 10
if (unpaidInvoicesData.status === "rejected") {
unpaidInvoices = invoices.filter(
inv => inv.status === "Unpaid" || inv.status === "Overdue"
).length;
}
const upcomingInvoices = invoices
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
.sort((a, b) => {
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",
};
}
recentInvoices = invoices
.sort((a, b) => {
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 => ({
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: extractErrorMessage(invoicesData.reason),
});
}
const activities: Activity[] = [];
recentInvoices.forEach(invoice => {
if (invoice.status === "Paid") {
const metadata: Record<string, unknown> = {
amount: invoice.total,
currency: invoice.currency ?? "JPY",
};
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: Record<string, unknown> = {
amount: invoice.total,
currency: invoice.currency ?? "JPY",
status: invoice.status,
};
if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
if (invoice.number) metadata.invoiceNumber = invoice.number;
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,
});
}
2025-08-22 17:02:49 +09:00
});
recentSubscriptions.forEach(subscription => {
const metadata: Record<string, unknown> = {
productName: subscription.productName,
status: subscription.status,
};
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,
});
});
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,
});
let currency = "JPY";
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: extractErrorMessage(error),
});
}
const summary: DashboardSummary = {
stats: {
activeSubscriptions,
unpaidInvoices,
openCases: 0,
currency,
},
nextInvoice,
recentActivity,
};
return dashboardSummarySchema.parse(summary);
},
this.logger,
{
context: `Get user summary for ${userId}`,
fallbackMessage: "Unable to retrieve dashboard summary",
}
);
2025-08-28 16:57:57 +09:00
}
private async getProfileForUser(user: PrismaUser): Promise<User> {
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(user.id);
return withErrorHandling(
async () => {
const whmcsClient = await this.whmcsService.getClientDetails(whmcsClientId);
const userAuth = mapPrismaUserToUserAuth(user);
const base = combineToUser(userAuth, whmcsClient);
// Portal-visible identifiers (read-only). These are stored in WHMCS custom fields.
const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
"198"
);
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const rawSfNumber = customerNumberFieldId
? getCustomFieldValue(whmcsClient.customfields, customerNumberFieldId)
: undefined;
const rawDob = dobFieldId
? getCustomFieldValue(whmcsClient.customfields, dobFieldId)
: undefined;
const rawGender = genderFieldId
? getCustomFieldValue(whmcsClient.customfields, genderFieldId)
: undefined;
const sfNumber = rawSfNumber?.trim() ? rawSfNumber.trim() : null;
const dateOfBirth = rawDob?.trim() ? rawDob.trim() : null;
const gender = rawGender?.trim() ? rawGender.trim() : null;
return {
...base,
sfNumber,
dateOfBirth,
gender,
};
},
this.logger,
{
context: `Fetch client profile from WHMCS for user ${user.id}`,
fallbackMessage: "Unable to retrieve customer profile from billing system",
}
);
}
}