refactor: streamline authentication workflows and remove deprecated services

- Removed WhmcsLinkWorkflowService and integrated its functionality into new AccountCreationWorkflowService and AccountMigrationWorkflowService.
- Introduced OtpEmailService for sending OTP emails, enhancing email handling in LoginOtpWorkflowService and VerificationWorkflowService.
- Replaced NewCustomerSignupWorkflowService and SfCompletionWorkflowService with AccountCreationWorkflowService, consolidating account creation logic.
- Updated GetStartedCoordinator to utilize new workflow services, improving clarity and maintainability of the authentication process.
- Enhanced error handling and logging across workflows to provide better feedback during account creation and migration.
This commit is contained in:
barsa 2026-03-03 18:34:04 +09:00
parent 451d58d436
commit f6c0812061
26 changed files with 424 additions and 684 deletions

View File

@ -122,26 +122,33 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300); const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300);
const timeout = this.configService.get<number>("WHMCS_QUEUE_TIMEOUT_MS", 30000); const timeout = this.configService.get<number>("WHMCS_QUEUE_TIMEOUT_MS", 30000);
this.logger.log("WHMCS Request Queue initialized", { this.logger.log(
concurrency, {
rateLimit: `${intervalCap} requests/minute (${(intervalCap / 60).toFixed(1)} RPS)`, concurrency,
timeout: `${timeout / 1000} seconds`, rateLimit: `${intervalCap} requests/minute (${(intervalCap / 60).toFixed(1)} RPS)`,
}); timeout: `${timeout / 1000} seconds`,
},
"WHMCS Request Queue initialized"
);
} }
async onModuleDestroy() { async onModuleDestroy() {
this.logger.log("Shutting down WHMCS Request Queue", { this.logger.log(
pendingRequests: this.queue?.pending ?? 0, {
queueSize: this.queue?.size ?? 0, pendingRequests: this.queue?.pending ?? 0,
}); queueSize: this.queue?.size ?? 0,
},
"Shutting down WHMCS Request Queue"
);
// Wait for pending requests to complete (with timeout) // Wait for pending requests to complete (with timeout)
try { try {
await this.queue?.onIdle(); await this.queue?.onIdle();
} catch (error) { } catch (error) {
this.logger.warn("Some WHMCS requests may not have completed during shutdown", { this.logger.warn(
error: error instanceof Error ? error.message : String(error), { 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.metrics.totalRequests++;
this.updateQueueMetrics(); this.updateQueueMetrics();
this.logger.debug("Queueing WHMCS request", { this.logger.debug(
requestId, {
queueSize: queue.size, requestId,
pending: queue.pending, queueSize: queue.size,
priority: options.priority || 0, pending: queue.pending,
}); priority: options.priority || 0,
},
"Queueing WHMCS request"
);
try { try {
const result = (await queue.add( const result = (await queue.add(
@ -179,12 +189,15 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
this.metrics.completedRequests++; this.metrics.completedRequests++;
this.metrics.lastRequestTime = new Date(); this.metrics.lastRequestTime = new Date();
this.logger.debug("WHMCS request completed", { this.logger.debug(
requestId, {
waitTime, requestId,
executionTime, waitTime,
totalTime: Date.now() - startTime, executionTime,
}); totalTime: Date.now() - startTime,
},
"WHMCS request completed"
);
return response; return response;
} catch (error) { } catch (error) {
@ -193,7 +206,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
this.metrics.failedRequests++; this.metrics.failedRequests++;
this.metrics.lastErrorTime = new Date(); 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, requestId,
waitTime, waitTime,
@ -279,10 +295,13 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
return; return;
} }
this.logger.warn("Clearing WHMCS request queue", { this.logger.warn(
queueSize: queue.size, {
pendingRequests: queue.pending, queueSize: queue.size,
}); pendingRequests: queue.pending,
},
"Clearing WHMCS request queue"
);
queue.clear(); queue.clear();
await queue.onIdle(); await queue.onIdle();

View File

@ -66,12 +66,12 @@ export class WhmcsErrorHandlerService {
const message = extractErrorMessage(error); const message = extractErrorMessage(error);
if (this.isTimeoutError(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); throw new WhmcsTimeoutError(message, error);
} }
if (this.isNetworkError(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); throw new WhmcsNetworkError(message, error);
} }
@ -82,11 +82,16 @@ export class WhmcsErrorHandlerService {
// Check for HTTP status errors (e.g., "HTTP 500: Internal Server Error") // Check for HTTP status errors (e.g., "HTTP 500: Internal Server Error")
const httpStatusError = this.parseHttpStatusError(message); const httpStatusError = this.parseHttpStatusError(message);
if (httpStatusError) { if (httpStatusError) {
this.logger.error("WHMCS HTTP status error", { // WARN not ERROR — these are external system issues (config, permissions, availability),
upstreamStatus: httpStatusError.status, // not bugs in our code. 401/403 during discovery is expected for some WHMCS setups.
upstreamStatusText: httpStatusError.statusText, this.logger.warn(
originalError: message, {
}); upstreamStatus: httpStatusError.status,
upstreamStatusText: httpStatusError.statusText,
originalError: message,
},
"WHMCS HTTP status error"
);
// Map upstream HTTP status to appropriate domain error // Map upstream HTTP status to appropriate domain error
const mapped = this.mapHttpStatusToDomainError(httpStatusError.status); const mapped = this.mapHttpStatusToDomainError(httpStatusError.status);
@ -108,11 +113,14 @@ export class WhmcsErrorHandlerService {
} }
// Log unhandled errors for debugging // Log unhandled errors for debugging
this.logger.error("WHMCS unhandled request error", { this.logger.error(
error: message, {
errorType: error instanceof Error ? error.constructor.name : typeof error, error: message,
stack: error instanceof Error ? error.stack : undefined, 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 // Wrap unknown errors with context
throw new WhmcsApiError( throw new WhmcsApiError(

View File

@ -49,12 +49,19 @@ export class WhmcsHttpClientService {
this.stats.failedRequests++; this.stats.failedRequests++;
this.stats.lastErrorTime = new Date(); this.stats.lastErrorTime = new Date();
this.logger.warn(`WHMCS HTTP request failed [${action}]`, { // Log at debug — the error handler and business layer decide the final severity.
error: extractErrorMessage(error), // Logging at warn/error here duplicates upstream logs for expected outcomes
action, // (e.g. "Client Not Found" during discovery, HTTP 403 on GetUsers).
params: redactForLogs(params), this.logger.debug(
responseTime: Date.now() - startTime, {
}); error: extractErrorMessage(error),
action,
params: redactForLogs(params),
responseTime: Date.now() - startTime,
},
"WHMCS HTTP request failed [%s]",
action
);
throw error; throw error;
} }
@ -132,12 +139,16 @@ export class WhmcsHttpClientService {
if (process.env["NODE_ENV"] !== "production") { if (process.env["NODE_ENV"] !== "production") {
const snippet = responseText?.slice(0, 300); const snippet = responseText?.slice(0, 300);
if (snippet) { if (snippet) {
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, { this.logger.debug(
action, {
status: response.status, action,
statusText: response.statusText, status: response.status,
snippet, statusText: response.statusText,
}); snippet,
},
"WHMCS non-OK response body snippet [%s]",
action
);
} }
} }
@ -236,26 +247,34 @@ export class WhmcsHttpClientService {
parsedResponse = JSON.parse(responseText); parsedResponse = JSON.parse(responseText);
} catch (parseError) { } catch (parseError) {
const isProd = process.env["NODE_ENV"] === "production"; const isProd = process.env["NODE_ENV"] === "production";
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, { this.logger.error(
...(isProd {
? { responseTextLength: responseText.length } ...(isProd
: { responseText: responseText.slice(0, 500) }), ? { responseTextLength: responseText.length }
parseError: extractErrorMessage(parseError), : { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params), parseError: extractErrorMessage(parseError),
}); params: redactForLogs(params),
},
"Invalid JSON response from WHMCS API [%s]",
action
);
throw new WhmcsOperationException("Invalid JSON response from WHMCS API"); throw new WhmcsOperationException("Invalid JSON response from WHMCS API");
} }
// Validate basic response structure // Validate basic response structure
if (!this.isWhmcsResponse(parsedResponse)) { if (!this.isWhmcsResponse(parsedResponse)) {
const isProd = process.env["NODE_ENV"] === "production"; const isProd = process.env["NODE_ENV"] === "production";
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { this.logger.error(
responseType: typeof parsedResponse, {
...(isProd responseType: typeof parsedResponse,
? { responseTextLength: responseText.length } ...(isProd
: { responseText: responseText.slice(0, 500) }), ? { responseTextLength: responseText.length }
params: redactForLogs(params), : { 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"); 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 errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
const errorCode = this.toDisplayString(errorcode, "unknown"); const errorCode = this.toDisplayString(errorcode, "unknown");
// Many WHMCS "result=error" responses are expected business outcomes (e.g. invalid credentials). // Many WHMCS "result=error" responses are expected business outcomes (e.g. "Client Not Found"
// Log as warning (not error) to avoid spamming error logs; the unified exception filter will // during discovery, invalid credentials during login). Log at debug — the error handler
// still emit the request-level log based on the mapped error code. // classifies severity and the business layer logs the final outcome.
this.logger.warn( this.logger.debug(
{ {
errorMessage, errorMessage,
errorCode, errorCode,
params: redactForLogs(params), 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 // Return error response for the orchestrator to handle with proper exception types

View File

@ -31,7 +31,7 @@ export class WhmcsAccountDiscoveryService {
// 1. Try to find client ID by email from cache // 1. Try to find client ID by email from cache
const cachedClientId = await this.cacheService.getClientIdByEmail(email); const cachedClientId = await this.cacheService.getClientIdByEmail(email);
if (cachedClientId) { 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) // If we have ID, fetch the full client data (which has its own cache)
return this.getClientDetailsById(cachedClientId); return this.getClientDetailsById(cachedClientId);
} }
@ -54,7 +54,7 @@ export class WhmcsAccountDiscoveryService {
this.cacheService.setClientIdByEmail(email, client.id), 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; return client;
} catch (error) { } catch (error) {
// Handle "Not Found" specifically — this is expected for discovery // 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) // Get the first associated client (users can belong to multiple clients)
const clientAssociation = exactMatch.clients?.[0]; const clientAssociation = exactMatch.clients?.[0];
if (!clientAssociation) { 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; return null;
} }
this.logger.log( 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 { return {
userId: Number(exactMatch.id), userId: Number(exactMatch.id),
clientId: Number(clientAssociation.id), clientId: Number(clientAssociation.id),
}; };
} catch (error) { } catch (error) {
// Sub-account lookup is best-effort — many WHMCS setups don't expose GetUsers. // 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 // (commonly returns HTTP 403). Log at debug to avoid polluting warn logs on every
// (findClientByEmail) is the authority; this is supplementary. // signup. The primary client lookup (findClientByEmail) is the authority.
this.logger.warn( this.logger.debug(
{ email, error: extractErrorMessage(error) }, { email, error: extractErrorMessage(error) },
"User sub-account lookup unavailable — skipping" "User sub-account lookup unavailable — skipping"
); );

View File

@ -7,7 +7,7 @@ Authentication and authorization for the Customer Portal.
``` ```
auth/ auth/
├── application/ # Orchestration layer ├── 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-login.service.ts
│ └── auth-health.service.ts │ └── auth-health.service.ts
├── decorators/ ├── decorators/
@ -72,7 +72,7 @@ LocalAuthGuard (validates credentials)
FailedLoginThrottleGuard (rate limiting) FailedLoginThrottleGuard (rate limiting)
AuthFacade.login() AuthOrchestrator.login()
├─► Generate token pair ├─► Generate token pair
├─► Set httpOnly cookies ├─► Set httpOnly cookies

View File

@ -8,30 +8,22 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util.js"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util.js";
import { import { type SsoLinkResponse } from "@customer-portal/domain/auth";
type SetPasswordRequest,
type ChangePasswordRequest,
type SsoLinkResponse,
} from "@customer-portal/domain/auth";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
import type { Request } from "express"; import type { Request } from "express";
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js"; import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
import { AuthTokenService } from "../infra/token/token.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"; import { AuthLoginService } from "./auth-login.service.js";
/** /**
* Auth Orchestrator * Auth Orchestrator
* *
* Application layer orchestrator that coordinates authentication operations. * Application layer orchestrator that coordinates authentication operations
* Delegates to specialized services for specific functionality: * requiring multiple services: login completion, logout, SSO, account status.
* - AuthHealthService: Health checks *
* - AuthLoginService: Login validation * Password and health operations are handled by their own services,
* - PasswordWorkflowService: Password operations * injected directly by the controller.
*/ */
@Injectable() @Injectable()
export class AuthOrchestrator { export class AuthOrchestrator {
@ -43,53 +35,25 @@ export class AuthOrchestrator {
private readonly salesforceService: SalesforceFacade, private readonly salesforceService: SalesforceFacade,
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly tokenBlacklistService: TokenBlacklistService, private readonly tokenBlacklistService: TokenBlacklistService,
private readonly passwordWorkflow: PasswordWorkflowService,
private readonly tokenService: AuthTokenService, private readonly tokenService: AuthTokenService,
private readonly authRateLimitService: AuthRateLimitService,
private readonly healthService: AuthHealthService,
private readonly loginService: AuthLoginService, private readonly loginService: AuthLoginService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
async healthCheck() { async validateUser(
return this.healthService.check(); email: string,
} password: string,
/**
* 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;
},
request?: Request request?: Request
) { ): Promise<{ id: string; email: string; role: string } | null> {
if (request) { return this.loginService.validateUser(email, password, request);
await this.authRateLimitService.clearLoginAttempts(request);
}
return this.completeLogin(
{ id: user.id, email: user.email, role: user.role ?? "USER" },
request
);
} }
/** /**
* Complete login after credential validation (and OTP verification if required) * Complete login after credential validation (and OTP verification if required)
* Generates tokens and updates user state * 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) { async completeLogin(user: { id: string; email: string; role: string }, request?: Request) {
// Update last login time and reset failed attempts // 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, { const profile = await this.usersService.update(user.id, {
lastLoginAt: new Date(), lastLoginAt: new Date(),
failedLoginAttempts: 0, 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<void> { async logout(userId?: string, token?: string, request?: Request): Promise<void> {
if (token) { if (token) {
await this.tokenBlacklistService.blacklistToken(token); await this.tokenBlacklistService.blacklistToken(token);
@ -162,61 +110,6 @@ export class AuthOrchestrator {
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true); await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true);
} }
/**
* Create SSO link to WHMCS for general access
*/
async createSsoLink(userId: string, destination?: string): Promise<SsoLinkResponse> {
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<void> {
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<void> {
await this.passwordWorkflow.requestPasswordReset(email, request);
}
async resetPassword(token: string, newPassword: string) {
return this.passwordWorkflow.resetPassword(token, newPassword);
}
async getAccountStatus(email: string) { async getAccountStatus(email: string) {
const normalized = email?.toLowerCase().trim(); const normalized = email?.toLowerCase().trim();
if (!normalized || !normalized.includes("@")) { 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<SsoLinkResponse> {
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( async refreshTokens(
@ -292,4 +210,22 @@ export class AuthOrchestrator {
tokens, tokens,
}; };
} }
private async updateAccountLastSignIn(userId: string): Promise<void> {
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),
});
}
}
} }

View File

@ -6,8 +6,6 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
import { AddressModule } from "@bff/modules/address/address.module.js"; import { AddressModule } from "@bff/modules/address/address.module.js";
import { TokensModule } from "../tokens/tokens.module.js"; import { TokensModule } from "../tokens/tokens.module.js";
import { OtpModule } from "../otp/otp.module.js"; import { OtpModule } from "../otp/otp.module.js";
// Coordinator
import { GetStartedCoordinator } from "../infra/workflows/get-started-coordinator.service.js";
// Workflow services // Workflow services
import { VerificationWorkflowService } from "../infra/workflows/verification-workflow.service.js"; import { VerificationWorkflowService } from "../infra/workflows/verification-workflow.service.js";
import { GuestEligibilityWorkflowService } from "../infra/workflows/guest-eligibility-workflow.service.js"; import { GuestEligibilityWorkflowService } from "../infra/workflows/guest-eligibility-workflow.service.js";
@ -21,8 +19,6 @@ import {
UpdateSalesforceFlagsStep, UpdateSalesforceFlagsStep,
GenerateAuthResultStep, GenerateAuthResultStep,
CreateEligibilityCaseStep, CreateEligibilityCaseStep,
PortalUserCreationService,
WhmcsCleanupService,
} from "../infra/workflows/steps/index.js"; } from "../infra/workflows/steps/index.js";
// Controller // Controller
import { GetStartedController } from "../presentation/http/get-started.controller.js"; import { GetStartedController } from "../presentation/http/get-started.controller.js";
@ -39,8 +35,6 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
], ],
controllers: [GetStartedController], controllers: [GetStartedController],
providers: [ providers: [
// Coordinator
GetStartedCoordinator,
// Workflow services // Workflow services
VerificationWorkflowService, VerificationWorkflowService,
GuestEligibilityWorkflowService, GuestEligibilityWorkflowService,
@ -53,9 +47,6 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
UpdateSalesforceFlagsStep, UpdateSalesforceFlagsStep,
GenerateAuthResultStep, GenerateAuthResultStep,
CreateEligibilityCaseStep, CreateEligibilityCaseStep,
PortalUserCreationService,
WhmcsCleanupService,
], ],
exports: [GetStartedCoordinator],
}) })
export class GetStartedModule {} export class GetStartedModule {}

View File

@ -4,6 +4,7 @@
* Provides JWT token management, storage, and revocation capabilities. * Provides JWT token management, storage, and revocation capabilities.
*/ */
export type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
export { AuthTokenService } from "./token.service.js"; export { AuthTokenService } from "./token.service.js";
export { TokenGeneratorService } from "./token-generator.service.js"; export { TokenGeneratorService } from "./token-generator.service.js";
export { TokenRefreshService } from "./token-refresh.service.js"; export { TokenRefreshService } from "./token-refresh.service.js";

View File

@ -7,26 +7,12 @@ import {
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto"; import { randomBytes, createHash } from "crypto";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth"; import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserRole } from "@customer-portal/domain/customer"; import type { UserRole } from "@customer-portal/domain/customer";
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
import { JoseJwtService } from "./jose-jwt.service.js"; import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.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"; const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
/** /**

View File

@ -7,30 +7,16 @@ import {
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth"; import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth } from "@customer-portal/domain/customer"; import type { UserAuth } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js"; import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.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 { TokenGeneratorService } from "./token-generator.service.js";
import { TokenStorageService } from "./token-storage.service.js"; import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js"; import { TokenRevocationService } from "./token-revocation.service.js";
import { JoseJwtService } from "./jose-jwt.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_INVALID_REFRESH_TOKEN = "Invalid refresh token";
const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable"; const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable";

View File

@ -1,6 +1,7 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { DeviceInfo } from "./token.types.js";
export interface StoredRefreshToken { export interface StoredRefreshToken {
familyId: string; familyId: string;
@ -17,11 +18,6 @@ export interface StoredRefreshTokenFamily {
absoluteExpiresAt?: string | undefined; absoluteExpiresAt?: string | undefined;
} }
export interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
export interface StoreRefreshTokenParams { export interface StoreRefreshTokenParams {
userId: string; userId: string;
familyId: string; familyId: string;

View File

@ -2,35 +2,13 @@ import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common"
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth"; import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth, UserRole } from "@customer-portal/domain/customer"; import type { UserAuth, UserRole } from "@customer-portal/domain/customer";
import type { DeviceInfo } from "./token.types.js";
import { TokenGeneratorService } from "./token-generator.service.js"; import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenRefreshService } from "./token-refresh.service.js"; import { TokenRefreshService } from "./token-refresh.service.js";
import { TokenRevocationService } from "./token-revocation.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"; const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
/** /**

View File

@ -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;
}

View File

@ -355,21 +355,11 @@ export class AccountCreationWorkflowService {
throw portalError; throw portalError;
} }
// Step 5: Update SF Flags (DEGRADABLE) // Step 5: Update SF Flags (DEGRADABLE — step handles its own errors)
await safeOperation( await this.sfFlagsStep.execute({
async () => sfAccountId: sfResult.sfAccountId,
this.sfFlagsStep.execute({ whmcsClientId: whmcsResult.whmcsClientId,
sfAccountId: sfResult.sfAccountId, });
whmcsClientId: whmcsResult.whmcsClientId,
}),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF flags update",
logger: this.logger,
metadata: { email },
}
);
// Step 6: Generate Auth Result // Step 6: Generate Auth Result
const auditSource = withEligibility const auditSource = withEligibility

View File

@ -151,19 +151,12 @@ export class AccountMigrationWorkflowService {
sfAccountId: sfAccount.id, sfAccountId: sfAccount.id,
}); });
// Update Salesforce portal flags (DEGRADABLE) // Update Salesforce portal flags (DEGRADABLE — step handles its own errors)
try { await this.sfFlagsStep.execute({
await this.sfFlagsStep.execute({ sfAccountId: sfAccount.id,
sfAccountId: sfAccount.id, whmcsClientId,
whmcsClientId, source: PORTAL_SOURCE_MIGRATED,
source: PORTAL_SOURCE_MIGRATED, });
});
} catch (flagsError) {
this.logger.warn(
{ error: extractErrorMessage(flagsError), email },
"SF flags update failed (non-critical, continuing)"
);
}
// Generate auth result // Generate auth result
const authResult = await this.authResultStep.execute({ const authResult = await this.authResultStep.execute({

View File

@ -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<SendVerificationCodeResponse> {
return this.verification.sendCode(request, fingerprint);
}
async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise<VerifyCodeResponse> {
return this.verification.verifyCode(request, fingerprint);
}
async guestEligibilityCheck(
request: GuestEligibilityRequest,
fingerprint?: string
): Promise<GuestEligibilityResponse> {
return this.guestEligibility.execute(request, fingerprint);
}
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
return this.accountCreation.execute(request) as Promise<AuthResultInternal>;
}
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<AuthResultInternal> {
return this.accountMigration.execute(request);
}
}

View File

@ -1,7 +1,5 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { PortalUserCreationService } from "./portal-user-creation.service.js";
import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.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. * Step: Create a portal user with ID mapping.
* *
* Delegates to PortalUserCreationService for the Prisma transaction * Creates both the User row and the IdMapping row atomically
* that creates both the User row and the IdMapping row atomically. * in a single Prisma transaction.
* *
* Rollback deletes the user and associated ID mapping. * Rollback deletes the user and associated ID mapping.
*/ */
@Injectable() @Injectable()
export class CreatePortalUserStep { export class CreatePortalUserStep {
constructor( constructor(
private readonly portalUserCreation: PortalUserCreationService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
async execute(params: CreatePortalUserParams): Promise<CreatePortalUserResult> { async execute(params: CreatePortalUserParams): Promise<CreatePortalUserResult> {
const { userId } = await this.portalUserCreation.createUserWithMapping({ try {
email: params.email, const result = await this.prisma.$transaction(async tx => {
passwordHash: params.passwordHash, const created = await tx.user.create({
whmcsClientId: params.whmcsClientId, data: {
sfAccountId: params.sfAccountId, 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<void> { async rollback(userId: string): Promise<void> {

View File

@ -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 { 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 { export interface CreateWhmcsClientParams {
email: string; email: string;
@ -30,37 +33,79 @@ export interface CreateWhmcsClientResult {
/** /**
* Step: Create a WHMCS billing client. * 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. * Rollback marks the created client as inactive for manual cleanup.
*/ */
@Injectable() @Injectable()
export class CreateWhmcsClientStep { export class CreateWhmcsClientStep {
constructor( constructor(
private readonly signupWhmcs: WhmcsCleanupService, private readonly whmcsClientService: WhmcsClientService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
async execute(params: CreateWhmcsClientParams): Promise<CreateWhmcsClientResult> { async execute(params: CreateWhmcsClientParams): Promise<CreateWhmcsClientResult> {
const result: WhmcsCreatedClient = await this.signupWhmcs.createClient({ const customerNumberFieldId = this.configService.get<string>("WHMCS_CUSTOMER_NUMBER_FIELD_ID");
firstName: params.firstName, const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
lastName: params.lastName, const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
email: params.email, const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
password: params.password,
phone: params.phone, const customfieldsMap: Record<string, string> = {};
address: params.address, if (customerNumberFieldId && params.customerNumber) {
customerNumber: params.customerNumber, customfieldsMap[customerNumberFieldId] = params.customerNumber;
...(params.company != null && { company: params.company }), }
...(params.dateOfBirth != null && { dateOfBirth: params.dateOfBirth }), if (dobFieldId && params.dateOfBirth) customfieldsMap[dobFieldId] = params.dateOfBirth;
...(params.gender != null && { gender: params.gender }), if (genderFieldId && params.gender) customfieldsMap[genderFieldId] = params.gender;
...(params.nationality != null && { nationality: params.nationality }), if (nationalityFieldId && params.nationality)
}); customfieldsMap[nationalityFieldId] = params.nationality;
this.logger.log( 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<void> { async rollback(whmcsClientId: number, email?: string): Promise<void> {
@ -68,6 +113,26 @@ export class CreateWhmcsClientStep {
{ whmcsClientId, email }, { whmcsClientId, email },
"Rolling back WHMCS client creation — marking for cleanup" "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"
);
}
} }
} }

View File

@ -25,7 +25,3 @@ export type {
CreateEligibilityCaseAddress, CreateEligibilityCaseAddress,
CreateEligibilityCaseResult, CreateEligibilityCaseResult,
} from "./create-eligibility-case.step.js"; } 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";

View File

@ -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<CreatedUserResult> {
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)}`
);
}
}
}

View File

@ -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<WhmcsCreatedClient> {
const customerNumberFieldId = this.configService.get<string>("WHMCS_CUSTOMER_NUMBER_FIELD_ID");
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfieldsMap: Record<string, string> = {};
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<void> {
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",
});
}
}
}

View File

@ -13,6 +13,7 @@ import {
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.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 { LocalAuthGuard } from "./guards/local-auth.guard.js";
import { import {
FailedLoginThrottleGuard, FailedLoginThrottleGuard,
@ -24,6 +25,7 @@ import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js"; import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.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 type { UserAuth } from "@customer-portal/domain/customer";
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js"; import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
import { getRequestFingerprint } from "@bff/core/http/request-context.util.js"; import { getRequestFingerprint } from "@bff/core/http/request-context.util.js";
@ -82,7 +84,9 @@ export class AuthController {
private authOrchestrator: AuthOrchestrator, private authOrchestrator: AuthOrchestrator,
private readonly jwtService: JoseJwtService, private readonly jwtService: JoseJwtService,
private readonly loginOtpWorkflow: LoginOtpWorkflowService, 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 { private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
@ -92,7 +96,7 @@ export class AuthController {
@Public() @Public()
@Get("health-check") @Get("health-check")
async healthCheck() { async healthCheck() {
return this.authOrchestrator.healthCheck(); return this.healthService.check();
} }
@Public() @Public()
@ -288,7 +292,10 @@ export class AuthController {
@Req() _req: Request, @Req() _req: Request,
@Res({ passthrough: true }) res: Response @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); setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) }; return { user: result.user, session: buildSessionInfo(result.tokens) };
} }
@ -302,8 +309,7 @@ export class AuthController {
type: CheckPasswordNeededResponseDto, type: CheckPasswordNeededResponseDto,
}) })
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) { async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) {
const response = await this.authOrchestrator.checkPasswordNeeded(data.email); return this.passwordWorkflow.checkPasswordNeeded(data.email);
return response;
} }
@Public() @Public()
@ -311,7 +317,7 @@ export class AuthController {
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) { 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" }; return { message: "If an account exists, a reset email has been sent" };
} }
@ -324,7 +330,7 @@ export class AuthController {
@Body() body: ResetPasswordRequestDto, @Body() body: ResetPasswordRequestDto,
@Res({ passthrough: true }) res: Response @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 // Clear auth cookies after password reset to force re-login
clearAuthCookies(res); clearAuthCookies(res);
@ -339,7 +345,7 @@ export class AuthController {
@Body() body: ChangePasswordRequestDto, @Body() body: ChangePasswordRequestDto,
@Res({ passthrough: true }) res: Response @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); setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) }; return { user: result.user, session: buildSessionInfo(result.tokens) };
} }

View File

@ -24,8 +24,12 @@ import {
} from "@customer-portal/domain/get-started"; } from "@customer-portal/domain/get-started";
import type { User } from "@customer-portal/domain/customer"; 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 { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js";
import type { AuthResultInternal } from "../../auth.types.js";
// DTO classes using Zod schemas // DTO classes using Zod schemas
class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {} class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {}
@ -58,7 +62,12 @@ interface AuthSuccessResponse {
*/ */
@Controller("auth/get-started") @Controller("auth/get-started")
export class GetStartedController { 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 * Send OTP verification code to email
@ -73,7 +82,7 @@ export class GetStartedController {
@Req() req: Request @Req() req: Request
): Promise<SendVerificationCodeResponseDto> { ): Promise<SendVerificationCodeResponseDto> {
const fingerprint = getRateLimitFingerprint(req); 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 @Req() req: Request
): Promise<VerifyCodeResponseDto> { ): Promise<VerifyCodeResponseDto> {
const fingerprint = getRateLimitFingerprint(req); 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 @Req() req: Request
): Promise<GuestEligibilityResponseDto> { ): Promise<GuestEligibilityResponseDto> {
const fingerprint = getRateLimitFingerprint(req); 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, @Body() body: CompleteAccountRequestDto,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
): Promise<AuthSuccessResponse> { ): Promise<AuthSuccessResponse> {
const result = await this.workflow.completeAccount(body); const result = (await this.accountCreation.execute(body)) as AuthResultInternal;
setAuthCookies(res, result.tokens); setAuthCookies(res, result.tokens);
@ -155,7 +164,12 @@ export class GetStartedController {
| SignupWithEligibilityResponseDto | SignupWithEligibilityResponseDto
| (AuthSuccessResponse & { success: true; eligibilityRequestId?: string }) | (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) { if (!result.success || !result.authResult) {
return { return {
@ -190,7 +204,7 @@ export class GetStartedController {
@Body() body: MigrateWhmcsAccountRequestDto, @Body() body: MigrateWhmcsAccountRequestDto,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
): Promise<AuthSuccessResponse> { ): Promise<AuthSuccessResponse> {
const result = await this.workflow.migrateWhmcsAccount(body); const result = await this.accountMigration.execute(body);
setAuthCookies(res, result.tokens); setAuthCookies(res, result.tokens);

View File

@ -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 * Auth tokens structure for cookie setting
@ -19,24 +20,6 @@ export interface SessionInfo {
tokenType: "Bearer"; 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 // Cookie paths - access token needs broader access, refresh token only for refresh endpoint
export const ACCESS_COOKIE_PATH = "/api"; export const ACCESS_COOKIE_PATH = "/api";
export const REFRESH_COOKIE_PATH = "/api/auth/refresh"; export const REFRESH_COOKIE_PATH = "/api/auth/refresh";

View File

@ -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;
}

View File

@ -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 * 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"; 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 * Set the trusted device cookie on the response
* *