diff --git a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts index 1a9f4302..183d0f93 100644 --- a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts +++ b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts @@ -122,26 +122,33 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { const intervalCap = this.configService.get("WHMCS_QUEUE_INTERVAL_CAP", 300); const timeout = this.configService.get("WHMCS_QUEUE_TIMEOUT_MS", 30000); - this.logger.log("WHMCS Request Queue initialized", { - concurrency, - rateLimit: `${intervalCap} requests/minute (${(intervalCap / 60).toFixed(1)} RPS)`, - timeout: `${timeout / 1000} seconds`, - }); + this.logger.log( + { + concurrency, + rateLimit: `${intervalCap} requests/minute (${(intervalCap / 60).toFixed(1)} RPS)`, + timeout: `${timeout / 1000} seconds`, + }, + "WHMCS Request Queue initialized" + ); } async onModuleDestroy() { - this.logger.log("Shutting down WHMCS Request Queue", { - pendingRequests: this.queue?.pending ?? 0, - queueSize: this.queue?.size ?? 0, - }); + this.logger.log( + { + pendingRequests: this.queue?.pending ?? 0, + queueSize: this.queue?.size ?? 0, + }, + "Shutting down WHMCS Request Queue" + ); // Wait for pending requests to complete (with timeout) try { await this.queue?.onIdle(); } catch (error) { - this.logger.warn("Some WHMCS requests may not have completed during shutdown", { - error: error instanceof Error ? error.message : String(error), - }); + this.logger.warn( + { error: error instanceof Error ? error.message : String(error) }, + "Some WHMCS requests may not have completed during shutdown" + ); } } @@ -156,12 +163,15 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { this.metrics.totalRequests++; this.updateQueueMetrics(); - this.logger.debug("Queueing WHMCS request", { - requestId, - queueSize: queue.size, - pending: queue.pending, - priority: options.priority || 0, - }); + this.logger.debug( + { + requestId, + queueSize: queue.size, + pending: queue.pending, + priority: options.priority || 0, + }, + "Queueing WHMCS request" + ); try { const result = (await queue.add( @@ -179,12 +189,15 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { this.metrics.completedRequests++; this.metrics.lastRequestTime = new Date(); - this.logger.debug("WHMCS request completed", { - requestId, - waitTime, - executionTime, - totalTime: Date.now() - startTime, - }); + this.logger.debug( + { + requestId, + waitTime, + executionTime, + totalTime: Date.now() - startTime, + }, + "WHMCS request completed" + ); return response; } catch (error) { @@ -193,7 +206,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { this.metrics.failedRequests++; this.metrics.lastErrorTime = new Date(); - this.logger.warn( + // Log at debug — the business layer (discovery, workflow) decides final severity. + // Queue metrics (waitTime, executionTime) are useful for performance debugging + // but shouldn't pollute warn logs for expected failures like "Client Not Found". + this.logger.debug( { requestId, waitTime, @@ -279,10 +295,13 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { return; } - this.logger.warn("Clearing WHMCS request queue", { - queueSize: queue.size, - pendingRequests: queue.pending, - }); + this.logger.warn( + { + queueSize: queue.size, + pendingRequests: queue.pending, + }, + "Clearing WHMCS request queue" + ); queue.clear(); await queue.onIdle(); diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts index c12384ea..db79df78 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts @@ -66,12 +66,12 @@ export class WhmcsErrorHandlerService { const message = extractErrorMessage(error); if (this.isTimeoutError(error)) { - this.logger.warn("WHMCS request timeout", { error: message }); + this.logger.warn({ error: message }, "WHMCS request timeout"); throw new WhmcsTimeoutError(message, error); } if (this.isNetworkError(error)) { - this.logger.warn("WHMCS network error", { error: message }); + this.logger.warn({ error: message }, "WHMCS network error"); throw new WhmcsNetworkError(message, error); } @@ -82,11 +82,16 @@ export class WhmcsErrorHandlerService { // Check for HTTP status errors (e.g., "HTTP 500: Internal Server Error") const httpStatusError = this.parseHttpStatusError(message); if (httpStatusError) { - this.logger.error("WHMCS HTTP status error", { - upstreamStatus: httpStatusError.status, - upstreamStatusText: httpStatusError.statusText, - originalError: message, - }); + // WARN not ERROR — these are external system issues (config, permissions, availability), + // not bugs in our code. 401/403 during discovery is expected for some WHMCS setups. + this.logger.warn( + { + upstreamStatus: httpStatusError.status, + upstreamStatusText: httpStatusError.statusText, + originalError: message, + }, + "WHMCS HTTP status error" + ); // Map upstream HTTP status to appropriate domain error const mapped = this.mapHttpStatusToDomainError(httpStatusError.status); @@ -108,11 +113,14 @@ export class WhmcsErrorHandlerService { } // Log unhandled errors for debugging - this.logger.error("WHMCS unhandled request error", { - error: message, - errorType: error instanceof Error ? error.constructor.name : typeof error, - stack: error instanceof Error ? error.stack : undefined, - }); + this.logger.error( + { + error: message, + errorType: error instanceof Error ? error.constructor.name : typeof error, + stack: error instanceof Error ? error.stack : undefined, + }, + "WHMCS unhandled request error" + ); // Wrap unknown errors with context throw new WhmcsApiError( diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 53051feb..21199ffa 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -49,12 +49,19 @@ export class WhmcsHttpClientService { this.stats.failedRequests++; this.stats.lastErrorTime = new Date(); - this.logger.warn(`WHMCS HTTP request failed [${action}]`, { - error: extractErrorMessage(error), - action, - params: redactForLogs(params), - responseTime: Date.now() - startTime, - }); + // Log at debug — the error handler and business layer decide the final severity. + // Logging at warn/error here duplicates upstream logs for expected outcomes + // (e.g. "Client Not Found" during discovery, HTTP 403 on GetUsers). + this.logger.debug( + { + error: extractErrorMessage(error), + action, + params: redactForLogs(params), + responseTime: Date.now() - startTime, + }, + "WHMCS HTTP request failed [%s]", + action + ); throw error; } @@ -132,12 +139,16 @@ export class WhmcsHttpClientService { if (process.env["NODE_ENV"] !== "production") { const snippet = responseText?.slice(0, 300); if (snippet) { - this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, { - action, - status: response.status, - statusText: response.statusText, - snippet, - }); + this.logger.debug( + { + action, + status: response.status, + statusText: response.statusText, + snippet, + }, + "WHMCS non-OK response body snippet [%s]", + action + ); } } @@ -236,26 +247,34 @@ export class WhmcsHttpClientService { parsedResponse = JSON.parse(responseText); } catch (parseError) { const isProd = process.env["NODE_ENV"] === "production"; - this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, { - ...(isProd - ? { responseTextLength: responseText.length } - : { responseText: responseText.slice(0, 500) }), - parseError: extractErrorMessage(parseError), - params: redactForLogs(params), - }); + this.logger.error( + { + ...(isProd + ? { responseTextLength: responseText.length } + : { responseText: responseText.slice(0, 500) }), + parseError: extractErrorMessage(parseError), + params: redactForLogs(params), + }, + "Invalid JSON response from WHMCS API [%s]", + action + ); throw new WhmcsOperationException("Invalid JSON response from WHMCS API"); } // Validate basic response structure if (!this.isWhmcsResponse(parsedResponse)) { const isProd = process.env["NODE_ENV"] === "production"; - this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { - responseType: typeof parsedResponse, - ...(isProd - ? { responseTextLength: responseText.length } - : { responseText: responseText.slice(0, 500) }), - params: redactForLogs(params), - }); + this.logger.error( + { + responseType: typeof parsedResponse, + ...(isProd + ? { responseTextLength: responseText.length } + : { responseText: responseText.slice(0, 500) }), + params: redactForLogs(params), + }, + "WHMCS API returned invalid response structure [%s]", + action + ); throw new WhmcsOperationException("Invalid response structure from WHMCS API"); } @@ -267,16 +286,17 @@ export class WhmcsHttpClientService { const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error"); const errorCode = this.toDisplayString(errorcode, "unknown"); - // Many WHMCS "result=error" responses are expected business outcomes (e.g. invalid credentials). - // Log as warning (not error) to avoid spamming error logs; the unified exception filter will - // still emit the request-level log based on the mapped error code. - this.logger.warn( + // Many WHMCS "result=error" responses are expected business outcomes (e.g. "Client Not Found" + // during discovery, invalid credentials during login). Log at debug — the error handler + // classifies severity and the business layer logs the final outcome. + this.logger.debug( { errorMessage, errorCode, params: redactForLogs(params), }, - `WHMCS API returned error [${action}]` + "WHMCS API returned error [%s]", + action ); // Return error response for the orchestrator to handle with proper exception types diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts index 9f8c1025..10d5ea58 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts @@ -31,7 +31,7 @@ export class WhmcsAccountDiscoveryService { // 1. Try to find client ID by email from cache const cachedClientId = await this.cacheService.getClientIdByEmail(email); if (cachedClientId) { - this.logger.debug(`Cache hit for email-to-id: ${email} -> ${cachedClientId}`); + this.logger.debug({ email, cachedClientId }, "Cache hit for email-to-id lookup"); // If we have ID, fetch the full client data (which has its own cache) return this.getClientDetailsById(cachedClientId); } @@ -54,7 +54,7 @@ export class WhmcsAccountDiscoveryService { this.cacheService.setClientIdByEmail(email, client.id), ]); - this.logger.log(`Discovered client by email: ${email}`); + this.logger.log({ email, clientId: client.id }, "Discovered client by email"); return client; } catch (error) { // Handle "Not Found" specifically — this is expected for discovery @@ -108,22 +108,26 @@ export class WhmcsAccountDiscoveryService { // Get the first associated client (users can belong to multiple clients) const clientAssociation = exactMatch.clients?.[0]; if (!clientAssociation) { - this.logger.warn(`User ${exactMatch.id} found but has no associated clients`); + this.logger.warn( + { userId: exactMatch.id, email }, + "User found but has no associated clients" + ); return null; } this.logger.log( - `Discovered user by email: ${email} (user: ${exactMatch.id}, client: ${clientAssociation.id})` + { email, userId: exactMatch.id, clientId: clientAssociation.id }, + "Discovered user by email" ); return { userId: Number(exactMatch.id), clientId: Number(clientAssociation.id), }; } catch (error) { - // Sub-account lookup is best-effort — many WHMCS setups don't expose GetUsers. - // Log and return null rather than blocking the flow. The primary client lookup - // (findClientByEmail) is the authority; this is supplementary. - this.logger.warn( + // Sub-account lookup is best-effort — many WHMCS setups don't expose GetUsers + // (commonly returns HTTP 403). Log at debug to avoid polluting warn logs on every + // signup. The primary client lookup (findClientByEmail) is the authority. + this.logger.debug( { email, error: extractErrorMessage(error) }, "User sub-account lookup unavailable — skipping" ); diff --git a/apps/bff/src/modules/auth/README.md b/apps/bff/src/modules/auth/README.md index 2f9a70f2..383a25f3 100644 --- a/apps/bff/src/modules/auth/README.md +++ b/apps/bff/src/modules/auth/README.md @@ -7,7 +7,7 @@ Authentication and authorization for the Customer Portal. ``` auth/ ├── application/ # Orchestration layer -│ ├── auth.facade.ts # Main entry point for auth operations +│ ├── auth-orchestrator.service.ts # Main entry point for auth operations │ ├── auth-login.service.ts │ └── auth-health.service.ts ├── decorators/ @@ -72,7 +72,7 @@ LocalAuthGuard (validates credentials) FailedLoginThrottleGuard (rate limiting) │ ▼ -AuthFacade.login() +AuthOrchestrator.login() │ ├─► Generate token pair ├─► Set httpOnly cookies diff --git a/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts b/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts index f751b945..58d33ca5 100644 --- a/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts +++ b/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts @@ -8,30 +8,22 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { Logger } from "nestjs-pino"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util.js"; -import { - type SetPasswordRequest, - type ChangePasswordRequest, - type SsoLinkResponse, -} from "@customer-portal/domain/auth"; +import { type SsoLinkResponse } from "@customer-portal/domain/auth"; import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js"; import { AuthTokenService } from "../infra/token/token.service.js"; -import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service.js"; -import { PasswordWorkflowService } from "../infra/workflows/password-workflow.service.js"; -// mapPrismaUserToDomain removed - usersService.update now returns profile directly -import { AuthHealthService } from "./auth-health.service.js"; import { AuthLoginService } from "./auth-login.service.js"; /** * Auth Orchestrator * - * Application layer orchestrator that coordinates authentication operations. - * Delegates to specialized services for specific functionality: - * - AuthHealthService: Health checks - * - AuthLoginService: Login validation - * - PasswordWorkflowService: Password operations + * Application layer orchestrator that coordinates authentication operations + * requiring multiple services: login completion, logout, SSO, account status. + * + * Password and health operations are handled by their own services, + * injected directly by the controller. */ @Injectable() export class AuthOrchestrator { @@ -43,53 +35,25 @@ export class AuthOrchestrator { private readonly salesforceService: SalesforceFacade, private readonly auditService: AuditService, private readonly tokenBlacklistService: TokenBlacklistService, - private readonly passwordWorkflow: PasswordWorkflowService, private readonly tokenService: AuthTokenService, - private readonly authRateLimitService: AuthRateLimitService, - private readonly healthService: AuthHealthService, private readonly loginService: AuthLoginService, @Inject(Logger) private readonly logger: Logger ) {} - async healthCheck() { - return this.healthService.check(); - } - - /** - * Original login method - validates credentials and completes login - * Used by LocalAuthGuard flow - */ - async login( - user: { - id: string; - email: string; - role?: string; - passwordHash?: string | null; - failedLoginAttempts?: number | null; - lockedUntil?: Date | null; - }, + async validateUser( + email: string, + password: string, request?: Request - ) { - if (request) { - await this.authRateLimitService.clearLoginAttempts(request); - } - - return this.completeLogin( - { id: user.id, email: user.email, role: user.role ?? "USER" }, - request - ); + ): Promise<{ id: string; email: string; role: string } | null> { + return this.loginService.validateUser(email, password, request); } /** * Complete login after credential validation (and OTP verification if required) * Generates tokens and updates user state - * - * @param user - Validated user info (id, email, role) - * @param request - Express request for audit logging */ async completeLogin(user: { id: string; email: string; role: string }, request?: Request) { // Update last login time and reset failed attempts - // usersService.update returns the updated profile, avoiding a second DB fetch const profile = await this.usersService.update(user.id, { lastLoginAt: new Date(), failedLoginAttempts: 0, @@ -125,22 +89,6 @@ export class AuthOrchestrator { }; } - async checkPasswordNeeded(email: string) { - return this.passwordWorkflow.checkPasswordNeeded(email); - } - - async setPassword(setPasswordData: SetPasswordRequest) { - return this.passwordWorkflow.setPassword(setPasswordData.email, setPasswordData.password); - } - - async validateUser( - email: string, - password: string, - request?: Request - ): Promise<{ id: string; email: string; role: string } | null> { - return this.loginService.validateUser(email, password, request); - } - async logout(userId?: string, token?: string, request?: Request): Promise { if (token) { await this.tokenBlacklistService.blacklistToken(token); @@ -162,61 +110,6 @@ export class AuthOrchestrator { await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true); } - /** - * Create SSO link to WHMCS for general access - */ - async createSsoLink(userId: string, destination?: string): Promise { - try { - this.logger.log("Creating SSO link request"); - - const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - const ssoDestination = "sso:custom_redirect"; - const ssoRedirectPath = sanitizeWhmcsRedirectPath(destination); - - const result = await this.whmcsSsoService.createSsoToken( - whmcsClientId, - ssoDestination, - ssoRedirectPath - ); - - this.logger.log("SSO link created successfully"); - - return result; - } catch (error) { - this.logger.error("SSO link creation failed", { - errorType: error instanceof Error ? error.constructor.name : "Unknown", - message: extractErrorMessage(error), - }); - throw error; - } - } - - private async updateAccountLastSignIn(userId: string): Promise { - try { - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.sfAccountId) { - return; - } - - await this.salesforceService.updateAccountPortalFields(mapping.sfAccountId, { - lastSignedInAt: new Date(), - }); - } catch (error) { - this.logger.debug("Failed to update Salesforce last sign-in", { - userId, - error: extractErrorMessage(error), - }); - } - } - - async requestPasswordReset(email: string, request?: Request): Promise { - await this.passwordWorkflow.requestPasswordReset(email, request); - } - - async resetPassword(token: string, newPassword: string) { - return this.passwordWorkflow.resetPassword(token, newPassword); - } - async getAccountStatus(email: string) { const normalized = email?.toLowerCase().trim(); if (!normalized || !normalized.includes("@")) { @@ -274,8 +167,33 @@ export class AuthOrchestrator { }; } - async changePassword(userId: string, data: ChangePasswordRequest, request?: Request) { - return this.passwordWorkflow.changePassword(userId, data, request); + /** + * Create SSO link to WHMCS for general access + */ + async createSsoLink(userId: string, destination?: string): Promise { + try { + this.logger.log("Creating SSO link request"); + + const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); + const ssoDestination = "sso:custom_redirect"; + const ssoRedirectPath = sanitizeWhmcsRedirectPath(destination); + + const result = await this.whmcsSsoService.createSsoToken( + whmcsClientId, + ssoDestination, + ssoRedirectPath + ); + + this.logger.log("SSO link created successfully"); + + return result; + } catch (error) { + this.logger.error("SSO link creation failed", { + errorType: error instanceof Error ? error.constructor.name : "Unknown", + message: extractErrorMessage(error), + }); + throw error; + } } async refreshTokens( @@ -292,4 +210,22 @@ export class AuthOrchestrator { tokens, }; } + + private async updateAccountLastSignIn(userId: string): Promise { + try { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.sfAccountId) { + return; + } + + await this.salesforceService.updateAccountPortalFields(mapping.sfAccountId, { + lastSignedInAt: new Date(), + }); + } catch (error) { + this.logger.debug("Failed to update Salesforce last sign-in", { + userId, + error: extractErrorMessage(error), + }); + } + } } diff --git a/apps/bff/src/modules/auth/get-started/get-started.module.ts b/apps/bff/src/modules/auth/get-started/get-started.module.ts index 5efb7aa9..2e2593c6 100644 --- a/apps/bff/src/modules/auth/get-started/get-started.module.ts +++ b/apps/bff/src/modules/auth/get-started/get-started.module.ts @@ -6,8 +6,6 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; import { AddressModule } from "@bff/modules/address/address.module.js"; import { TokensModule } from "../tokens/tokens.module.js"; import { OtpModule } from "../otp/otp.module.js"; -// Coordinator -import { GetStartedCoordinator } from "../infra/workflows/get-started-coordinator.service.js"; // Workflow services import { VerificationWorkflowService } from "../infra/workflows/verification-workflow.service.js"; import { GuestEligibilityWorkflowService } from "../infra/workflows/guest-eligibility-workflow.service.js"; @@ -21,8 +19,6 @@ import { UpdateSalesforceFlagsStep, GenerateAuthResultStep, CreateEligibilityCaseStep, - PortalUserCreationService, - WhmcsCleanupService, } from "../infra/workflows/steps/index.js"; // Controller import { GetStartedController } from "../presentation/http/get-started.controller.js"; @@ -39,8 +35,6 @@ import { GetStartedController } from "../presentation/http/get-started.controlle ], controllers: [GetStartedController], providers: [ - // Coordinator - GetStartedCoordinator, // Workflow services VerificationWorkflowService, GuestEligibilityWorkflowService, @@ -53,9 +47,6 @@ import { GetStartedController } from "../presentation/http/get-started.controlle UpdateSalesforceFlagsStep, GenerateAuthResultStep, CreateEligibilityCaseStep, - PortalUserCreationService, - WhmcsCleanupService, ], - exports: [GetStartedCoordinator], }) export class GetStartedModule {} diff --git a/apps/bff/src/modules/auth/infra/token/index.ts b/apps/bff/src/modules/auth/infra/token/index.ts index 9dac27a2..cf1bcfd5 100644 --- a/apps/bff/src/modules/auth/infra/token/index.ts +++ b/apps/bff/src/modules/auth/infra/token/index.ts @@ -4,6 +4,7 @@ * Provides JWT token management, storage, and revocation capabilities. */ +export type { RefreshTokenPayload, DeviceInfo } from "./token.types.js"; export { AuthTokenService } from "./token.service.js"; export { TokenGeneratorService } from "./token-generator.service.js"; export { TokenRefreshService } from "./token-refresh.service.js"; diff --git a/apps/bff/src/modules/auth/infra/token/token-generator.service.ts b/apps/bff/src/modules/auth/infra/token/token-generator.service.ts index d3432cbb..075f9c61 100644 --- a/apps/bff/src/modules/auth/infra/token/token-generator.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-generator.service.ts @@ -7,26 +7,12 @@ import { import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; -import type { JWTPayload } from "jose"; import type { AuthTokens } from "@customer-portal/domain/auth"; import type { UserRole } from "@customer-portal/domain/customer"; +import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js"; import { JoseJwtService } from "./jose-jwt.service.js"; import { TokenStorageService } from "./token-storage.service.js"; -interface RefreshTokenPayload extends JWTPayload { - userId: string; - familyId?: string | undefined; - tokenId: string; - deviceId?: string | undefined; - userAgent?: string | undefined; - type: "refresh"; -} - -interface DeviceInfo { - deviceId?: string | undefined; - userAgent?: string | undefined; -} - const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable"; /** diff --git a/apps/bff/src/modules/auth/infra/token/token-refresh.service.ts b/apps/bff/src/modules/auth/infra/token/token-refresh.service.ts index 58696fcd..dd4066bf 100644 --- a/apps/bff/src/modules/auth/infra/token/token-refresh.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-refresh.service.ts @@ -7,30 +7,16 @@ import { import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; -import type { JWTPayload } from "jose"; import type { AuthTokens } from "@customer-portal/domain/auth"; import type { UserAuth } from "@customer-portal/domain/customer"; import { UsersService } from "@bff/modules/users/application/users.service.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; +import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js"; import { TokenGeneratorService } from "./token-generator.service.js"; import { TokenStorageService } from "./token-storage.service.js"; import { TokenRevocationService } from "./token-revocation.service.js"; import { JoseJwtService } from "./jose-jwt.service.js"; -interface RefreshTokenPayload extends JWTPayload { - userId: string; - familyId?: string | undefined; - tokenId: string; - deviceId?: string | undefined; - userAgent?: string | undefined; - type: "refresh"; -} - -interface DeviceInfo { - deviceId?: string | undefined; - userAgent?: string | undefined; -} - const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token"; const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable"; diff --git a/apps/bff/src/modules/auth/infra/token/token-storage.service.ts b/apps/bff/src/modules/auth/infra/token/token-storage.service.ts index 9f1c75b6..1ed0ce6c 100644 --- a/apps/bff/src/modules/auth/infra/token/token-storage.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-storage.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; +import type { DeviceInfo } from "./token.types.js"; export interface StoredRefreshToken { familyId: string; @@ -17,11 +18,6 @@ export interface StoredRefreshTokenFamily { absoluteExpiresAt?: string | undefined; } -export interface DeviceInfo { - deviceId?: string | undefined; - userAgent?: string | undefined; -} - export interface StoreRefreshTokenParams { userId: string; familyId: string; diff --git a/apps/bff/src/modules/auth/infra/token/token.service.ts b/apps/bff/src/modules/auth/infra/token/token.service.ts index 978372e4..8201a265 100644 --- a/apps/bff/src/modules/auth/infra/token/token.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token.service.ts @@ -2,35 +2,13 @@ import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common" import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; -import type { JWTPayload } from "jose"; import type { AuthTokens } from "@customer-portal/domain/auth"; import type { UserAuth, UserRole } from "@customer-portal/domain/customer"; +import type { DeviceInfo } from "./token.types.js"; import { TokenGeneratorService } from "./token-generator.service.js"; import { TokenRefreshService } from "./token-refresh.service.js"; import { TokenRevocationService } from "./token-revocation.service.js"; -export interface RefreshTokenPayload extends JWTPayload { - userId: string; - /** - * Refresh token family identifier (stable across rotations). - * Present on newly issued tokens; legacy tokens used `tokenId` for this value. - */ - familyId?: string | undefined; - /** - * Refresh token identifier (unique per token). Used for replay/reuse detection. - * For legacy tokens, this was equal to the family id. - */ - tokenId: string; - deviceId?: string | undefined; - userAgent?: string | undefined; - type: "refresh"; -} - -export interface DeviceInfo { - deviceId?: string | undefined; - userAgent?: string | undefined; -} - const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable"; /** diff --git a/apps/bff/src/modules/auth/infra/token/token.types.ts b/apps/bff/src/modules/auth/infra/token/token.types.ts new file mode 100644 index 00000000..e1ae187a --- /dev/null +++ b/apps/bff/src/modules/auth/infra/token/token.types.ts @@ -0,0 +1,23 @@ +import type { JWTPayload } from "jose"; + +export interface RefreshTokenPayload extends JWTPayload { + userId: string; + /** + * Refresh token family identifier (stable across rotations). + * Present on newly issued tokens; legacy tokens used `tokenId` for this value. + */ + familyId?: string | undefined; + /** + * Refresh token identifier (unique per token). Used for replay/reuse detection. + * For legacy tokens, this was equal to the family id. + */ + tokenId: string; + deviceId?: string | undefined; + userAgent?: string | undefined; + type: "refresh"; +} + +export interface DeviceInfo { + deviceId?: string | undefined; + userAgent?: string | undefined; +} diff --git a/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts index e06c6b1a..8a3cab7d 100644 --- a/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts @@ -355,21 +355,11 @@ export class AccountCreationWorkflowService { throw portalError; } - // Step 5: Update SF Flags (DEGRADABLE) - await safeOperation( - async () => - this.sfFlagsStep.execute({ - sfAccountId: sfResult.sfAccountId, - whmcsClientId: whmcsResult.whmcsClientId, - }), - { - criticality: OperationCriticality.OPTIONAL, - fallback: undefined, - context: "SF flags update", - logger: this.logger, - metadata: { email }, - } - ); + // Step 5: Update SF Flags (DEGRADABLE — step handles its own errors) + await this.sfFlagsStep.execute({ + sfAccountId: sfResult.sfAccountId, + whmcsClientId: whmcsResult.whmcsClientId, + }); // Step 6: Generate Auth Result const auditSource = withEligibility diff --git a/apps/bff/src/modules/auth/infra/workflows/account-migration-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/account-migration-workflow.service.ts index 4b398eac..aa2b092a 100644 --- a/apps/bff/src/modules/auth/infra/workflows/account-migration-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/account-migration-workflow.service.ts @@ -151,19 +151,12 @@ export class AccountMigrationWorkflowService { sfAccountId: sfAccount.id, }); - // Update Salesforce portal flags (DEGRADABLE) - try { - await this.sfFlagsStep.execute({ - sfAccountId: sfAccount.id, - whmcsClientId, - source: PORTAL_SOURCE_MIGRATED, - }); - } catch (flagsError) { - this.logger.warn( - { error: extractErrorMessage(flagsError), email }, - "SF flags update failed (non-critical, continuing)" - ); - } + // Update Salesforce portal flags (DEGRADABLE — step handles its own errors) + await this.sfFlagsStep.execute({ + sfAccountId: sfAccount.id, + whmcsClientId, + source: PORTAL_SOURCE_MIGRATED, + }); // Generate auth result const authResult = await this.authResultStep.execute({ diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts deleted file mode 100644 index e065a179..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Injectable } from "@nestjs/common"; - -import type { - SendVerificationCodeRequest, - SendVerificationCodeResponse, - VerifyCodeRequest, - VerifyCodeResponse, - GuestEligibilityRequest, - GuestEligibilityResponse, - CompleteAccountRequest, - SignupWithEligibilityRequest, - MigrateWhmcsAccountRequest, -} from "@customer-portal/domain/get-started"; - -import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; - -import { VerificationWorkflowService } from "./verification-workflow.service.js"; -import { GuestEligibilityWorkflowService } from "./guest-eligibility-workflow.service.js"; -import { AccountCreationWorkflowService } from "./account-creation-workflow.service.js"; -import { AccountMigrationWorkflowService } from "./account-migration-workflow.service.js"; - -/** - * Get Started Coordinator - * - * Thin routing layer that delegates to focused workflow services. - * Method signatures match the previous god class so the controller - * requires minimal changes. - */ -@Injectable() -export class GetStartedCoordinator { - constructor( - private readonly verification: VerificationWorkflowService, - private readonly guestEligibility: GuestEligibilityWorkflowService, - private readonly accountCreation: AccountCreationWorkflowService, - private readonly accountMigration: AccountMigrationWorkflowService - ) {} - - async sendVerificationCode( - request: SendVerificationCodeRequest, - fingerprint?: string - ): Promise { - return this.verification.sendCode(request, fingerprint); - } - - async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise { - return this.verification.verifyCode(request, fingerprint); - } - - async guestEligibilityCheck( - request: GuestEligibilityRequest, - fingerprint?: string - ): Promise { - return this.guestEligibility.execute(request, fingerprint); - } - - async completeAccount(request: CompleteAccountRequest): Promise { - return this.accountCreation.execute(request) as Promise; - } - - async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{ - success: boolean; - message?: string; - eligibilityRequestId?: string; - authResult?: AuthResultInternal; - }> { - return this.accountCreation.execute(request, { withEligibility: true }) as Promise<{ - success: boolean; - message?: string; - eligibilityRequestId?: string; - authResult?: AuthResultInternal; - }>; - } - - async migrateWhmcsAccount(request: MigrateWhmcsAccountRequest): Promise { - return this.accountMigration.execute(request); - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts index 5be84afe..2f8f6494 100644 --- a/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts @@ -1,7 +1,5 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { PortalUserCreationService } from "./portal-user-creation.service.js"; - import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -19,30 +17,69 @@ export interface CreatePortalUserResult { /** * Step: Create a portal user with ID mapping. * - * Delegates to PortalUserCreationService for the Prisma transaction - * that creates both the User row and the IdMapping row atomically. + * Creates both the User row and the IdMapping row atomically + * in a single Prisma transaction. * * Rollback deletes the user and associated ID mapping. */ @Injectable() export class CreatePortalUserStep { constructor( - private readonly portalUserCreation: PortalUserCreationService, private readonly prisma: PrismaService, @Inject(Logger) private readonly logger: Logger ) {} async execute(params: CreatePortalUserParams): Promise { - const { userId } = await this.portalUserCreation.createUserWithMapping({ - email: params.email, - passwordHash: params.passwordHash, - whmcsClientId: params.whmcsClientId, - sfAccountId: params.sfAccountId, - }); + try { + const result = await this.prisma.$transaction(async tx => { + const created = await tx.user.create({ + data: { + email: params.email, + passwordHash: params.passwordHash, + emailVerified: true, + failedLoginAttempts: 0, + lockedUntil: null, + lastLoginAt: null, + }, + select: { id: true, email: true }, + }); - this.logger.log({ userId, email: params.email }, "Portal user created with ID mapping"); + await tx.idMapping.create({ + data: { + userId: created.id, + whmcsClientId: params.whmcsClientId, + sfAccountId: params.sfAccountId, + }, + }); - return { userId }; + return { userId: created.id }; + }); + + this.logger.log( + { + userId: result.userId, + email: params.email, + whmcsClientId: params.whmcsClientId, + sfAccountId: params.sfAccountId, + }, + "Portal user created with ID mapping" + ); + + return result; + } catch (dbError) { + this.logger.error( + { + whmcsClientId: params.whmcsClientId, + email: params.email, + error: extractErrorMessage(dbError), + }, + "Database transaction failed" + ); + + throw new BadRequestException( + `Failed to create user account: ${extractErrorMessage(dbError)}` + ); + } } async rollback(userId: string): Promise { diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts index 272b05a4..d2b11158 100644 --- a/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts @@ -1,6 +1,9 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; -import { WhmcsCleanupService, type WhmcsCreatedClient } from "./whmcs-cleanup.service.js"; +import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { serializeWhmcsKeyValueMap } from "@customer-portal/domain/customer/providers"; export interface CreateWhmcsClientParams { email: string; @@ -30,37 +33,79 @@ export interface CreateWhmcsClientResult { /** * Step: Create a WHMCS billing client. * - * Delegates to WhmcsCleanupService for the actual API call. + * Calls WhmcsClientService directly with custom-field mapping. * Rollback marks the created client as inactive for manual cleanup. */ @Injectable() export class CreateWhmcsClientStep { constructor( - private readonly signupWhmcs: WhmcsCleanupService, + private readonly whmcsClientService: WhmcsClientService, + private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} async execute(params: CreateWhmcsClientParams): Promise { - const result: WhmcsCreatedClient = await this.signupWhmcs.createClient({ - firstName: params.firstName, - lastName: params.lastName, - email: params.email, - password: params.password, - phone: params.phone, - address: params.address, - customerNumber: params.customerNumber, - ...(params.company != null && { company: params.company }), - ...(params.dateOfBirth != null && { dateOfBirth: params.dateOfBirth }), - ...(params.gender != null && { gender: params.gender }), - ...(params.nationality != null && { nationality: params.nationality }), - }); + const customerNumberFieldId = this.configService.get("WHMCS_CUSTOMER_NUMBER_FIELD_ID"); + 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 customfieldsMap: Record = {}; + if (customerNumberFieldId && params.customerNumber) { + customfieldsMap[customerNumberFieldId] = params.customerNumber; + } + if (dobFieldId && params.dateOfBirth) customfieldsMap[dobFieldId] = params.dateOfBirth; + if (genderFieldId && params.gender) customfieldsMap[genderFieldId] = params.gender; + if (nationalityFieldId && params.nationality) + customfieldsMap[nationalityFieldId] = params.nationality; this.logger.log( - { email: params.email, whmcsClientId: result.clientId }, - "WHMCS client created" + { + email: params.email, + firstName: params.firstName, + lastName: params.lastName, + sfNumber: params.customerNumber, + }, + "Creating WHMCS client" ); - return { whmcsClientId: result.clientId }; + try { + const whmcsClient = await this.whmcsClientService.addClient({ + firstname: params.firstName, + lastname: params.lastName, + email: params.email, + companyname: params.company || "", + phonenumber: params.phone, + address1: params.address.address1, + address2: params.address.address2 ?? "", + city: params.address.city, + state: params.address.state, + postcode: params.address.postcode, + country: params.address.country, + password2: params.password, + customfields: serializeWhmcsKeyValueMap(customfieldsMap) || undefined, + }); + + this.logger.log( + { whmcsClientId: whmcsClient.clientId, email: params.email }, + "WHMCS client created successfully" + ); + + return { whmcsClientId: whmcsClient.clientId }; + } catch (whmcsError) { + this.logger.error( + { + error: extractErrorMessage(whmcsError), + email: params.email, + firstName: params.firstName, + lastName: params.lastName, + }, + "Failed to create WHMCS client" + ); + throw new BadRequestException( + `Failed to create billing account: ${extractErrorMessage(whmcsError)}` + ); + } } async rollback(whmcsClientId: number, email?: string): Promise { @@ -68,6 +113,26 @@ export class CreateWhmcsClientStep { { whmcsClientId, email }, "Rolling back WHMCS client creation — marking for cleanup" ); - await this.signupWhmcs.markClientForCleanup(whmcsClientId, email ?? "unknown"); + + try { + await this.whmcsClientService.updateClient(whmcsClientId, { + status: "Inactive", + }); + + this.logger.warn( + { whmcsClientId, email, action: "marked_for_cleanup" }, + "Marked orphaned WHMCS client for manual cleanup" + ); + } catch (cleanupError) { + this.logger.error( + { + whmcsClientId, + email, + cleanupError: extractErrorMessage(cleanupError), + recommendation: "Manual cleanup required in WHMCS admin", + }, + "Failed to mark orphaned WHMCS client for cleanup" + ); + } } } diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/index.ts b/apps/bff/src/modules/auth/infra/workflows/steps/index.ts index 41eb43f7..b24a1394 100644 --- a/apps/bff/src/modules/auth/infra/workflows/steps/index.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/index.ts @@ -25,7 +25,3 @@ export type { CreateEligibilityCaseAddress, CreateEligibilityCaseResult, } from "./create-eligibility-case.step.js"; - -export { PortalUserCreationService } from "./portal-user-creation.service.js"; -export type { UserCreationParams, CreatedUserResult } from "./portal-user-creation.service.js"; -export { WhmcsCleanupService } from "./whmcs-cleanup.service.js"; diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/portal-user-creation.service.ts b/apps/bff/src/modules/auth/infra/workflows/steps/portal-user-creation.service.ts deleted file mode 100644 index 359c3536..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/steps/portal-user-creation.service.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Service responsible for creating portal users and ID mappings during signup - */ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { PrismaService } from "@bff/infra/database/prisma.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { WhmcsCleanupService } from "./whmcs-cleanup.service.js"; - -export interface UserCreationParams { - email: string; - passwordHash: string; - whmcsClientId: number; - sfAccountId: string; -} - -export interface CreatedUserResult { - userId: string; -} - -@Injectable() -export class PortalUserCreationService { - constructor( - private readonly prisma: PrismaService, - private readonly whmcsService: WhmcsCleanupService, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Create user and ID mapping in a single transaction - * Compensates by marking WHMCS client for cleanup on failure - */ - async createUserWithMapping(params: UserCreationParams): Promise { - try { - const result = await this.prisma.$transaction(async tx => { - const created = await tx.user.create({ - data: { - email: params.email, - passwordHash: params.passwordHash, - emailVerified: false, - failedLoginAttempts: 0, - lockedUntil: null, - lastLoginAt: null, - }, - select: { id: true, email: true }, - }); - - await tx.idMapping.create({ - data: { - userId: created.id, - whmcsClientId: params.whmcsClientId, - sfAccountId: params.sfAccountId, - }, - }); - - return { userId: created.id }; - }); - - this.logger.debug("Created user with mapping", { - userId: result.userId, - email: params.email, - whmcsClientId: params.whmcsClientId, - sfAccountId: params.sfAccountId, - }); - - return result; - } catch (dbError) { - this.logger.error("Database transaction failed, cleaning up WHMCS client", { - whmcsClientId: params.whmcsClientId, - email: params.email, - error: extractErrorMessage(dbError), - }); - - // Compensation: Mark the orphaned WHMCS client for cleanup - await this.whmcsService.markClientForCleanup(params.whmcsClientId, params.email); - - throw new BadRequestException( - `Failed to create user account: ${extractErrorMessage(dbError)}` - ); - } - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/whmcs-cleanup.service.ts b/apps/bff/src/modules/auth/infra/workflows/steps/whmcs-cleanup.service.ts deleted file mode 100644 index 9c427caf..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/steps/whmcs-cleanup.service.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Service responsible for WHMCS client creation and cleanup during signup. - * - * Used by CreateWhmcsClientStep for client creation and by - * PortalUserCreationService for compensation (marking orphaned clients). - */ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Logger } from "nestjs-pino"; -import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { serializeWhmcsKeyValueMap } from "@customer-portal/domain/customer/providers"; - -export interface WhmcsClientCreationParams { - firstName: string; - lastName: string; - email: string; - password: string; - company?: string; - phone: string; - address: { - address1: string; - address2?: string; - city: string; - state: string; - postcode: string; - country: string; - }; - customerNumber: string | null; - dateOfBirth?: string | null; - gender?: string | null; - nationality?: string | null; -} - -export interface WhmcsCreatedClient { - clientId: number; -} - -@Injectable() -export class WhmcsCleanupService { - constructor( - private readonly whmcsClientService: WhmcsClientService, - private readonly configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Create a new WHMCS client for signup - */ - async createClient(params: WhmcsClientCreationParams): Promise { - const customerNumberFieldId = this.configService.get("WHMCS_CUSTOMER_NUMBER_FIELD_ID"); - 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 customfieldsMap: Record = {}; - if (customerNumberFieldId && params.customerNumber) { - customfieldsMap[customerNumberFieldId] = params.customerNumber; - } - if (dobFieldId && params.dateOfBirth) customfieldsMap[dobFieldId] = params.dateOfBirth; - if (genderFieldId && params.gender) customfieldsMap[genderFieldId] = params.gender; - if (nationalityFieldId && params.nationality) - customfieldsMap[nationalityFieldId] = params.nationality; - - this.logger.log("Creating WHMCS client", { - email: params.email, - firstName: params.firstName, - lastName: params.lastName, - sfNumber: params.customerNumber, - }); - - try { - const whmcsClient = await this.whmcsClientService.addClient({ - firstname: params.firstName, - lastname: params.lastName, - email: params.email, - companyname: params.company || "", - phonenumber: params.phone, - address1: params.address.address1, - address2: params.address.address2 ?? "", - city: params.address.city, - state: params.address.state, - postcode: params.address.postcode, - country: params.address.country, - password2: params.password, - customfields: serializeWhmcsKeyValueMap(customfieldsMap) || undefined, - }); - - this.logger.log("WHMCS client created successfully", { - clientId: whmcsClient.clientId, - email: params.email, - }); - - return { clientId: whmcsClient.clientId }; - } catch (whmcsError) { - this.logger.error( - { - error: extractErrorMessage(whmcsError), - email: params.email, - firstName: params.firstName, - lastName: params.lastName, - }, - "Failed to create WHMCS client" - ); - throw new BadRequestException( - `Failed to create billing account: ${extractErrorMessage(whmcsError)}` - ); - } - } - - /** - * Mark orphaned WHMCS client for cleanup - * Called when database transaction fails after WHMCS client creation - */ - async markClientForCleanup(clientId: number, email: string): Promise { - try { - await this.whmcsClientService.updateClient(clientId, { - status: "Inactive", - }); - - this.logger.warn("Marked orphaned WHMCS client for manual cleanup", { - whmcsClientId: clientId, - email, - action: "marked_for_cleanup", - }); - } catch (cleanupError) { - this.logger.error("Failed to mark orphaned WHMCS client for cleanup", { - whmcsClientId: clientId, - email, - cleanupError: extractErrorMessage(cleanupError), - recommendation: "Manual cleanup required in WHMCS admin", - }); - } - } -} diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index a20f4cb9..2acb89f2 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -13,6 +13,7 @@ import { import type { Request, Response } from "express"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js"; +import { AuthHealthService } from "@bff/modules/auth/application/auth-health.service.js"; import { LocalAuthGuard } from "./guards/local-auth.guard.js"; import { FailedLoginThrottleGuard, @@ -24,6 +25,7 @@ import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { JoseJwtService } from "../../infra/token/jose-jwt.service.js"; import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js"; +import { PasswordWorkflowService } from "../../infra/workflows/password-workflow.service.js"; import type { UserAuth } from "@customer-portal/domain/customer"; import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js"; import { getRequestFingerprint } from "@bff/core/http/request-context.util.js"; @@ -82,7 +84,9 @@ export class AuthController { private authOrchestrator: AuthOrchestrator, private readonly jwtService: JoseJwtService, private readonly loginOtpWorkflow: LoginOtpWorkflowService, - private readonly trustedDeviceService: TrustedDeviceService + private readonly trustedDeviceService: TrustedDeviceService, + private readonly passwordWorkflow: PasswordWorkflowService, + private readonly healthService: AuthHealthService ) {} private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void { @@ -92,7 +96,7 @@ export class AuthController { @Public() @Get("health-check") async healthCheck() { - return this.authOrchestrator.healthCheck(); + return this.healthService.check(); } @Public() @@ -288,7 +292,10 @@ export class AuthController { @Req() _req: Request, @Res({ passthrough: true }) res: Response ) { - const result = await this.authOrchestrator.setPassword(setPasswordData); + const result = await this.passwordWorkflow.setPassword( + setPasswordData.email, + setPasswordData.password + ); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(result.tokens) }; } @@ -302,8 +309,7 @@ export class AuthController { type: CheckPasswordNeededResponseDto, }) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) { - const response = await this.authOrchestrator.checkPasswordNeeded(data.email); - return response; + return this.passwordWorkflow.checkPasswordNeeded(data.email); } @Public() @@ -311,7 +317,7 @@ export class AuthController { @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) { - await this.authOrchestrator.requestPasswordReset(body.email, req); + await this.passwordWorkflow.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; } @@ -324,7 +330,7 @@ export class AuthController { @Body() body: ResetPasswordRequestDto, @Res({ passthrough: true }) res: Response ) { - await this.authOrchestrator.resetPassword(body.token, body.password); + await this.passwordWorkflow.resetPassword(body.token, body.password); // Clear auth cookies after password reset to force re-login clearAuthCookies(res); @@ -339,7 +345,7 @@ export class AuthController { @Body() body: ChangePasswordRequestDto, @Res({ passthrough: true }) res: Response ) { - const result = await this.authOrchestrator.changePassword(req.user.id, body, req); + const result = await this.passwordWorkflow.changePassword(req.user.id, body, req); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(result.tokens) }; } diff --git a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts index 15e5c23e..669c5b4e 100644 --- a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts @@ -24,8 +24,12 @@ import { } from "@customer-portal/domain/get-started"; import type { User } from "@customer-portal/domain/customer"; -import { GetStartedCoordinator } from "../../infra/workflows/get-started-coordinator.service.js"; +import { VerificationWorkflowService } from "../../infra/workflows/verification-workflow.service.js"; +import { AccountCreationWorkflowService } from "../../infra/workflows/account-creation-workflow.service.js"; +import { AccountMigrationWorkflowService } from "../../infra/workflows/account-migration-workflow.service.js"; +import { GuestEligibilityWorkflowService } from "../../infra/workflows/guest-eligibility-workflow.service.js"; import { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js"; +import type { AuthResultInternal } from "../../auth.types.js"; // DTO classes using Zod schemas class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {} @@ -58,7 +62,12 @@ interface AuthSuccessResponse { */ @Controller("auth/get-started") export class GetStartedController { - constructor(private readonly workflow: GetStartedCoordinator) {} + constructor( + private readonly verification: VerificationWorkflowService, + private readonly accountCreation: AccountCreationWorkflowService, + private readonly accountMigration: AccountMigrationWorkflowService, + private readonly guestEligibility: GuestEligibilityWorkflowService + ) {} /** * Send OTP verification code to email @@ -73,7 +82,7 @@ export class GetStartedController { @Req() req: Request ): Promise { const fingerprint = getRateLimitFingerprint(req); - return this.workflow.sendVerificationCode(body, fingerprint); + return this.verification.sendCode(body, fingerprint); } /** @@ -89,7 +98,7 @@ export class GetStartedController { @Req() req: Request ): Promise { const fingerprint = getRateLimitFingerprint(req); - return this.workflow.verifyCode(body, fingerprint); + return this.verification.verifyCode(body, fingerprint); } /** @@ -108,7 +117,7 @@ export class GetStartedController { @Req() req: Request ): Promise { const fingerprint = getRateLimitFingerprint(req); - return this.workflow.guestEligibilityCheck(body, fingerprint); + return this.guestEligibility.execute(body, fingerprint); } /** @@ -126,7 +135,7 @@ export class GetStartedController { @Body() body: CompleteAccountRequestDto, @Res({ passthrough: true }) res: Response ): Promise { - const result = await this.workflow.completeAccount(body); + const result = (await this.accountCreation.execute(body)) as AuthResultInternal; setAuthCookies(res, result.tokens); @@ -155,7 +164,12 @@ export class GetStartedController { | SignupWithEligibilityResponseDto | (AuthSuccessResponse & { success: true; eligibilityRequestId?: string }) > { - const result = await this.workflow.signupWithEligibility(body); + const result = (await this.accountCreation.execute(body, { withEligibility: true })) as { + success: boolean; + message?: string; + eligibilityRequestId?: string; + authResult?: AuthResultInternal; + }; if (!result.success || !result.authResult) { return { @@ -190,7 +204,7 @@ export class GetStartedController { @Body() body: MigrateWhmcsAccountRequestDto, @Res({ passthrough: true }) res: Response ): Promise { - const result = await this.workflow.migrateWhmcsAccount(body); + const result = await this.accountMigration.execute(body); setAuthCookies(res, result.tokens); diff --git a/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts b/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts index fcaf2ca2..7e0ac817 100644 --- a/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts +++ b/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts @@ -1,4 +1,5 @@ -import type { Response, CookieOptions } from "express"; +import type { Response } from "express"; +import { getSecureCookie } from "./secure-cookie.util.js"; /** * Auth tokens structure for cookie setting @@ -19,24 +20,6 @@ export interface SessionInfo { tokenType: "Bearer"; } -/** - * Custom setSecureCookie function signature - * This is added by our security middleware - */ -type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void; - -/** - * Get setSecureCookie function from response if available - * Returns null if the custom helper is not present - */ -function getSecureCookie(res: Response): SetSecureCookieFn | null { - const maybeSecure = res as Response & { setSecureCookie?: unknown }; - if (typeof maybeSecure.setSecureCookie === "function") { - return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn; - } - return null; -} - // Cookie paths - access token needs broader access, refresh token only for refresh endpoint export const ACCESS_COOKIE_PATH = "/api"; export const REFRESH_COOKIE_PATH = "/api/auth/refresh"; diff --git a/apps/bff/src/modules/auth/presentation/http/utils/secure-cookie.util.ts b/apps/bff/src/modules/auth/presentation/http/utils/secure-cookie.util.ts new file mode 100644 index 00000000..2c2785f1 --- /dev/null +++ b/apps/bff/src/modules/auth/presentation/http/utils/secure-cookie.util.ts @@ -0,0 +1,19 @@ +import type { Response, CookieOptions } from "express"; + +/** + * Custom setSecureCookie function signature. + * This is added by our security middleware. + */ +export type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void; + +/** + * Get setSecureCookie function from response if available. + * Returns null if the custom helper is not present. + */ +export function getSecureCookie(res: Response): SetSecureCookieFn | null { + const maybeSecure = res as Response & { setSecureCookie?: unknown }; + if (typeof maybeSecure.setSecureCookie === "function") { + return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn; + } + return null; +} diff --git a/apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts b/apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts index 8f36ba54..1020b254 100644 --- a/apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts +++ b/apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts @@ -1,4 +1,5 @@ -import type { Request, Response, CookieOptions } from "express"; +import type { Request, Response } from "express"; +import { getSecureCookie } from "./secure-cookie.util.js"; /** * Cookie name for trusted device token @@ -10,24 +11,6 @@ export const TRUSTED_DEVICE_COOKIE_NAME = "trusted_device"; */ export const TRUSTED_DEVICE_COOKIE_PATH = "/api/auth"; -/** - * Custom setSecureCookie function signature - * This is added by our security middleware - */ -type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void; - -/** - * Get setSecureCookie function from response if available - * Returns null if the custom helper is not present - */ -function getSecureCookie(res: Response): SetSecureCookieFn | null { - const maybeSecure = res as Response & { setSecureCookie?: unknown }; - if (typeof maybeSecure.setSecureCookie === "function") { - return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn; - } - return null; -} - /** * Set the trusted device cookie on the response *