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:
parent
451d58d436
commit
f6c0812061
@ -122,26 +122,33 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||
const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300);
|
||||
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)`,
|
||||
timeout: `${timeout / 1000} seconds`,
|
||||
});
|
||||
},
|
||||
"WHMCS Request Queue initialized"
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log("Shutting down WHMCS Request Queue", {
|
||||
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", {
|
||||
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", {
|
||||
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", {
|
||||
this.logger.warn(
|
||||
{
|
||||
queueSize: queue.size,
|
||||
pendingRequests: queue.pending,
|
||||
});
|
||||
},
|
||||
"Clearing WHMCS request queue"
|
||||
);
|
||||
|
||||
queue.clear();
|
||||
await queue.onIdle();
|
||||
|
||||
@ -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", {
|
||||
// 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", {
|
||||
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(
|
||||
|
||||
@ -49,12 +49,19 @@ export class WhmcsHttpClientService {
|
||||
this.stats.failedRequests++;
|
||||
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.
|
||||
// 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}]`, {
|
||||
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}]`, {
|
||||
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}]`, {
|
||||
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
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<void> {
|
||||
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<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) {
|
||||
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<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(
|
||||
@ -292,4 +210,22 @@ export class AuthOrchestrator {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
23
apps/bff/src/modules/auth/infra/token/token.types.ts
Normal file
23
apps/bff/src/modules/auth/infra/token/token.types.ts
Normal 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;
|
||||
}
|
||||
@ -355,21 +355,11 @@ export class AccountCreationWorkflowService {
|
||||
throw portalError;
|
||||
}
|
||||
|
||||
// Step 5: Update SF Flags (DEGRADABLE)
|
||||
await safeOperation(
|
||||
async () =>
|
||||
this.sfFlagsStep.execute({
|
||||
// Step 5: Update SF Flags (DEGRADABLE — step handles its own errors)
|
||||
await this.sfFlagsStep.execute({
|
||||
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
|
||||
const auditSource = withEligibility
|
||||
|
||||
@ -151,19 +151,12 @@ export class AccountMigrationWorkflowService {
|
||||
sfAccountId: sfAccount.id,
|
||||
});
|
||||
|
||||
// Update Salesforce portal flags (DEGRADABLE)
|
||||
try {
|
||||
// Update Salesforce portal flags (DEGRADABLE — step handles its own errors)
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
||||
// Generate auth result
|
||||
const authResult = await this.authResultStep.execute({
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<CreatePortalUserResult> {
|
||||
const { userId } = await this.portalUserCreation.createUserWithMapping({
|
||||
try {
|
||||
const result = await this.prisma.$transaction(async tx => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email: params.email,
|
||||
passwordHash: params.passwordHash,
|
||||
whmcsClientId: params.whmcsClientId,
|
||||
sfAccountId: params.sfAccountId,
|
||||
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> {
|
||||
|
||||
@ -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<CreateWhmcsClientResult> {
|
||||
const result: WhmcsCreatedClient = await this.signupWhmcs.createClient({
|
||||
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(
|
||||
{
|
||||
email: params.email,
|
||||
firstName: params.firstName,
|
||||
lastName: params.lastName,
|
||||
sfNumber: params.customerNumber,
|
||||
},
|
||||
"Creating WHMCS client"
|
||||
);
|
||||
|
||||
try {
|
||||
const whmcsClient = await this.whmcsClientService.addClient({
|
||||
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 }),
|
||||
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(
|
||||
{ email: params.email, whmcsClientId: result.clientId },
|
||||
"WHMCS client created"
|
||||
{ whmcsClientId: whmcsClient.clientId, email: params.email },
|
||||
"WHMCS client created successfully"
|
||||
);
|
||||
|
||||
return { whmcsClientId: result.clientId };
|
||||
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> {
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) };
|
||||
}
|
||||
|
||||
@ -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<SendVerificationCodeResponseDto> {
|
||||
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<VerifyCodeResponseDto> {
|
||||
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<GuestEligibilityResponseDto> {
|
||||
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<AuthSuccessResponse> {
|
||||
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<AuthSuccessResponse> {
|
||||
const result = await this.workflow.migrateWhmcsAccount(body);
|
||||
const result = await this.accountMigration.execute(body);
|
||||
|
||||
setAuthCookies(res, result.tokens);
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user