Refactor AuditService and AuthAdminController to streamline audit log retrieval and security statistics. Introduce new methods in AuditService for fetching audit logs and security stats, and update AuthAdminController to utilize these methods. Remove redundant code and improve error handling in InvoicesService. Clean up unused DTOs and API routes in the portal. Enhance query keys for better organization in billing and catalog features.
This commit is contained in:
parent
a4119a2db8
commit
dcf32c1d06
@ -37,10 +37,6 @@ export class AuditService {
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
get prismaClient() {
|
||||
return this.prisma;
|
||||
}
|
||||
|
||||
async log(data: AuditLogData): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
@ -115,6 +111,84 @@ export class AuditService {
|
||||
request.ip
|
||||
);
|
||||
}
|
||||
|
||||
async getAuditLogs({
|
||||
page,
|
||||
limit,
|
||||
action,
|
||||
userId,
|
||||
}: {
|
||||
page: number;
|
||||
limit: number;
|
||||
action?: AuditAction;
|
||||
userId?: string;
|
||||
}) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
if (action) where.action = action;
|
||||
if (userId) where.userId = userId;
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { logs, total };
|
||||
}
|
||||
|
||||
async getSecurityStats() {
|
||||
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const [totalUsers, lockedAccounts, failedLoginsToday, successfulLoginsToday] =
|
||||
await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.user.count({
|
||||
where: {
|
||||
lockedUntil: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_SUCCESS,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
lockedAccounts,
|
||||
failedLoginsToday,
|
||||
successfulLoginsToday,
|
||||
securityEventsToday: failedLoginsToday + successfulLoginsToday,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -39,29 +39,12 @@ export class AuthAdminController {
|
||||
throw new BadRequestException("Invalid pagination parameters");
|
||||
}
|
||||
|
||||
const where: { action?: AuditAction; userId?: string } = {};
|
||||
if (action) where.action = action;
|
||||
if (userId) where.userId = userId;
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
this.auditService.prismaClient.auditLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limitNum,
|
||||
}),
|
||||
this.auditService.prismaClient.auditLog.count({ where }),
|
||||
]);
|
||||
const { logs, total } = await this.auditService.getAuditLogs({
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
action,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
@ -103,42 +86,6 @@ export class AuthAdminController {
|
||||
@ApiOperation({ summary: "Get security statistics (admin only)" })
|
||||
@ApiResponse({ status: 200, description: "Security stats retrieved" })
|
||||
async getSecurityStats() {
|
||||
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const [totalUsers, lockedAccounts, failedLoginsToday, successfulLoginsToday] =
|
||||
await Promise.all([
|
||||
this.auditService.prismaClient.user.count(),
|
||||
this.auditService.prismaClient.user.count({
|
||||
where: {
|
||||
lockedUntil: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.auditService.prismaClient.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.auditService.prismaClient.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_SUCCESS,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
lockedAccounts,
|
||||
failedLoginsToday,
|
||||
successfulLoginsToday,
|
||||
securityEventsToday: failedLoginsToday + successfulLoginsToday,
|
||||
};
|
||||
return this.auditService.getSecurityStats();
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,8 +131,8 @@ export class AuthZodController {
|
||||
})
|
||||
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
|
||||
@ApiResponse({ status: 429, description: "Too many link attempts" })
|
||||
async linkWhmcs(@Body(ZodPipe(bffLinkWhmcsSchema)) linkData: BffLinkWhmcsData, @Req() req: Request) {
|
||||
return this.authService.linkWhmcsUser(linkData, req);
|
||||
async linkWhmcs(@Body(ZodPipe(bffLinkWhmcsSchema)) linkData: BffLinkWhmcsData, @Req() _req: Request) {
|
||||
return this.authService.linkWhmcsUser(linkData);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -143,8 +143,8 @@ export class AuthZodController {
|
||||
@ApiResponse({ status: 200, description: "Password set successfully" })
|
||||
@ApiResponse({ status: 401, description: "User not found" })
|
||||
@ApiResponse({ status: 429, description: "Too many password attempts" })
|
||||
async setPassword(@Body(ZodPipe(bffSetPasswordSchema)) setPasswordData: BffSetPasswordData, @Req() req: Request) {
|
||||
return this.authService.setPassword(setPasswordData, req);
|
||||
async setPassword(@Body(ZodPipe(bffSetPasswordSchema)) setPasswordData: BffSetPasswordData, @Req() _req: Request) {
|
||||
return this.authService.setPassword(setPasswordData);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -183,7 +183,12 @@ export class AuthZodController {
|
||||
@Req() req: Request & { user: { id: string } },
|
||||
@Body(ZodPipe(bffChangePasswordSchema)) body: BffChangePasswordData
|
||||
) {
|
||||
return this.authService.changePassword(req.user.id, body.currentPassword, body.newPassword);
|
||||
return this.authService.changePassword(
|
||||
req.user.id,
|
||||
body.currentPassword,
|
||||
body.newPassword,
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
|
||||
@ -15,6 +15,10 @@ import { GlobalAuthGuard } from "./guards/global-auth.guard";
|
||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||
import { EmailModule } from "@bff/infra/email/email.module";
|
||||
import { ValidationModule } from "@bff/core/validation";
|
||||
import { AuthTokenService } from "./services/token.service";
|
||||
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
|
||||
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
|
||||
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -38,6 +42,10 @@ import { ValidationModule } from "@bff/core/validation";
|
||||
JwtStrategy,
|
||||
LocalStrategy,
|
||||
TokenBlacklistService,
|
||||
AuthTokenService,
|
||||
SignupWorkflowService,
|
||||
PasswordWorkflowService,
|
||||
WhmcsLinkWorkflowService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: GlobalAuthGuard,
|
||||
|
||||
@ -3,7 +3,6 @@ import {
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
@ -15,41 +14,45 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||
import { SignupDto } from "./dto/signup.dto";
|
||||
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||
import { ValidateSignupDto } from "./dto/validate-signup.dto";
|
||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
|
||||
import { EmailService } from "@bff/infra/email/email.service";
|
||||
|
||||
import { User as SharedUser, type AuthTokens } from "@customer-portal/domain";
|
||||
import {
|
||||
type BffSignupData,
|
||||
type BffValidateSignupData,
|
||||
type BffLinkWhmcsData,
|
||||
type BffSetPasswordData,
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
import type { Request } from "express";
|
||||
import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||
import { calculateExpiryDate } from "./utils/jwt-expiry.util";
|
||||
import { AuthTokenService } from "./services/token.service";
|
||||
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
|
||||
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
|
||||
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
||||
import { sanitizeUser } from "./utils/sanitize-user.util";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly MAX_LOGIN_ATTEMPTS = 5;
|
||||
private readonly LOCKOUT_DURATION_MINUTES = 15;
|
||||
private readonly DEFAULT_TOKEN_TYPE = "Bearer";
|
||||
private readonly DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private mappingsService: MappingsService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private whmcsService: WhmcsService,
|
||||
private salesforceService: SalesforceService,
|
||||
private auditService: AuditService,
|
||||
private tokenBlacklistService: TokenBlacklistService,
|
||||
private emailService: EmailService,
|
||||
private prisma: PrismaService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly signupWorkflow: SignupWorkflowService,
|
||||
private readonly passwordWorkflow: PasswordWorkflowService,
|
||||
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -98,314 +101,12 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async validateSignup(validateData: ValidateSignupDto, request?: Request) {
|
||||
const { sfNumber } = validateData;
|
||||
|
||||
try {
|
||||
// 1. Check if SF number exists in Salesforce
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||
if (!sfAccount) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, reason: "SF number not found" },
|
||||
request,
|
||||
false,
|
||||
"Customer number not found in Salesforce"
|
||||
);
|
||||
throw new BadRequestException("Customer number not found in Salesforce");
|
||||
}
|
||||
|
||||
// 2. Check if SF account already has a mapping (already registered)
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
||||
if (existingMapping) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, sfAccountId: sfAccount.id, reason: "Already has mapping" },
|
||||
request,
|
||||
false,
|
||||
"Customer number already registered"
|
||||
);
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Check WH_Account__c field in Salesforce
|
||||
const accountDetails = await this.salesforceService.getAccountDetails(sfAccount.id);
|
||||
if (accountDetails?.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{
|
||||
sfNumber,
|
||||
sfAccountId: sfAccount.id,
|
||||
whAccount: accountDetails.WH_Account__c,
|
||||
reason: "WH Account not empty",
|
||||
},
|
||||
request,
|
||||
false,
|
||||
"Account already has WHMCS integration"
|
||||
);
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
}
|
||||
|
||||
// Log successful validation
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, sfAccountId: sfAccount.id, step: "validation" },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
sfAccountId: sfAccount.id,
|
||||
message: "Customer number validated successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
// Re-throw known exceptions
|
||||
if (error instanceof BadRequestException || error instanceof ConflictException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log unexpected errors
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, error: getErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
|
||||
this.logger.error("Signup validation error", { error: getErrorMessage(error) });
|
||||
throw new BadRequestException("Validation failed");
|
||||
}
|
||||
async validateSignup(validateData: BffValidateSignupData, request?: Request) {
|
||||
return this.signupWorkflow.validateSignup(validateData, request);
|
||||
}
|
||||
|
||||
async signup(signupData: SignupDto, request?: Request) {
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
phone,
|
||||
sfNumber,
|
||||
address,
|
||||
nationality,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
} = signupData;
|
||||
|
||||
// Enhanced input validation
|
||||
this.validateSignupData(signupData);
|
||||
|
||||
// Check if a portal user already exists (do not create anything yet)
|
||||
const existingUser = await this.usersService.findByEmailInternal(email);
|
||||
if (existingUser) {
|
||||
const mapped = await this.mappingsService.hasMapping(existingUser.id);
|
||||
const message = mapped
|
||||
? "You already have an account. Please sign in."
|
||||
: "You already have an account with us. Please sign in to continue setup.";
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
existingUser.id,
|
||||
{ email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" },
|
||||
request,
|
||||
false,
|
||||
message
|
||||
);
|
||||
throw new ConflictException(message);
|
||||
}
|
||||
|
||||
// Hash password with environment-based configuration (computed ahead, used after WHMCS success)
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
try {
|
||||
// 0. Lookup Salesforce Account by Customer Number (SF Number)
|
||||
const sfAccount: { id: string } | null =
|
||||
await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||
if (!sfAccount) {
|
||||
throw new BadRequestException(
|
||||
`Salesforce account not found for Customer Number: ${sfNumber}`
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Create client in WHMCS first (no portal user is created yet)
|
||||
let whmcsClient: { clientId: number };
|
||||
try {
|
||||
// 1.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
|
||||
try {
|
||||
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
if (existingWhmcs) {
|
||||
// If a mapping already exists for this WHMCS client, user should sign in
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(
|
||||
existingWhmcs.id
|
||||
);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("You already have an account. Please sign in.");
|
||||
}
|
||||
|
||||
// Otherwise, instruct to link the existing billing account instead of creating a new one
|
||||
throw new ConflictException(
|
||||
"We found an existing billing account for this email. Please link your account instead."
|
||||
);
|
||||
}
|
||||
} catch (pre) {
|
||||
// Continue only if the client was not found; rethrow other errors
|
||||
if (!(pre instanceof NotFoundException)) {
|
||||
throw pre;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare WHMCS custom fields (IDs configurable via env)
|
||||
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 nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||
|
||||
const customfields: Record<string, string> = {};
|
||||
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
|
||||
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId && gender) customfields[genderFieldId] = gender;
|
||||
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
|
||||
|
||||
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
|
||||
|
||||
// Validate required WHMCS fields
|
||||
if (
|
||||
!address?.street ||
|
||||
!address?.city ||
|
||||
!address?.state ||
|
||||
!address?.postalCode ||
|
||||
!address?.country
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Complete address information is required for billing account creation"
|
||||
);
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
throw new BadRequestException("Phone number is required for billing account creation");
|
||||
}
|
||||
|
||||
this.logger.log("WHMCS client data", {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
address: address,
|
||||
phone,
|
||||
country: address.country,
|
||||
});
|
||||
|
||||
whmcsClient = await this.whmcsService.addClient({
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
email,
|
||||
companyname: company || "",
|
||||
phonenumber: phone,
|
||||
address1: address.street,
|
||||
address2: address.streetLine2 || "",
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postalCode,
|
||||
country: address.country,
|
||||
password2: password, // WHMCS requires plain password for new clients
|
||||
customfields,
|
||||
});
|
||||
|
||||
this.logger.log("WHMCS client created successfully", {
|
||||
clientId: whmcsClient.clientId,
|
||||
email,
|
||||
});
|
||||
} catch (whmcsError) {
|
||||
this.logger.error("Failed to create WHMCS client", {
|
||||
error: getErrorMessage(whmcsError),
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
throw new BadRequestException(
|
||||
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Only now create the portal user and mapping atomically
|
||||
const { createdUserId } = await this.prisma.$transaction(async tx => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
company: company || null,
|
||||
phone: phone || null,
|
||||
emailVerified: false,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
await tx.idMapping.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
whmcsClientId: whmcsClient.clientId,
|
||||
sfAccountId: sfAccount.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { createdUserId: created.id };
|
||||
});
|
||||
|
||||
// Fetch sanitized user response
|
||||
const freshUser = await this.usersService.findByIdInternal(createdUserId);
|
||||
|
||||
// Log successful signup
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
createdUserId,
|
||||
{ email, whmcsClientId: whmcsClient.clientId },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
const tokens = this.generateTokens({ id: createdUserId, email });
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(
|
||||
freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser)
|
||||
),
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
// Log failed signup
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ email, error: getErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
|
||||
// TODO: Implement rollback logic if any step fails
|
||||
this.logger.error("Signup error", { error: getErrorMessage(error) });
|
||||
throw new BadRequestException("Failed to create user account");
|
||||
}
|
||||
async signup(signupData: BffSignupData, request?: Request) {
|
||||
return this.signupWorkflow.signup(signupData, request);
|
||||
}
|
||||
|
||||
async login(
|
||||
@ -435,167 +136,23 @@ export class AuthService {
|
||||
true
|
||||
);
|
||||
|
||||
const tokens = this.generateTokens(user);
|
||||
const tokens = this.tokenService.generateTokens(user);
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
user: sanitizeUser(user),
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async linkWhmcsUser(linkData: LinkWhmcsDto, _request?: Request) {
|
||||
const { email, password } = linkData;
|
||||
|
||||
// Check if user already exists in portal
|
||||
const existingUser = await this.usersService.findByEmailInternal(email);
|
||||
if (existingUser) {
|
||||
// If user exists but has no password (abandoned during setup), allow them to continue
|
||||
if (!existingUser.passwordHash) {
|
||||
this.logger.log(
|
||||
`User ${email} exists but has no password - allowing password setup to continue`
|
||||
);
|
||||
return {
|
||||
user: this.sanitizeUser(existingUser),
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
} else {
|
||||
throw new ConflictException(
|
||||
"User already exists in portal and has completed setup. Please use the login page."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. First, find the client by email using GetClientsDetails directly
|
||||
let clientDetails: WhmcsClientResponse["client"];
|
||||
try {
|
||||
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
} catch (error) {
|
||||
this.logger.warn(`WHMCS client lookup failed for email ${email}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new UnauthorizedException("WHMCS client not found with this email address");
|
||||
}
|
||||
|
||||
// 1.a If this WHMCS client is already mapped, direct the user to sign in instead
|
||||
try {
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("This billing account is already linked. Please sign in.");
|
||||
}
|
||||
} catch (mapErr) {
|
||||
if (mapErr instanceof ConflictException) throw mapErr;
|
||||
// ignore not-found mapping cases; proceed with linking
|
||||
}
|
||||
|
||||
// 2. Validate the password using ValidateLogin
|
||||
try {
|
||||
this.logger.debug(`About to validate WHMCS password for ${email}`);
|
||||
const validateResult = await this.whmcsService.validateLogin(email, password);
|
||||
this.logger.debug("WHMCS validation successful", { email });
|
||||
if (!validateResult || !validateResult.userId) {
|
||||
throw new UnauthorizedException("Invalid WHMCS credentials");
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug("WHMCS validation failed", { email, error: getErrorMessage(error) });
|
||||
throw new UnauthorizedException("Invalid WHMCS password");
|
||||
}
|
||||
|
||||
// 3. Extract Customer Number from field ID 198
|
||||
const customerNumberField = clientDetails.customfields?.find(field => field.id === 198);
|
||||
const customerNumber = customerNumberField?.value?.trim();
|
||||
|
||||
if (!customerNumber) {
|
||||
throw new BadRequestException(
|
||||
`Customer Number not found in WHMCS custom field 198. Please contact support.`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`
|
||||
);
|
||||
|
||||
// 3. Find existing Salesforce account using Customer Number
|
||||
const sfAccount: { id: string } | null =
|
||||
await this.salesforceService.findAccountByCustomerNumber(customerNumber);
|
||||
if (!sfAccount) {
|
||||
throw new BadRequestException(
|
||||
`Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Create portal user (without password initially)
|
||||
const user: SharedUser = await this.usersService.create({
|
||||
email,
|
||||
passwordHash: null, // No password hash - will be set when user sets password
|
||||
firstName: clientDetails.firstname || "",
|
||||
lastName: clientDetails.lastname || "",
|
||||
company: clientDetails.companyname || "",
|
||||
phone: clientDetails.phonenumber || "",
|
||||
emailVerified: true, // WHMCS users are pre-verified
|
||||
});
|
||||
|
||||
// 5. Store ID mappings
|
||||
await this.mappingsService.createMapping({
|
||||
userId: user.id,
|
||||
whmcsClientId: clientDetails.id,
|
||||
sfAccountId: sfAccount.id,
|
||||
});
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("WHMCS linking error", { error: getErrorMessage(error) });
|
||||
if (error instanceof BadRequestException || error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
throw new BadRequestException("Failed to link WHMCS account");
|
||||
}
|
||||
async linkWhmcsUser(linkData: BffLinkWhmcsData) {
|
||||
return this.whmcsLinkWorkflow.linkWhmcsUser(linkData.email, linkData.password);
|
||||
}
|
||||
|
||||
async checkPasswordNeeded(email: string) {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
return { needsPasswordSet: false, userExists: false };
|
||||
}
|
||||
|
||||
return {
|
||||
needsPasswordSet: !user.passwordHash,
|
||||
userExists: true,
|
||||
email: user.email,
|
||||
};
|
||||
return this.passwordWorkflow.checkPasswordNeeded(email);
|
||||
}
|
||||
|
||||
async setPassword(setPasswordData: SetPasswordDto, _request?: Request) {
|
||||
const { email, password } = setPasswordData;
|
||||
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
|
||||
// Check if user needs to set password (linked users have null password hash)
|
||||
if (user.passwordHash) {
|
||||
throw new BadRequestException("User already has a password set");
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const saltRounds = 12; // Use a fixed safe value
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Update user with new password
|
||||
const updatedUser: SharedUser = await this.usersService.update(user.id, {
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = this.generateTokens(updatedUser);
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(updatedUser),
|
||||
tokens,
|
||||
};
|
||||
async setPassword(setPasswordData: BffSetPasswordData) {
|
||||
return this.passwordWorkflow.setPassword(setPasswordData.email, setPasswordData.password);
|
||||
}
|
||||
|
||||
async validateUser(
|
||||
@ -722,41 +279,6 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private generateTokens(user: { id: string; email: string; role?: string }): AuthTokens {
|
||||
const payload = { email: user.email, sub: user.id, role: user.role };
|
||||
const expiresIn = this.configService.get<string | number>("JWT_EXPIRES_IN", "7d");
|
||||
const accessToken = this.jwtService.sign(payload, { expiresIn });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
expiresAt: calculateExpiryDate(expiresIn),
|
||||
tokenType: "Bearer",
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeUser<
|
||||
T extends {
|
||||
id: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
passwordHash?: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
},
|
||||
>(user: T): Omit<T, "passwordHash" | "failedLoginAttempts" | "lockedUntil"> {
|
||||
const {
|
||||
passwordHash: _passwordHash,
|
||||
failedLoginAttempts: _failedLoginAttempts,
|
||||
lockedUntil: _lockedUntil,
|
||||
...sanitizedUser
|
||||
} = user as T & {
|
||||
passwordHash?: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
};
|
||||
return sanitizedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO link to WHMCS for general access
|
||||
*/
|
||||
@ -863,68 +385,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
// Always act as if successful to avoid account enumeration
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a short-lived signed token (JWT) containing user id and purpose
|
||||
const token = this.jwtService.sign(
|
||||
{ sub: user.id, purpose: "password_reset" },
|
||||
{ expiresIn: "15m" }
|
||||
);
|
||||
|
||||
const appBase = this.configService.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`;
|
||||
const templateId = this.configService.get<string>("EMAIL_TEMPLATE_RESET");
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Reset your password",
|
||||
templateId,
|
||||
dynamicTemplateData: { resetUrl },
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Reset your Assist Solutions password",
|
||||
html: `
|
||||
<p>We received a request to reset your password.</p>
|
||||
<p><a href="${resetUrl}">Click here to reset your password</a>. This link expires in 15 minutes.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
await this.passwordWorkflow.requestPasswordReset(email);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; purpose: string }>(token);
|
||||
if (payload.purpose !== "password_reset") {
|
||||
throw new BadRequestException("Invalid token");
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user) throw new BadRequestException("Invalid token");
|
||||
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
||||
const tokens = this.generateTokens(updatedUser);
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(updatedUser),
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Reset password failed", { error: getErrorMessage(error) });
|
||||
throw new BadRequestException("Invalid or expired token");
|
||||
}
|
||||
return this.passwordWorkflow.resetPassword(token, newPassword);
|
||||
}
|
||||
|
||||
async getAccountStatus(email: string) {
|
||||
@ -992,195 +457,21 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
// Fetch raw user with passwordHash
|
||||
const user = await this.usersService.findByIdInternal(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new BadRequestException("No password set. Please set a password first.");
|
||||
}
|
||||
|
||||
const isCurrentValid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!isCurrentValid) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.PASSWORD_CHANGE,
|
||||
user.id,
|
||||
{ action: "change_password", reason: "invalid_current_password" },
|
||||
undefined,
|
||||
false,
|
||||
"Invalid current password"
|
||||
);
|
||||
throw new BadRequestException("Current password is incorrect");
|
||||
}
|
||||
|
||||
// Validate new password strength (reusing signup policy)
|
||||
if (
|
||||
newPassword.length < 8 ||
|
||||
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Password must be at least 8 characters and include uppercase, lowercase, number, and special character."
|
||||
);
|
||||
}
|
||||
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.PASSWORD_CHANGE,
|
||||
user.id,
|
||||
{ action: "change_password" },
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
// Issue fresh tokens
|
||||
const tokens = this.generateTokens(updatedUser);
|
||||
return {
|
||||
user: this.sanitizeUser(updatedUser),
|
||||
tokens,
|
||||
};
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
request?: Request
|
||||
) {
|
||||
return this.passwordWorkflow.changePassword(userId, currentPassword, newPassword, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight validation for signup. No side effects.
|
||||
* Returns a clear nextAction for the UI and detailed flags.
|
||||
*/
|
||||
async signupPreflight(signupData: SignupDto) {
|
||||
const { email, sfNumber } = signupData;
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
const result: {
|
||||
ok: boolean;
|
||||
canProceed: boolean;
|
||||
nextAction: "proceed_signup" | "link_whmcs" | "login" | "fix_input" | "blocked";
|
||||
messages: string[];
|
||||
normalized: { email: string };
|
||||
portal: { userExists: boolean; needsPasswordSet?: boolean };
|
||||
salesforce: { accountId?: string; alreadyMapped: boolean };
|
||||
whmcs: { clientExists: boolean; clientId?: number };
|
||||
} = {
|
||||
ok: true,
|
||||
canProceed: false,
|
||||
nextAction: "blocked",
|
||||
messages: [],
|
||||
normalized: { email: normalizedEmail },
|
||||
portal: { userExists: false },
|
||||
salesforce: { alreadyMapped: false },
|
||||
whmcs: { clientExists: false },
|
||||
};
|
||||
|
||||
// 0) Portal user existence
|
||||
const portalUser = await this.usersService.findByEmailInternal(normalizedEmail);
|
||||
if (portalUser) {
|
||||
result.portal.userExists = true;
|
||||
const mapped = await this.mappingsService.hasMapping(portalUser.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("An account already exists. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy unmapped user
|
||||
result.portal.needsPasswordSet = !portalUser.passwordHash;
|
||||
result.nextAction = portalUser.passwordHash ? "login" : "fix_input";
|
||||
result.messages.push(
|
||||
portalUser.passwordHash
|
||||
? "An account exists without billing link. Please sign in to continue setup."
|
||||
: "An account exists and needs password setup. Please set a password to continue."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 1) Salesforce checks
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||
if (!sfAccount) {
|
||||
result.nextAction = "fix_input";
|
||||
result.messages.push("Customer number not found in Salesforce");
|
||||
return result;
|
||||
}
|
||||
result.salesforce.accountId = sfAccount.id;
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
||||
if (existingMapping) {
|
||||
result.salesforce.alreadyMapped = true;
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This customer number is already registered. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2) WHMCS checks by email
|
||||
try {
|
||||
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
|
||||
if (client) {
|
||||
result.whmcs.clientExists = true;
|
||||
result.whmcs.clientId = client.id;
|
||||
// If this WHMCS client is already linked to a portal user, direct to login
|
||||
try {
|
||||
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This billing account is already linked. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// ignore; treat as unmapped
|
||||
}
|
||||
|
||||
// Client exists but not mapped → suggest linking instead of creating new
|
||||
result.nextAction = "link_whmcs";
|
||||
result.messages.push(
|
||||
"We found an existing billing account for this email. Please link your account."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
// NotFoundException is expected when client doesn't exist. Other errors are reported.
|
||||
if (!(err instanceof NotFoundException)) {
|
||||
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
|
||||
result.messages.push("Unable to verify billing system. Please try again later.");
|
||||
result.nextAction = "blocked";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here: no portal user, SF valid and not mapped, no WHMCS client → OK to proceed
|
||||
result.canProceed = true;
|
||||
result.nextAction = "proceed_signup";
|
||||
result.messages.push("All checks passed. Ready to create your account.");
|
||||
return result;
|
||||
async signupPreflight(signupData: BffSignupData) {
|
||||
return this.signupWorkflow.signupPreflight(signupData);
|
||||
}
|
||||
|
||||
private validateSignupData(signupData: SignupDto) {
|
||||
const { email, password, firstName, lastName } = signupData;
|
||||
|
||||
if (!email || !password || !firstName || !lastName) {
|
||||
throw new BadRequestException(
|
||||
"Email, password, firstName, and lastName are required for signup."
|
||||
);
|
||||
}
|
||||
|
||||
if (!email.includes("@")) {
|
||||
throw new BadRequestException("Invalid email address.");
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new BadRequestException("Password must be at least 8 characters long.");
|
||||
}
|
||||
|
||||
// Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character
|
||||
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(password)) {
|
||||
throw new BadRequestException(
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail } from "class-validator";
|
||||
|
||||
export class AccountStatusRequestDto {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
|
||||
export type AccountState = "none" | "portal_only" | "whmcs_only" | "both_mapped";
|
||||
export type RecommendedAction = "sign_up" | "sign_in" | "link_account" | "set_password";
|
||||
|
||||
export interface AccountStatusResponseDto {
|
||||
state: AccountState;
|
||||
portalUserExists: boolean;
|
||||
whmcsClientExists: boolean;
|
||||
mapped: boolean;
|
||||
needsPasswordSet?: boolean;
|
||||
recommendedAction: RecommendedAction;
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength, Matches } from "class-validator";
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ example: "CurrentPassword123!" })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({ example: "NewSecurePassword123!" })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/)
|
||||
newPassword!: string;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { IsEmail, IsString } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class LinkWhmcsDto {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: "existing-whmcs-password" })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { IsEmail, IsString } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: "SecurePassword123!" })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail } from "class-validator";
|
||||
|
||||
export class RequestPasswordResetDto {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength, Matches } from "class-validator";
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ description: "Password reset token" })
|
||||
@IsString()
|
||||
token!: string;
|
||||
|
||||
@ApiProperty({ example: "SecurePassword123!" })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/)
|
||||
password!: string;
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { IsEmail, IsString, MinLength, Matches } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class SetPasswordDto {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "NewSecurePassword123!",
|
||||
description:
|
||||
"Password must be at least 8 characters and contain uppercase, lowercase, number, and special character",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8, { message: "Password must be at least 8 characters long" })
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/, {
|
||||
message:
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
Matches,
|
||||
IsIn,
|
||||
ValidateNested,
|
||||
IsNotEmpty,
|
||||
} from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class AddressDto {
|
||||
@ApiProperty({ example: "123 Main Street" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
street: string;
|
||||
|
||||
@ApiProperty({ example: "Apt 4B", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
streetLine2?: string;
|
||||
|
||||
@ApiProperty({ example: "Tokyo" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
city: string;
|
||||
|
||||
@ApiProperty({ example: "Tokyo" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
state: string;
|
||||
|
||||
@ApiProperty({ example: "100-0001" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
postalCode: string;
|
||||
|
||||
@ApiProperty({ example: "JP", description: "ISO 2-letter country code" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
country: string;
|
||||
}
|
||||
|
||||
export class SignupDto {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "SecurePassword123!",
|
||||
description:
|
||||
"Password must be at least 8 characters and contain uppercase, lowercase, number, and special character",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8, { message: "Password must be at least 8 characters long" })
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/, {
|
||||
message:
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
|
||||
})
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: "John" })
|
||||
@IsString()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: "Doe" })
|
||||
@IsString()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: "Acme Corp", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
company?: string;
|
||||
|
||||
@ApiProperty({ example: "+81-90-1234-5678", description: "Contact phone number" })
|
||||
@IsString()
|
||||
@Matches(/^[+]?[0-9\s\-()]{7,20}$/, {
|
||||
message:
|
||||
"Phone number must contain 7-20 digits and may include +, spaces, dashes, and parentheses",
|
||||
})
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({ example: "CN-0012345", description: "Customer Number (SF Number)" })
|
||||
@IsString()
|
||||
sfNumber: string;
|
||||
|
||||
@ApiProperty({ description: "Address for WHMCS client (required)" })
|
||||
@ValidateNested()
|
||||
@Type(() => AddressDto)
|
||||
address: AddressDto;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationality?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: "1990-01-01" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateOfBirth?: string;
|
||||
|
||||
@ApiProperty({ required: false, enum: ["male", "female", "other"] })
|
||||
@IsOptional()
|
||||
@IsIn(["male", "female", "other"])
|
||||
gender?: "male" | "female" | "other";
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class SsoLinkDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "WHMCS destination path",
|
||||
example: "index.php?rp=/account/paymentmethods",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
destination?: string;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { IsString, IsNotEmpty } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class ValidateSignupDto {
|
||||
@ApiProperty({
|
||||
description: "Customer Number (SF Number) to validate",
|
||||
example: "12345",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sfNumber: string;
|
||||
}
|
||||
25
apps/bff/src/modules/auth/services/token.service.ts
Normal file
25
apps/bff/src/modules/auth/services/token.service.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { calculateExpiryDate } from "../utils/jwt-expiry.util";
|
||||
import type { AuthTokens } from "@customer-portal/domain";
|
||||
|
||||
@Injectable()
|
||||
export class AuthTokenService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
generateTokens(user: { id: string; email: string; role?: string }): AuthTokens {
|
||||
const payload = { email: user.email, sub: user.id, role: user.role };
|
||||
const expiresIn = this.configService.get<string | number>("JWT_EXPIRES_IN", "7d");
|
||||
const accessToken = this.jwtService.sign(payload, { expiresIn });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
expiresAt: calculateExpiryDate(expiresIn),
|
||||
tokenType: "Bearer",
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,195 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import type { Request } from "express";
|
||||
import { UsersService } from "@bff/modules/users/users.service";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
||||
import { EmailService } from "@bff/infra/email/email.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { AuthTokenService } from "../token.service";
|
||||
import { sanitizeUser } from "../../utils/sanitize-user.util";
|
||||
import {
|
||||
type AuthTokens,
|
||||
} from "@customer-portal/domain";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
|
||||
interface PasswordChangeResult {
|
||||
user: Omit<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lockedUntil">;
|
||||
tokens: AuthTokens;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PasswordWorkflowService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async checkPasswordNeeded(email: string) {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
return { needsPasswordSet: false, userExists: false };
|
||||
}
|
||||
|
||||
return {
|
||||
needsPasswordSet: !user.passwordHash,
|
||||
userExists: true,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
async setPassword(email: string, password: string) {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
|
||||
if (user.passwordHash) {
|
||||
throw new BadRequestException("User already has a password set");
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
||||
const tokens = this.tokenService.generateTokens(updatedUser);
|
||||
|
||||
return {
|
||||
user: sanitizeUser(updatedUser),
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.jwtService.sign(
|
||||
{ sub: user.id, purpose: "password_reset" },
|
||||
{ expiresIn: "15m" }
|
||||
);
|
||||
|
||||
const appBase = this.configService.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`;
|
||||
const templateId = this.configService.get<string>("EMAIL_TEMPLATE_RESET");
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Reset your password",
|
||||
templateId,
|
||||
dynamicTemplateData: { resetUrl },
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Reset your Assist Solutions password",
|
||||
html: `
|
||||
<p>We received a request to reset your password.</p>
|
||||
<p><a href="${resetUrl}">Click here to reset your password</a>. This link expires in 15 minutes.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string): Promise<PasswordChangeResult> {
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; purpose: string }>(token);
|
||||
if (payload.purpose !== "password_reset") {
|
||||
throw new BadRequestException("Invalid token");
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user) throw new BadRequestException("Invalid token");
|
||||
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
||||
const tokens = this.tokenService.generateTokens(updatedUser);
|
||||
|
||||
return {
|
||||
user: sanitizeUser(updatedUser),
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Reset password failed", { error: getErrorMessage(error) });
|
||||
throw new BadRequestException("Invalid or expired token");
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
request?: Request
|
||||
): Promise<PasswordChangeResult> {
|
||||
const user = await this.usersService.findByIdInternal(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new BadRequestException("No password set. Please set a password first.");
|
||||
}
|
||||
|
||||
const isCurrentValid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!isCurrentValid) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.PASSWORD_CHANGE,
|
||||
user.id,
|
||||
{ action: "change_password", reason: "invalid_current_password" },
|
||||
request,
|
||||
false,
|
||||
"Invalid current password"
|
||||
);
|
||||
throw new BadRequestException("Current password is incorrect");
|
||||
}
|
||||
|
||||
if (
|
||||
newPassword.length < 8 ||
|
||||
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Password must be at least 8 characters and include uppercase, lowercase, number, and special character."
|
||||
);
|
||||
}
|
||||
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.PASSWORD_CHANGE,
|
||||
user.id,
|
||||
{ action: "change_password" },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
const tokens = this.tokenService.generateTokens(updatedUser);
|
||||
return {
|
||||
user: sanitizeUser(updatedUser),
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,438 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import type { Request } from "express";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
||||
import { UsersService } from "@bff/modules/users/users.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||
import { AuthTokenService } from "../token.service";
|
||||
import { sanitizeUser } from "../../utils/sanitize-user.util";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import {
|
||||
bffSignupSchema,
|
||||
type BffSignupData,
|
||||
type BffValidateSignupData,
|
||||
type AuthTokens,
|
||||
} from "@customer-portal/domain";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
|
||||
type SanitizedPrismaUser = Omit<
|
||||
PrismaUser,
|
||||
"passwordHash" | "failedLoginAttempts" | "lockedUntil"
|
||||
>;
|
||||
|
||||
interface SignupResult {
|
||||
user: SanitizedPrismaUser;
|
||||
tokens: AuthTokens;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SignupWorkflowService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async validateSignup(validateData: BffValidateSignupData, request?: Request) {
|
||||
const { sfNumber } = validateData;
|
||||
|
||||
try {
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||
if (!sfAccount) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, reason: "SF number not found" },
|
||||
request,
|
||||
false,
|
||||
"Customer number not found in Salesforce"
|
||||
);
|
||||
throw new BadRequestException("Customer number not found in Salesforce");
|
||||
}
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
||||
if (existingMapping) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, sfAccountId: sfAccount.id, reason: "Already has mapping" },
|
||||
request,
|
||||
false,
|
||||
"Customer number already registered"
|
||||
);
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
}
|
||||
|
||||
const accountDetails = await this.salesforceService.getAccountDetails(sfAccount.id);
|
||||
if (accountDetails?.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{
|
||||
sfNumber,
|
||||
sfAccountId: sfAccount.id,
|
||||
whAccount: accountDetails.WH_Account__c,
|
||||
reason: "WH Account not empty",
|
||||
},
|
||||
request,
|
||||
false,
|
||||
"Account already has WHMCS integration"
|
||||
);
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
}
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, sfAccountId: sfAccount.id, step: "validation" },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
sfAccountId: sfAccount.id,
|
||||
message: "Customer number validated successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException || error instanceof ConflictException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, error: getErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
|
||||
this.logger.error("Signup validation error", { error: getErrorMessage(error) });
|
||||
throw new BadRequestException("Validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
async signup(signupData: BffSignupData, request?: Request): Promise<SignupResult> {
|
||||
this.validateSignupData(signupData);
|
||||
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
phone,
|
||||
sfNumber,
|
||||
address,
|
||||
nationality,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
} = signupData;
|
||||
|
||||
const existingUser = await this.usersService.findByEmailInternal(email);
|
||||
if (existingUser) {
|
||||
const mapped = await this.mappingsService.hasMapping(existingUser.id);
|
||||
const message = mapped
|
||||
? "You already have an account. Please sign in."
|
||||
: "You already have an account with us. Please sign in to continue setup.";
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
existingUser.id,
|
||||
{ email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" },
|
||||
request,
|
||||
false,
|
||||
message
|
||||
);
|
||||
throw new ConflictException(message);
|
||||
}
|
||||
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
try {
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||
if (!sfAccount) {
|
||||
throw new BadRequestException(
|
||||
`Salesforce account not found for Customer Number: ${sfNumber}`
|
||||
);
|
||||
}
|
||||
|
||||
let whmcsClient: { clientId: number };
|
||||
try {
|
||||
try {
|
||||
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
if (existingWhmcs) {
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(
|
||||
existingWhmcs.id
|
||||
);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("You already have an account. Please sign in.");
|
||||
}
|
||||
|
||||
throw new ConflictException(
|
||||
"We found an existing billing account for this email. Please link your account instead."
|
||||
);
|
||||
}
|
||||
} catch (pre) {
|
||||
if (!(pre instanceof NotFoundException)) {
|
||||
throw pre;
|
||||
}
|
||||
}
|
||||
|
||||
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 nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||
|
||||
const customfields: Record<string, string> = {};
|
||||
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
|
||||
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId && gender) customfields[genderFieldId] = gender;
|
||||
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
|
||||
|
||||
if (
|
||||
!address?.street ||
|
||||
!address?.city ||
|
||||
!address?.state ||
|
||||
!address?.postalCode ||
|
||||
!address?.country
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Complete address information is required for billing account creation"
|
||||
);
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
throw new BadRequestException("Phone number is required for billing account creation");
|
||||
}
|
||||
|
||||
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
|
||||
|
||||
whmcsClient = await this.whmcsService.addClient({
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
email,
|
||||
companyname: company || "",
|
||||
phonenumber: phone,
|
||||
address1: address.street,
|
||||
address2: address.streetLine2 || "",
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postalCode,
|
||||
country: address.country,
|
||||
password2: password,
|
||||
customfields,
|
||||
});
|
||||
|
||||
this.logger.log("WHMCS client created successfully", {
|
||||
clientId: whmcsClient.clientId,
|
||||
email,
|
||||
});
|
||||
} catch (whmcsError) {
|
||||
this.logger.error("Failed to create WHMCS client", {
|
||||
error: getErrorMessage(whmcsError),
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
throw new BadRequestException(
|
||||
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
|
||||
);
|
||||
}
|
||||
|
||||
const { createdUserId } = await this.prisma.$transaction(async tx => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
company: company || null,
|
||||
phone: phone || null,
|
||||
emailVerified: false,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
await tx.idMapping.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
whmcsClientId: whmcsClient.clientId,
|
||||
sfAccountId: sfAccount.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { createdUserId: created.id };
|
||||
});
|
||||
|
||||
const freshUser = await this.usersService.findByIdInternal(createdUserId);
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
createdUserId,
|
||||
{ email, whmcsClientId: whmcsClient.clientId },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
const tokens = this.tokenService.generateTokens({ id: createdUserId, email });
|
||||
|
||||
return {
|
||||
user: sanitizeUser(
|
||||
freshUser ?? ({ id: createdUserId, email } as PrismaUser)
|
||||
),
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ email, error: getErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
|
||||
this.logger.error("Signup error", { error: getErrorMessage(error) });
|
||||
throw new BadRequestException("Failed to create user account");
|
||||
}
|
||||
}
|
||||
|
||||
async signupPreflight(signupData: BffSignupData) {
|
||||
const { email, sfNumber } = signupData;
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
const result: {
|
||||
ok: boolean;
|
||||
canProceed: boolean;
|
||||
nextAction: "proceed_signup" | "link_whmcs" | "login" | "fix_input" | "blocked";
|
||||
messages: string[];
|
||||
normalized: { email: string };
|
||||
portal: { userExists: boolean; needsPasswordSet?: boolean };
|
||||
salesforce: { accountId?: string; alreadyMapped: boolean };
|
||||
whmcs: { clientExists: boolean; clientId?: number };
|
||||
} = {
|
||||
ok: true,
|
||||
canProceed: false,
|
||||
nextAction: "blocked",
|
||||
messages: [],
|
||||
normalized: { email: normalizedEmail },
|
||||
portal: { userExists: false },
|
||||
salesforce: { alreadyMapped: false },
|
||||
whmcs: { clientExists: false },
|
||||
};
|
||||
|
||||
const portalUser = await this.usersService.findByEmailInternal(normalizedEmail);
|
||||
if (portalUser) {
|
||||
result.portal.userExists = true;
|
||||
const mapped = await this.mappingsService.hasMapping(portalUser.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("An account already exists. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.portal.needsPasswordSet = !portalUser.passwordHash;
|
||||
result.nextAction = portalUser.passwordHash ? "login" : "fix_input";
|
||||
result.messages.push(
|
||||
portalUser.passwordHash
|
||||
? "An account exists without billing link. Please sign in to continue setup."
|
||||
: "An account exists and needs password setup. Please set a password to continue."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||
if (!sfAccount) {
|
||||
result.nextAction = "fix_input";
|
||||
result.messages.push("Customer number not found in Salesforce");
|
||||
return result;
|
||||
}
|
||||
result.salesforce.accountId = sfAccount.id;
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
||||
if (existingMapping) {
|
||||
result.salesforce.alreadyMapped = true;
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This customer number is already registered. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
|
||||
if (client) {
|
||||
result.whmcs.clientExists = true;
|
||||
result.whmcs.clientId = client.id;
|
||||
|
||||
try {
|
||||
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This billing account is already linked. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// ignore; treat as unmapped
|
||||
}
|
||||
|
||||
result.nextAction = "link_whmcs";
|
||||
result.messages.push(
|
||||
"We found an existing billing account for this email. Please link your account."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof NotFoundException)) {
|
||||
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
|
||||
result.messages.push("Unable to verify billing system. Please try again later.");
|
||||
result.nextAction = "blocked";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result.canProceed = true;
|
||||
result.nextAction = "proceed_signup";
|
||||
result.messages.push("All checks passed. Ready to create your account.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private validateSignupData(signupData: BffSignupData) {
|
||||
const validation = bffSignupSchema.safeParse(signupData);
|
||||
if (!validation.success) {
|
||||
const message = validation.error.issues
|
||||
.map(issue => issue.message)
|
||||
.join(". ") || "Invalid signup data";
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { UsersService } from "@bff/modules/users/users.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { sanitizeUser } from "../../utils/sanitize-user.util";
|
||||
import type { User as SharedUser } from "@customer-portal/domain";
|
||||
import type { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsLinkWorkflowService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async linkWhmcsUser(email: string, password: string) {
|
||||
const existingUser = await this.usersService.findByEmailInternal(email);
|
||||
if (existingUser) {
|
||||
if (!existingUser.passwordHash) {
|
||||
this.logger.log(
|
||||
`User ${email} exists but has no password - allowing password setup to continue`
|
||||
);
|
||||
return {
|
||||
user: sanitizeUser(existingUser),
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new ConflictException(
|
||||
"User already exists in portal and has completed setup. Please use the login page."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let clientDetails: WhmcsClientResponse["client"];
|
||||
try {
|
||||
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
} catch (error) {
|
||||
this.logger.warn(`WHMCS client lookup failed for email ${email}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new UnauthorizedException("WHMCS client not found with this email address");
|
||||
}
|
||||
|
||||
try {
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("This billing account is already linked. Please sign in.");
|
||||
}
|
||||
} catch (mapErr) {
|
||||
if (mapErr instanceof ConflictException) throw mapErr;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug(`About to validate WHMCS password for ${email}`);
|
||||
const validateResult = await this.whmcsService.validateLogin(email, password);
|
||||
this.logger.debug("WHMCS validation successful", { email });
|
||||
if (!validateResult || !validateResult.userId) {
|
||||
throw new UnauthorizedException("Invalid WHMCS credentials");
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug("WHMCS validation failed", { email, error: getErrorMessage(error) });
|
||||
throw new UnauthorizedException("Invalid WHMCS password");
|
||||
}
|
||||
|
||||
const customerNumberField = clientDetails.customfields?.find(field => field.id === 198);
|
||||
const customerNumber = customerNumberField?.value?.trim();
|
||||
|
||||
if (!customerNumber) {
|
||||
throw new BadRequestException(
|
||||
`Customer Number not found in WHMCS custom field 198. Please contact support.`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`
|
||||
);
|
||||
|
||||
const sfAccount: { id: string } | null =
|
||||
await this.salesforceService.findAccountByCustomerNumber(customerNumber);
|
||||
if (!sfAccount) {
|
||||
throw new BadRequestException(
|
||||
`Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.`
|
||||
);
|
||||
}
|
||||
|
||||
const user: SharedUser = await this.usersService.create({
|
||||
email,
|
||||
passwordHash: null,
|
||||
firstName: clientDetails.firstname || "",
|
||||
lastName: clientDetails.lastname || "",
|
||||
company: clientDetails.companyname || "",
|
||||
phone: clientDetails.phonenumber || "",
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
await this.mappingsService.createMapping({
|
||||
userId: user.id,
|
||||
whmcsClientId: clientDetails.id,
|
||||
sfAccountId: sfAccount.id,
|
||||
});
|
||||
|
||||
return {
|
||||
user: sanitizeUser(user),
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("WHMCS linking error", { error: getErrorMessage(error) });
|
||||
if (error instanceof BadRequestException || error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
throw new BadRequestException("Failed to link WHMCS account");
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/bff/src/modules/auth/utils/sanitize-user.util.ts
Normal file
23
apps/bff/src/modules/auth/utils/sanitize-user.util.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export function sanitizeUser<
|
||||
T extends {
|
||||
id: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
passwordHash?: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
},
|
||||
>(user: T): Omit<T, "passwordHash" | "failedLoginAttempts" | "lockedUntil"> {
|
||||
const {
|
||||
passwordHash: _passwordHash,
|
||||
failedLoginAttempts: _failedLoginAttempts,
|
||||
lockedUntil: _lockedUntil,
|
||||
...rest
|
||||
} = user as T & {
|
||||
passwordHash?: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
};
|
||||
|
||||
return rest;
|
||||
}
|
||||
@ -118,16 +118,6 @@ export class InvoicesController {
|
||||
return this.invoicesService.getPaymentGateways();
|
||||
}
|
||||
|
||||
@Get("test-payment-methods/:clientId")
|
||||
@ApiOperation({
|
||||
summary: "Test WHMCS payment methods API for specific client ID",
|
||||
description: "Direct test of WHMCS GetPayMethods API - TEMPORARY DEBUG ENDPOINT",
|
||||
})
|
||||
@ApiParam({ name: "clientId", type: Number, description: "WHMCS Client ID to test" })
|
||||
async testPaymentMethods(@Param("clientId", ParseIntPipe) clientId: number): Promise<unknown> {
|
||||
return this.invoicesService.testWhmcsPaymentMethods(clientId);
|
||||
}
|
||||
|
||||
@Post("payment-methods/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
@ -43,10 +49,10 @@ export class InvoicesService {
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1) {
|
||||
throw new Error("Page must be greater than 0");
|
||||
throw new BadRequestException("Page must be greater than 0");
|
||||
}
|
||||
if (limit < 1 || limit > 100) {
|
||||
throw new Error("Limit must be between 1 and 100");
|
||||
throw new BadRequestException("Limit must be between 1 and 100");
|
||||
}
|
||||
|
||||
// Fetch invoices from WHMCS
|
||||
@ -74,7 +80,7 @@ export class InvoicesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to retrieve invoices: ${getErrorMessage(error)}`);
|
||||
throw new InternalServerErrorException("Failed to retrieve invoices");
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,7 +91,7 @@ export class InvoicesService {
|
||||
try {
|
||||
// Validate invoice ID
|
||||
if (!invoiceId || invoiceId < 1) {
|
||||
throw new Error("Invalid invoice ID");
|
||||
throw new BadRequestException("Invalid invoice ID");
|
||||
}
|
||||
|
||||
// Get WHMCS client ID from user mapping
|
||||
@ -118,7 +124,7 @@ export class InvoicesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to retrieve invoice: ${getErrorMessage(error)}`);
|
||||
throw new InternalServerErrorException("Failed to retrieve invoice");
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,7 +139,7 @@ export class InvoicesService {
|
||||
try {
|
||||
// Validate invoice ID
|
||||
if (!invoiceId || invoiceId < 1) {
|
||||
throw new Error("Invalid invoice ID");
|
||||
throw new BadRequestException("Invalid invoice ID");
|
||||
}
|
||||
|
||||
// Get WHMCS client ID from user mapping
|
||||
@ -192,7 +198,7 @@ export class InvoicesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to create SSO link: ${getErrorMessage(error)}`);
|
||||
throw new InternalServerErrorException("Failed to create SSO link");
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,7 +216,9 @@ export class InvoicesService {
|
||||
// Validate status
|
||||
const validStatuses = ["Paid", "Unpaid", "Cancelled", "Overdue", "Collections"] as const;
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`);
|
||||
throw new BadRequestException(
|
||||
`Invalid status. Must be one of: ${validStatuses.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.getInvoices(userId, { page, limit, status });
|
||||
@ -219,7 +227,11 @@ export class InvoicesService {
|
||||
error: getErrorMessage(error),
|
||||
options,
|
||||
});
|
||||
throw error;
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException("Failed to retrieve invoices");
|
||||
}
|
||||
}
|
||||
|
||||
@ -294,7 +306,10 @@ export class InvoicesService {
|
||||
this.logger.error(`Failed to generate invoice stats for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
throw new InternalServerErrorException("Failed to generate invoice statistics");
|
||||
}
|
||||
}
|
||||
|
||||
@ -357,7 +372,7 @@ export class InvoicesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to retrieve invoice subscriptions: ${getErrorMessage(error)}`);
|
||||
throw new InternalServerErrorException("Failed to retrieve invoice subscriptions");
|
||||
}
|
||||
}
|
||||
|
||||
@ -421,7 +436,7 @@ export class InvoicesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to retrieve payment methods: ${getErrorMessage(error)}`);
|
||||
throw new InternalServerErrorException("Failed to retrieve payment methods");
|
||||
}
|
||||
}
|
||||
|
||||
@ -444,7 +459,7 @@ export class InvoicesService {
|
||||
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error(`Failed to invalidate payment methods cache: ${getErrorMessage(error)}`);
|
||||
throw new InternalServerErrorException("Failed to invalidate payment methods cache");
|
||||
}
|
||||
}
|
||||
|
||||
@ -463,46 +478,7 @@ export class InvoicesService {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
throw new Error(`Failed to retrieve payment gateways: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TEMPORARY DEBUG METHOD: Test WHMCS payment methods API directly
|
||||
*/
|
||||
async testWhmcsPaymentMethods(clientId: number): Promise<unknown> {
|
||||
try {
|
||||
this.logger.log(`🔬 TESTING WHMCS GetPayMethods API for client ${clientId}`);
|
||||
|
||||
// Call WHMCS API directly with detailed logging
|
||||
const result = await this.whmcsService.getPaymentMethods(clientId, `test-client-${clientId}`);
|
||||
|
||||
this.logger.log(`🔬 Test result for client ${clientId}:`, {
|
||||
totalCount: result.totalCount,
|
||||
paymentMethods: result.paymentMethods,
|
||||
});
|
||||
|
||||
return {
|
||||
clientId,
|
||||
testTimestamp: new Date().toISOString(),
|
||||
whmcsResponse: result,
|
||||
summary: {
|
||||
totalPaymentMethods: result.totalCount,
|
||||
hasPaymentMethods: result.totalCount > 0,
|
||||
paymentMethodTypes: result.paymentMethods.map((pm: { type: string }) => pm.type),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`🔬 Test failed for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
return {
|
||||
clientId,
|
||||
testTimestamp: new Date().toISOString(),
|
||||
error: getErrorMessage(error),
|
||||
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
||||
};
|
||||
throw new InternalServerErrorException("Failed to retrieve payment gateways");
|
||||
}
|
||||
}
|
||||
|
||||
@ -518,7 +494,7 @@ export class InvoicesService {
|
||||
try {
|
||||
// Validate invoice ID
|
||||
if (!invoiceId || invoiceId < 1) {
|
||||
throw new Error("Invalid invoice ID");
|
||||
throw new BadRequestException("Invalid invoice ID");
|
||||
}
|
||||
|
||||
// Get WHMCS client ID from user mapping
|
||||
@ -566,7 +542,7 @@ export class InvoicesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to create payment SSO link: ${getErrorMessage(error)}`);
|
||||
throw new InternalServerErrorException("Failed to create payment SSO link");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: unknown = await request.json();
|
||||
|
||||
// Forward the request to the BFF
|
||||
const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:4000";
|
||||
const response = await fetch(`${bffUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Forward any relevant headers
|
||||
"User-Agent": request.headers.get("user-agent") || "",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// Return the response with the same status code
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error("Login API error:", error);
|
||||
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: unknown = await request.json();
|
||||
|
||||
// Forward the request to the BFF
|
||||
const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:4000";
|
||||
const response = await fetch(`${bffUrl}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Forward any relevant headers
|
||||
"User-Agent": request.headers.get("user-agent") || "",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// Return the response with the same status code
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error("Signup API error:", error);
|
||||
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: unknown = await request.json();
|
||||
|
||||
// Forward the request to the BFF
|
||||
const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:4000";
|
||||
const response = await fetch(`${bffUrl}/api/auth/validate-signup`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Forward any relevant headers
|
||||
"User-Agent": request.headers.get("user-agent") || "",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// Return the response with the same status code
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error("Validate signup API error:", error);
|
||||
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -19,24 +19,40 @@ export const queryKeys = {
|
||||
// Billing queries
|
||||
billing: {
|
||||
all: ['billing'] as const,
|
||||
invoices: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.billing.all, 'invoices', params] as const,
|
||||
invoices: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.billing.all, 'invoices', params ?? {}] as const,
|
||||
invoice: (id: string) => [...queryKeys.billing.all, 'invoice', id] as const,
|
||||
paymentMethods: () => [...queryKeys.billing.all, 'paymentMethods'] as const,
|
||||
gateways: () => [...queryKeys.billing.all, 'gateways'] as const,
|
||||
},
|
||||
|
||||
// Subscription queries
|
||||
subscriptions: {
|
||||
all: ['subscriptions'] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.subscriptions.all, 'list', params] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.subscriptions.all, 'list', params ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.subscriptions.all, 'detail', id] as const,
|
||||
invoices: (subscriptionId: number, params?: Record<string, unknown>) =>
|
||||
[...queryKeys.subscriptions.all, 'invoices', subscriptionId, params ?? {}] as const,
|
||||
stats: () => [...queryKeys.subscriptions.all, 'stats'] as const,
|
||||
},
|
||||
|
||||
// Catalog queries
|
||||
catalog: {
|
||||
all: ['catalog'] as const,
|
||||
products: (type: string, params?: Record<string, unknown>) =>
|
||||
[...queryKeys.catalog.all, 'products', type, params] as const,
|
||||
products: (type: string, params?: Record<string, unknown>) =>
|
||||
[...queryKeys.catalog.all, 'products', type, params ?? {}] as const,
|
||||
internet: {
|
||||
all: [...queryKeys.catalog.all, 'internet'] as const,
|
||||
combined: () => [...queryKeys.catalog.internet.all, 'combined'] as const,
|
||||
},
|
||||
sim: {
|
||||
all: [...queryKeys.catalog.all, 'sim'] as const,
|
||||
combined: () => [...queryKeys.catalog.sim.all, 'combined'] as const,
|
||||
},
|
||||
vpn: {
|
||||
all: [...queryKeys.catalog.all, 'vpn'] as const,
|
||||
combined: () => [...queryKeys.catalog.vpn.all, 'combined'] as const,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
// Allow absolute URL (http/https) or relative path (e.g., "/api") for same-origin proxying
|
||||
NEXT_PUBLIC_API_BASE: z
|
||||
.string()
|
||||
.default("/api")
|
||||
.refine(
|
||||
v => /^https?:\/\//.test(v) || v.startsWith("/"),
|
||||
"NEXT_PUBLIC_API_BASE must be an absolute URL or a leading-slash path"
|
||||
),
|
||||
NEXT_PUBLIC_API_BASE: z.string().url().default("http://localhost:4000/api"),
|
||||
NEXT_PUBLIC_APP_NAME: z.string().default("Assist Solutions Portal"),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().default("0.1.0"),
|
||||
});
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiClient, queryKeys } from "@/core/api";
|
||||
import { useAuthStore } from "../services/auth.store";
|
||||
import type {
|
||||
LoginRequest,
|
||||
SignupRequest,
|
||||
ForgotPasswordRequest,
|
||||
ResetPasswordRequest,
|
||||
ChangePasswordRequest,
|
||||
AuthUser
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
/**
|
||||
* Simplified Auth Hook - Just UI state + API calls
|
||||
* All business logic is handled by the BFF
|
||||
*/
|
||||
export function useAuth() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated, user, tokens, logout: storeLogout } = useAuthStore();
|
||||
|
||||
// Login mutation - BFF handles all validation and business logic
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: (credentials: LoginRequest) =>
|
||||
apiClient.POST('/auth/login', { body: credentials }),
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Store tokens and user (BFF returns formatted data)
|
||||
useAuthStore.getState().setUser(response.data.user);
|
||||
useAuthStore.getState().setTokens(response.data.tokens);
|
||||
|
||||
// Navigate to dashboard
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Signup mutation
|
||||
const signupMutation = useMutation({
|
||||
mutationFn: (data: SignupRequest) =>
|
||||
apiClient.POST('/auth/signup', { body: data }),
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
useAuthStore.getState().setUser(response.data.user);
|
||||
useAuthStore.getState().setTokens(response.data.tokens);
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Password reset request
|
||||
const passwordResetMutation = useMutation({
|
||||
mutationFn: (data: ForgotPasswordRequest) =>
|
||||
apiClient.POST('/auth/request-password-reset', { body: data }),
|
||||
});
|
||||
|
||||
// Password reset
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: (data: ResetPasswordRequest) =>
|
||||
apiClient.POST('/auth/reset-password', { body: data }),
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
useAuthStore.getState().setUser(response.data.user);
|
||||
useAuthStore.getState().setTokens(response.data.tokens);
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Change password
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (data: ChangePasswordRequest) =>
|
||||
apiClient.POST('/auth/change-password', { body: data }),
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
useAuthStore.getState().setTokens(response.data.tokens);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
const profileQuery = useQuery({
|
||||
queryKey: queryKeys.auth.profile(),
|
||||
queryFn: () => apiClient.GET('/auth/me'),
|
||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
||||
});
|
||||
|
||||
// Logout function
|
||||
const logout = async () => {
|
||||
try {
|
||||
// Call BFF logout endpoint
|
||||
await apiClient.POST('/auth/logout');
|
||||
} catch (error) {
|
||||
console.warn('Logout API call failed:', error);
|
||||
} finally {
|
||||
// Clear local state regardless
|
||||
storeLogout();
|
||||
queryClient.clear();
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
isAuthenticated,
|
||||
user: profileQuery.data?.data || user,
|
||||
loading: loginMutation.isPending || signupMutation.isPending || profileQuery.isLoading,
|
||||
|
||||
// Actions (just trigger API calls, BFF handles business logic)
|
||||
login: loginMutation.mutate,
|
||||
signup: signupMutation.mutate,
|
||||
requestPasswordReset: passwordResetMutation.mutate,
|
||||
resetPassword: resetPasswordMutation.mutate,
|
||||
changePassword: changePasswordMutation.mutate,
|
||||
logout,
|
||||
|
||||
// Mutation states for UI feedback
|
||||
loginError: loginMutation.error,
|
||||
signupError: signupMutation.error,
|
||||
passwordResetError: passwordResetMutation.error,
|
||||
resetPasswordError: resetPasswordMutation.error,
|
||||
changePasswordError: changePasswordMutation.error,
|
||||
};
|
||||
}
|
||||
@ -1,72 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
type UseMutationOptions,
|
||||
type UseMutationResult,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiClient, queryKeys } from "@/core/api";
|
||||
import type {
|
||||
Invoice,
|
||||
PaymentMethod,
|
||||
InvoiceQueryParams
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
InvoiceQueryParams,
|
||||
InvoiceSsoLink,
|
||||
InvoicePaymentLink,
|
||||
PaymentGatewayList,
|
||||
PaymentMethodList,
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
/**
|
||||
* Simplified Billing Hook - Just API calls, BFF handles all business logic
|
||||
*/
|
||||
export function useBilling() {
|
||||
const queryClient = useQueryClient();
|
||||
const emptyInvoiceList: InvoiceList = {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
// Get invoices (BFF returns formatted, ready-to-display data)
|
||||
useInvoices: (params?: InvoiceQueryParams) =>
|
||||
useQuery({
|
||||
queryKey: queryKeys.billing.invoices(params),
|
||||
queryFn: () => apiClient.GET('/invoices', { params: { query: params } }),
|
||||
}),
|
||||
const emptyPaymentMethods: PaymentMethodList = {
|
||||
paymentMethods: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
// Get single invoice
|
||||
useInvoice: (id: string) =>
|
||||
useQuery({
|
||||
queryKey: queryKeys.billing.invoice(id),
|
||||
queryFn: () => apiClient.GET('/invoices/{id}', { params: { path: { id } } }),
|
||||
enabled: !!id,
|
||||
}),
|
||||
type InvoicesQueryKey = ReturnType<typeof queryKeys.billing.invoices>;
|
||||
type InvoiceQueryKey = ReturnType<typeof queryKeys.billing.invoice>;
|
||||
type PaymentMethodsQueryKey = ReturnType<typeof queryKeys.billing.paymentMethods>;
|
||||
|
||||
// Get payment methods
|
||||
usePaymentMethods: () =>
|
||||
useQuery({
|
||||
queryKey: queryKeys.billing.paymentMethods(),
|
||||
queryFn: () => apiClient.GET('/billing/payment-methods'),
|
||||
}),
|
||||
type InvoicesQueryOptions = Omit<
|
||||
UseQueryOptions<InvoiceList, Error, InvoiceList, InvoicesQueryKey>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
|
||||
// Create payment link (BFF handles WHMCS integration)
|
||||
useCreatePaymentLink: () =>
|
||||
useMutation({
|
||||
mutationFn: (invoiceId: string) =>
|
||||
apiClient.POST('/invoices/{id}/payment-link', {
|
||||
params: { path: { id: invoiceId } }
|
||||
}),
|
||||
}),
|
||||
type InvoiceQueryOptions = Omit<
|
||||
UseQueryOptions<Invoice, Error, Invoice, InvoiceQueryKey>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
|
||||
// Add payment method
|
||||
useAddPaymentMethod: () =>
|
||||
useMutation({
|
||||
mutationFn: (paymentData: any) =>
|
||||
apiClient.POST('/billing/payment-methods', { body: paymentData }),
|
||||
onSuccess: () => {
|
||||
// Invalidate payment methods cache
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.billing.paymentMethods() });
|
||||
type PaymentMethodsQueryOptions = Omit<
|
||||
UseQueryOptions<PaymentMethodList, Error, PaymentMethodList, PaymentMethodsQueryKey>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
|
||||
type PaymentLinkMutationOptions = UseMutationOptions<
|
||||
InvoicePaymentLink,
|
||||
Error,
|
||||
{
|
||||
invoiceId: number;
|
||||
paymentMethodId?: number;
|
||||
gatewayName?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type SsoLinkMutationOptions = UseMutationOptions<
|
||||
InvoiceSsoLink,
|
||||
Error,
|
||||
{ invoiceId: number; target?: "view" | "download" | "pay" }
|
||||
>;
|
||||
|
||||
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
|
||||
const response = await apiClient.GET("/invoices", params ? { params: { query: params } } : undefined);
|
||||
return response.data ?? emptyInvoiceList;
|
||||
}
|
||||
|
||||
async function fetchInvoice(id: string): Promise<Invoice> {
|
||||
const response = await apiClient.GET("/invoices/{id}", { params: { path: { id } } });
|
||||
if (!response.data) {
|
||||
throw new Error("Invoice not found");
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
|
||||
const response = await apiClient.GET("/invoices/payment-methods");
|
||||
return response.data ?? emptyPaymentMethods;
|
||||
}
|
||||
|
||||
async function fetchPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
const response = await apiClient.GET("/invoices/payment-gateways");
|
||||
return response.data ?? { gateways: [], totalCount: 0 };
|
||||
}
|
||||
|
||||
export function useInvoices(
|
||||
params?: InvoiceQueryParams,
|
||||
options?: InvoicesQueryOptions
|
||||
): UseQueryResult<InvoiceList, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.invoices(params ?? {}),
|
||||
queryFn: () => fetchInvoices(params),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoice(
|
||||
id: string,
|
||||
options?: InvoiceQueryOptions
|
||||
): UseQueryResult<Invoice, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.invoice(id),
|
||||
queryFn: () => fetchInvoice(id),
|
||||
enabled: Boolean(id),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePaymentMethods(
|
||||
options?: PaymentMethodsQueryOptions
|
||||
): UseQueryResult<PaymentMethodList, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.paymentMethods(),
|
||||
queryFn: fetchPaymentMethods,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePaymentGateways(
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
PaymentGatewayList,
|
||||
Error,
|
||||
PaymentGatewayList,
|
||||
ReturnType<typeof queryKeys.billing.gateways>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
): UseQueryResult<PaymentGatewayList, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.gateways(),
|
||||
queryFn: fetchPaymentGateways,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInvoicePaymentLink(
|
||||
options?: PaymentLinkMutationOptions
|
||||
): UseMutationResult<
|
||||
InvoicePaymentLink,
|
||||
Error,
|
||||
{
|
||||
invoiceId: number;
|
||||
paymentMethodId?: number;
|
||||
gatewayName?: string;
|
||||
}
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ invoiceId, paymentMethodId, gatewayName }) => {
|
||||
const response = await apiClient.POST("/invoices/{id}/payment-link", {
|
||||
params: {
|
||||
path: { id: invoiceId },
|
||||
query: {
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create payment link");
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete payment method
|
||||
useDeletePaymentMethod: () =>
|
||||
useMutation({
|
||||
mutationFn: (methodId: string) =>
|
||||
apiClient.DELETE('/billing/payment-methods/{id}', {
|
||||
params: { path: { id: methodId } }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.billing.paymentMethods() });
|
||||
export function useCreateInvoiceSsoLink(
|
||||
options?: SsoLinkMutationOptions
|
||||
): UseMutationResult<
|
||||
InvoiceSsoLink,
|
||||
Error,
|
||||
{ invoiceId: number; target?: "view" | "download" | "pay" }
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ invoiceId, target }) => {
|
||||
const response = await apiClient.POST("/invoices/{id}/sso-link", {
|
||||
params: {
|
||||
path: { id: invoiceId },
|
||||
query: target ? { target } : undefined,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create SSO link");
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ import { apiClient } from "@/core/api";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import { PaymentMethodCard, usePaymentMethods } from "@/features/billing";
|
||||
import { useRefreshPaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||
import { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { InlineToast } from "@/components/ui/inline-toast";
|
||||
import { SectionHeader } from "@/components/common";
|
||||
@ -26,20 +25,22 @@ export function PaymentMethodsContainer() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isAuthenticated } = useSession();
|
||||
|
||||
const paymentMethodsQuery = usePaymentMethods();
|
||||
const {
|
||||
data: paymentMethodsData,
|
||||
isLoading: isLoadingPaymentMethods,
|
||||
isFetching: isFetchingPaymentMethods,
|
||||
error: paymentMethodsError,
|
||||
} = usePaymentMethods();
|
||||
} = paymentMethodsQuery;
|
||||
|
||||
// Auth hydration flag to avoid showing empty state before auth is checked
|
||||
const { hasCheckedAuth } = useAuthStore();
|
||||
|
||||
const refreshPaymentMethods = useRefreshPaymentMethods();
|
||||
|
||||
const paymentRefresh = usePaymentRefresh({
|
||||
refetch: () => Promise.resolve({ data: paymentMethodsData }),
|
||||
refetch: async () => {
|
||||
const result = await paymentMethodsQuery.refetch();
|
||||
return { data: result.data };
|
||||
},
|
||||
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
|
||||
attachFocusListeners: true,
|
||||
});
|
||||
@ -207,4 +208,3 @@ export function PaymentMethodsContainer() {
|
||||
|
||||
export default PaymentMethodsContainer;
|
||||
|
||||
|
||||
|
||||
@ -3,84 +3,9 @@
|
||||
* React hooks for catalog functionality
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/core/api";
|
||||
import { catalogService } from "../services";
|
||||
import type {
|
||||
CatalogFilter,
|
||||
InternetPlan,
|
||||
SimPlan,
|
||||
VpnPlan,
|
||||
CatalogOrderItem,
|
||||
OrderTotals
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
// Type aliases for convenience
|
||||
type CatalogProduct = InternetPlan | SimPlan | VpnPlan;
|
||||
type ProductConfiguration = Record<string, unknown>;
|
||||
type OrderSummary = {
|
||||
items: CatalogOrderItem[];
|
||||
totals: OrderTotals;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch all products with optional filtering
|
||||
*/
|
||||
export function useProducts(filters?: CatalogFilter) {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", "products", filters],
|
||||
queryFn: () => catalogService.getProducts(filters),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a specific product
|
||||
*/
|
||||
export function useProduct(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", "product", id],
|
||||
queryFn: () => catalogService.getProduct(id),
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch products by category
|
||||
*/
|
||||
export function useProductsByCategory(category: "internet" | "sim" | "vpn") {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", "products", "category", category],
|
||||
queryFn: () => catalogService.getProductsByCategory(category),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate order summary
|
||||
*/
|
||||
export function useCalculateOrder() {
|
||||
return useMutation({
|
||||
mutationFn: (configuration: ProductConfiguration) =>
|
||||
catalogService.calculateOrderSummary(configuration),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to submit an order
|
||||
*/
|
||||
export function useSubmitOrder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (orderSummary: OrderSummary) => catalogService.submitOrder(orderSummary),
|
||||
onSuccess: () => {
|
||||
// Invalidate relevant queries after successful order
|
||||
void queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internet catalog composite hook
|
||||
@ -88,7 +13,7 @@ export function useSubmitOrder() {
|
||||
*/
|
||||
export function useInternetCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", "internet", "all"],
|
||||
queryKey: queryKeys.catalog.internet.combined(),
|
||||
queryFn: async () => {
|
||||
const [plans, installations, addons] = await Promise.all([
|
||||
catalogService.getInternetPlans(),
|
||||
@ -107,7 +32,7 @@ export function useInternetCatalog() {
|
||||
*/
|
||||
export function useSimCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", "sim", "all"],
|
||||
queryKey: queryKeys.catalog.sim.combined(),
|
||||
queryFn: async () => {
|
||||
const [plans, activationFees, addons] = await Promise.all([
|
||||
catalogService.getSimPlans(),
|
||||
@ -126,7 +51,7 @@ export function useSimCatalog() {
|
||||
*/
|
||||
export function useVpnCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", "vpn", "all"],
|
||||
queryKey: queryKeys.catalog.vpn.combined(),
|
||||
queryFn: async () => {
|
||||
const [plans, activationFees] = await Promise.all([
|
||||
catalogService.getVpnPlans(),
|
||||
|
||||
53
apps/portal/src/features/catalog/services/catalog.service.ts
Normal file
53
apps/portal/src/features/catalog/services/catalog.service.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { apiClient } from "@/core/api";
|
||||
import type { InternetProduct, SimProduct, VpnProduct } from "@customer-portal/domain";
|
||||
|
||||
async function getInternetPlans(): Promise<InternetProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/internet/plans");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async function getInternetInstallations(): Promise<InternetProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/internet/installations");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async function getInternetAddons(): Promise<InternetProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/internet/addons");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async function getSimPlans(): Promise<SimProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/sim/plans");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async function getSimActivationFees(): Promise<SimProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/sim/activation-fees");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async function getSimAddons(): Promise<SimProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/sim/addons");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async function getVpnPlans(): Promise<VpnProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/vpn/plans");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async function getVpnActivationFees(): Promise<VpnProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/vpn/activation-fees");
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
export const catalogService = {
|
||||
getInternetPlans,
|
||||
getInternetInstallations,
|
||||
getInternetAddons,
|
||||
getSimPlans,
|
||||
getSimActivationFees,
|
||||
getSimAddons,
|
||||
getVpnPlans,
|
||||
getVpnActivationFees,
|
||||
} as const;
|
||||
1
apps/portal/src/features/catalog/services/index.ts
Normal file
1
apps/portal/src/features/catalog/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { catalogService } from "./catalog.service";
|
||||
36
apps/portal/src/features/orders/services/orders.service.ts
Normal file
36
apps/portal/src/features/orders/services/orders.service.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { apiClient } from "@/core/api";
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
orderType: "Internet" | "SIM" | "VPN" | "Other";
|
||||
skus: string[];
|
||||
configurations?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function createOrder<T = { sfOrderId: string }>(payload: CreateOrderRequest): Promise<T> {
|
||||
const response = await apiClient.POST("/orders", { body: payload });
|
||||
if (!response.data) {
|
||||
throw new Error("Order creation failed");
|
||||
}
|
||||
return response.data as T;
|
||||
}
|
||||
|
||||
async function getMyOrders<T = unknown[]>(): Promise<T> {
|
||||
const response = await apiClient.GET("/orders/user");
|
||||
return (response.data ?? []) as T;
|
||||
}
|
||||
|
||||
async function getOrderById<T = unknown>(orderId: string): Promise<T> {
|
||||
const response = await apiClient.GET("/orders/{sfOrderId}", {
|
||||
params: { path: { sfOrderId: orderId } },
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
return response.data as T;
|
||||
}
|
||||
|
||||
export const ordersService = {
|
||||
createOrder,
|
||||
getMyOrders,
|
||||
getOrderById,
|
||||
} as const;
|
||||
@ -3,15 +3,52 @@
|
||||
* React hooks for subscription functionality using shared types
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient, queryKeys } from "@/core/api";
|
||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||
import { apiClient } from "@/core/api";
|
||||
import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain";
|
||||
import type { InvoiceList, Subscription, SubscriptionList } from "@customer-portal/domain";
|
||||
|
||||
interface UseSubscriptionsOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const emptySubscriptionList: SubscriptionList = {
|
||||
subscriptions: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
const emptyStats = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
suspended: 0,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
};
|
||||
|
||||
const emptyInvoiceList: InvoiceList = {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function toSubscriptionList(
|
||||
payload?: SubscriptionList | Subscription[] | null
|
||||
): SubscriptionList {
|
||||
if (!payload) {
|
||||
return emptySubscriptionList;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
return {
|
||||
subscriptions: payload,
|
||||
totalCount: payload.length,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all subscriptions
|
||||
*/
|
||||
@ -19,28 +56,17 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
||||
const { status } = options;
|
||||
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||
|
||||
return useQuery<SubscriptionList | Subscription[]>({
|
||||
queryKey: ["subscriptions", status],
|
||||
return useQuery<SubscriptionList>({
|
||||
queryKey: queryKeys.subscriptions.list(status ? { status } : {}),
|
||||
queryFn: async () => {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
...(status && { status }),
|
||||
});
|
||||
|
||||
|
||||
const response = await apiClient.GET(
|
||||
"/subscriptions",
|
||||
status ? { params: { query: { status } } } : undefined
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
return [] as Subscription[];
|
||||
}
|
||||
|
||||
return response.data as SubscriptionList | Subscription[];
|
||||
return toSubscriptionList(response.data);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
enabled: isAuthenticated && hasValidToken,
|
||||
});
|
||||
}
|
||||
@ -52,18 +78,13 @@ export function useActiveSubscriptions() {
|
||||
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||
|
||||
return useQuery<Subscription[]>({
|
||||
queryKey: ["subscriptions", "active"],
|
||||
queryKey: [...queryKeys.subscriptions.all, "active"] as const,
|
||||
queryFn: async () => {
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/active");
|
||||
return (response.data ?? []) as Subscription[];
|
||||
return response.data ?? [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
enabled: isAuthenticated && hasValidToken,
|
||||
});
|
||||
}
|
||||
@ -74,41 +95,14 @@ export function useActiveSubscriptions() {
|
||||
export function useSubscriptionStats() {
|
||||
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||
|
||||
return useQuery<{
|
||||
total: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
cancelled: number;
|
||||
pending: number;
|
||||
}>({
|
||||
queryKey: ["subscriptions", "stats"],
|
||||
return useQuery({
|
||||
queryKey: queryKeys.subscriptions.stats(),
|
||||
queryFn: async () => {
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/stats");
|
||||
return (response.data ?? {
|
||||
total: 0,
|
||||
active: 0,
|
||||
suspended: 0,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
}) as {
|
||||
|
||||
|
||||
total: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
cancelled: number;
|
||||
pending: number;
|
||||
|
||||
};
|
||||
|
||||
return response.data ?? emptyStats;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
enabled: isAuthenticated && hasValidToken,
|
||||
});
|
||||
}
|
||||
@ -120,27 +114,19 @@ export function useSubscription(subscriptionId: number) {
|
||||
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||
|
||||
return useQuery<Subscription>({
|
||||
queryKey: ["subscription", subscriptionId],
|
||||
queryKey: queryKeys.subscriptions.detail(String(subscriptionId)),
|
||||
queryFn: async () => {
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/{id}", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Subscription not found");
|
||||
}
|
||||
|
||||
return response.data as Subscription;
|
||||
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
enabled: isAuthenticated && hasValidToken,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
enabled: isAuthenticated && hasValidToken && subscriptionId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
@ -155,34 +141,28 @@ export function useSubscriptionInvoices(
|
||||
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||
|
||||
return useQuery<InvoiceList>({
|
||||
queryKey: ["subscription-invoices", subscriptionId, page, limit],
|
||||
queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }),
|
||||
queryFn: async () => {
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/{id}/invoices", {
|
||||
params: {
|
||||
path: { id: subscriptionId },
|
||||
query: { page, limit },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
response.data ?? {
|
||||
invoices: [],
|
||||
if (!response.data) {
|
||||
return {
|
||||
...emptyInvoiceList,
|
||||
pagination: {
|
||||
...emptyInvoiceList.pagination,
|
||||
page,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
},
|
||||
}
|
||||
) as InvoiceList;
|
||||
|
||||
};
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: isAuthenticated && hasValidToken && !!subscriptionId,
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
enabled: isAuthenticated && hasValidToken && subscriptionId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
@ -198,12 +178,9 @@ export function useSubscriptionAction() {
|
||||
params: { path: { id } },
|
||||
body: { action },
|
||||
});
|
||||
|
||||
},
|
||||
onSuccess: (_, { id }) => {
|
||||
// Invalidate relevant queries after successful action
|
||||
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["subscription", id] });
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user