diff --git a/apps/bff/src/infra/audit/audit.service.ts b/apps/bff/src/infra/audit/audit.service.ts index 375a1dde..554f6265 100644 --- a/apps/bff/src/infra/audit/audit.service.ts +++ b/apps/bff/src/infra/audit/audit.service.ts @@ -37,10 +37,6 @@ export class AuditService { @Inject(Logger) private readonly logger: Logger ) {} - get prismaClient() { - return this.prisma; - } - async log(data: AuditLogData): Promise { 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, + }; + } } - diff --git a/apps/bff/src/modules/auth/auth-admin.controller.ts b/apps/bff/src/modules/auth/auth-admin.controller.ts index a9c4fa98..6b901522 100644 --- a/apps/bff/src/modules/auth/auth-admin.controller.ts +++ b/apps/bff/src/modules/auth/auth-admin.controller.ts @@ -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(); } } diff --git a/apps/bff/src/modules/auth/auth-zod.controller.ts b/apps/bff/src/modules/auth/auth-zod.controller.ts index 37f9b792..f5e521d1 100644 --- a/apps/bff/src/modules/auth/auth-zod.controller.ts +++ b/apps/bff/src/modules/auth/auth-zod.controller.ts @@ -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") diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 5a818ca9..d1cf0208 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -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, diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index 45129d95..44e7da98 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -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("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( - "WHMCS_CUSTOMER_NUMBER_FIELD_ID", - "198" - ); - const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); - const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); - const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); - - const customfields: Record = {}; - 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("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 { - 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 { - 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("APP_BASE_URL", "http://localhost:3000"); - const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`; - const templateId = this.configService.get("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: ` -

We received a request to reset your password.

-

Click here to reset your password. This link expires in 15 minutes.

-

If you didn't request this, you can safely ignore this email.

- `, - }); - } + 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("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("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." - ); - } - } } diff --git a/apps/bff/src/modules/auth/dto/account-status.dto.ts b/apps/bff/src/modules/auth/dto/account-status.dto.ts deleted file mode 100644 index 427fbb55..00000000 --- a/apps/bff/src/modules/auth/dto/account-status.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/change-password.dto.ts b/apps/bff/src/modules/auth/dto/change-password.dto.ts deleted file mode 100644 index fc76ab51..00000000 --- a/apps/bff/src/modules/auth/dto/change-password.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/link-whmcs.dto.ts b/apps/bff/src/modules/auth/dto/link-whmcs.dto.ts deleted file mode 100644 index 2461c0f9..00000000 --- a/apps/bff/src/modules/auth/dto/link-whmcs.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/login.dto.ts b/apps/bff/src/modules/auth/dto/login.dto.ts deleted file mode 100644 index a6b2563c..00000000 --- a/apps/bff/src/modules/auth/dto/login.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/request-password-reset.dto.ts b/apps/bff/src/modules/auth/dto/request-password-reset.dto.ts deleted file mode 100644 index c83a316b..00000000 --- a/apps/bff/src/modules/auth/dto/request-password-reset.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/reset-password.dto.ts b/apps/bff/src/modules/auth/dto/reset-password.dto.ts deleted file mode 100644 index 97a7b74b..00000000 --- a/apps/bff/src/modules/auth/dto/reset-password.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/set-password.dto.ts b/apps/bff/src/modules/auth/dto/set-password.dto.ts deleted file mode 100644 index 1347622e..00000000 --- a/apps/bff/src/modules/auth/dto/set-password.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/signup.dto.ts b/apps/bff/src/modules/auth/dto/signup.dto.ts deleted file mode 100644 index 77ae8a83..00000000 --- a/apps/bff/src/modules/auth/dto/signup.dto.ts +++ /dev/null @@ -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"; -} diff --git a/apps/bff/src/modules/auth/dto/sso-link.dto.ts b/apps/bff/src/modules/auth/dto/sso-link.dto.ts deleted file mode 100644 index b678d207..00000000 --- a/apps/bff/src/modules/auth/dto/sso-link.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/dto/validate-signup.dto.ts b/apps/bff/src/modules/auth/dto/validate-signup.dto.ts deleted file mode 100644 index 35954f23..00000000 --- a/apps/bff/src/modules/auth/dto/validate-signup.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/bff/src/modules/auth/services/token.service.ts b/apps/bff/src/modules/auth/services/token.service.ts new file mode 100644 index 00000000..39d49bec --- /dev/null +++ b/apps/bff/src/modules/auth/services/token.service.ts @@ -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("JWT_EXPIRES_IN", "7d"); + const accessToken = this.jwtService.sign(payload, { expiresIn }); + + return { + accessToken, + expiresAt: calculateExpiryDate(expiresIn), + tokenType: "Bearer", + }; + } +} diff --git a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts new file mode 100644 index 00000000..f991a5b3 --- /dev/null +++ b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts @@ -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; + 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 { + 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("APP_BASE_URL", "http://localhost:3000"); + const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`; + const templateId = this.configService.get("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: ` +

We received a request to reset your password.

+

Click here to reset your password. This link expires in 15 minutes.

+

If you didn't request this, you can safely ignore this email.

+ `, + }); + } + } + + async resetPassword(token: string, newPassword: string): Promise { + 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("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 { + 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("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, + }; + } +} diff --git a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts new file mode 100644 index 00000000..231e84b6 --- /dev/null +++ b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts @@ -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 { + 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("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( + "WHMCS_CUSTOMER_NUMBER_FIELD_ID", + "198" + ); + const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); + const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); + const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); + + const customfields: Record = {}; + 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); + } + } +} diff --git a/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts new file mode 100644 index 00000000..215c2c64 --- /dev/null +++ b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts @@ -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"); + } + } +} diff --git a/apps/bff/src/modules/auth/utils/sanitize-user.util.ts b/apps/bff/src/modules/auth/utils/sanitize-user.util.ts new file mode 100644 index 00000000..2bf2640c --- /dev/null +++ b/apps/bff/src/modules/auth/utils/sanitize-user.util.ts @@ -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 { + const { + passwordHash: _passwordHash, + failedLoginAttempts: _failedLoginAttempts, + lockedUntil: _lockedUntil, + ...rest + } = user as T & { + passwordHash?: string | null; + failedLoginAttempts?: number | null; + lockedUntil?: Date | null; + }; + + return rest; +} diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index 41e298f0..c49f0363 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -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 { - return this.invoicesService.testWhmcsPaymentMethods(clientId); - } - @Post("payment-methods/refresh") @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/apps/bff/src/modules/invoices/invoices.service.ts b/apps/bff/src/modules/invoices/invoices.service.ts index 3e41c2d8..ee65afb7 100644 --- a/apps/bff/src/modules/invoices/invoices.service.ts +++ b/apps/bff/src/modules/invoices/invoices.service.ts @@ -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 { - 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"); } } diff --git a/apps/portal/src/app/api/auth/login/route.ts b/apps/portal/src/app/api/auth/login/route.ts deleted file mode 100644 index 932e27ac..00000000 --- a/apps/portal/src/app/api/auth/login/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/apps/portal/src/app/api/auth/signup/route.ts b/apps/portal/src/app/api/auth/signup/route.ts deleted file mode 100644 index 6ec16f1e..00000000 --- a/apps/portal/src/app/api/auth/signup/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/apps/portal/src/app/api/auth/validate-signup/route.ts b/apps/portal/src/app/api/auth/validate-signup/route.ts deleted file mode 100644 index 6a2652f4..00000000 --- a/apps/portal/src/app/api/auth/validate-signup/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/apps/portal/src/core/api/query-keys.ts b/apps/portal/src/core/api/query-keys.ts index 53c80e9a..738be29d 100644 --- a/apps/portal/src/core/api/query-keys.ts +++ b/apps/portal/src/core/api/query-keys.ts @@ -19,24 +19,40 @@ export const queryKeys = { // Billing queries billing: { all: ['billing'] as const, - invoices: (params?: Record) => - [...queryKeys.billing.all, 'invoices', params] as const, + invoices: (params?: Record) => + [...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) => - [...queryKeys.subscriptions.all, 'list', params] as const, + list: (params?: Record) => + [...queryKeys.subscriptions.all, 'list', params ?? {}] as const, detail: (id: string) => [...queryKeys.subscriptions.all, 'detail', id] as const, + invoices: (subscriptionId: number, params?: Record) => + [...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) => - [...queryKeys.catalog.all, 'products', type, params] as const, + products: (type: string, params?: Record) => + [...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; diff --git a/apps/portal/src/core/config/env.ts b/apps/portal/src/core/config/env.ts index b017c823..5fc4855a 100644 --- a/apps/portal/src/core/config/env.ts +++ b/apps/portal/src/core/config/env.ts @@ -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"), }); diff --git a/apps/portal/src/features/auth/hooks/useAuth.ts b/apps/portal/src/features/auth/hooks/useAuth.ts deleted file mode 100644 index db65e683..00000000 --- a/apps/portal/src/features/auth/hooks/useAuth.ts +++ /dev/null @@ -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, - }; -} diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index 8e896cd8..bbc14840 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -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; +type InvoiceQueryKey = ReturnType; +type PaymentMethodsQueryKey = ReturnType; - // Get payment methods - usePaymentMethods: () => - useQuery({ - queryKey: queryKeys.billing.paymentMethods(), - queryFn: () => apiClient.GET('/billing/payment-methods'), - }), +type InvoicesQueryOptions = Omit< + UseQueryOptions, + "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, + "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, + "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 { + const response = await apiClient.GET("/invoices", params ? { params: { query: params } } : undefined); + return response.data ?? emptyInvoiceList; +} + +async function fetchInvoice(id: string): Promise { + 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 { + const response = await apiClient.GET("/invoices/payment-methods"); + return response.data ?? emptyPaymentMethods; +} + +async function fetchPaymentGateways(): Promise { + const response = await apiClient.GET("/invoices/payment-gateways"); + return response.data ?? { gateways: [], totalCount: 0 }; +} + +export function useInvoices( + params?: InvoiceQueryParams, + options?: InvoicesQueryOptions +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.billing.invoices(params ?? {}), + queryFn: () => fetchInvoices(params), + ...options, + }); +} + +export function useInvoice( + id: string, + options?: InvoiceQueryOptions +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.billing.invoice(id), + queryFn: () => fetchInvoice(id), + enabled: Boolean(id), + ...options, + }); +} + +export function usePaymentMethods( + options?: PaymentMethodsQueryOptions +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.billing.paymentMethods(), + queryFn: fetchPaymentMethods, + ...options, + }); +} + +export function usePaymentGateways( + options?: Omit< + UseQueryOptions< + PaymentGatewayList, + Error, + PaymentGatewayList, + ReturnType + >, + "queryKey" | "queryFn" + > +): UseQueryResult { + 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, }, - }), - }; -} \ No newline at end of file + }); + if (!response.data) { + throw new Error("Failed to create SSO link"); + } + return response.data; + }, + ...options, + }); +} diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index 5368a717..8d14908b 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -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(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; - diff --git a/apps/portal/src/features/catalog/hooks/useCatalog.ts b/apps/portal/src/features/catalog/hooks/useCatalog.ts index 1cf4a2de..e428860a 100644 --- a/apps/portal/src/features/catalog/hooks/useCatalog.ts +++ b/apps/portal/src/features/catalog/hooks/useCatalog.ts @@ -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; -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(), diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts new file mode 100644 index 00000000..863d3ac7 --- /dev/null +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -0,0 +1,53 @@ +import { apiClient } from "@/core/api"; +import type { InternetProduct, SimProduct, VpnProduct } from "@customer-portal/domain"; + +async function getInternetPlans(): Promise { + const response = await apiClient.GET("/catalog/internet/plans"); + return response.data ?? []; +} + +async function getInternetInstallations(): Promise { + const response = await apiClient.GET("/catalog/internet/installations"); + return response.data ?? []; +} + +async function getInternetAddons(): Promise { + const response = await apiClient.GET("/catalog/internet/addons"); + return response.data ?? []; +} + +async function getSimPlans(): Promise { + const response = await apiClient.GET("/catalog/sim/plans"); + return response.data ?? []; +} + +async function getSimActivationFees(): Promise { + const response = await apiClient.GET("/catalog/sim/activation-fees"); + return response.data ?? []; +} + +async function getSimAddons(): Promise { + const response = await apiClient.GET("/catalog/sim/addons"); + return response.data ?? []; +} + +async function getVpnPlans(): Promise { + const response = await apiClient.GET("/catalog/vpn/plans"); + return response.data ?? []; +} + +async function getVpnActivationFees(): Promise { + 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; diff --git a/apps/portal/src/features/catalog/services/index.ts b/apps/portal/src/features/catalog/services/index.ts new file mode 100644 index 00000000..14dc64fb --- /dev/null +++ b/apps/portal/src/features/catalog/services/index.ts @@ -0,0 +1 @@ +export { catalogService } from "./catalog.service"; diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts new file mode 100644 index 00000000..961e9d07 --- /dev/null +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -0,0 +1,36 @@ +import { apiClient } from "@/core/api"; + +export interface CreateOrderRequest { + orderType: "Internet" | "SIM" | "VPN" | "Other"; + skus: string[]; + configurations?: Record; +} + +async function createOrder(payload: CreateOrderRequest): Promise { + 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(): Promise { + const response = await apiClient.GET("/orders/user"); + return (response.data ?? []) as T; +} + +async function getOrderById(orderId: string): Promise { + 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; diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 807d920b..55e0045f 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -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({ - queryKey: ["subscriptions", status], + return useQuery({ + 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({ - 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({ - 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({ - 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 }); }, }); }