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 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();
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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;
|
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
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 { 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> {
|
||||||
|
|||||||
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
|
||||||
|
|||||||
@ -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 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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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
|
* 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
|
||||||
*
|
*
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user