refactor: simplify auth signup flow by merging duplicate workflows

Merge SfCompletionWorkflow and NewCustomerSignupWorkflow into a single
AccountCreationWorkflowService, remove legacy /auth/migrate endpoint
and WhmcsLinkWorkflow, extract shared OTP email pattern into
OtpEmailService, and improve PORTAL_EXISTS redirect UX with email
pre-fill.

- Consolidate signup/ directory services into steps/ (PortalUserCreationService,
  WhmcsCleanupService) and new AccountCreationWorkflowService
- Rename WhmcsMigrationWorkflowService to AccountMigrationWorkflowService
- Remove dead code: WhmcsLinkWorkflowService, auth.controller /migrate endpoint
- Extract OtpEmailService from duplicated login/verification OTP email logic
- Pass email query param on PORTAL_EXISTS redirect for login pre-fill
- Delete 1977 lines of legacy code, add ~350 lines of consolidated logic
This commit is contained in:
barsa 2026-03-03 17:58:06 +09:00
parent 9b7cbcf78f
commit 451d58d436
26 changed files with 656 additions and 1518 deletions

View File

@ -48,7 +48,6 @@ export class CsrfMiddleware implements NestMiddleware {
"/api/auth/request-password-reset",
"/api/auth/reset-password", // Public auth endpoint for password reset
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
"/api/auth/migrate", // Public auth endpoint for account migration
"/api/health",
"/docs",
"/api/webhooks", // Webhooks typically don't use CSRF

View File

@ -9,7 +9,6 @@ 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 LinkWhmcsRequest,
type SetPasswordRequest,
type ChangePasswordRequest,
type SsoLinkResponse,
@ -21,7 +20,6 @@ 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";
import { WhmcsLinkWorkflowService } from "../infra/workflows/whmcs-link-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";
@ -34,7 +32,6 @@ import { AuthLoginService } from "./auth-login.service.js";
* - AuthHealthService: Health checks
* - AuthLoginService: Login validation
* - PasswordWorkflowService: Password operations
* - WhmcsLinkWorkflowService: WHMCS account linking
*/
@Injectable()
export class AuthOrchestrator {
@ -47,7 +44,6 @@ export class AuthOrchestrator {
private readonly auditService: AuditService,
private readonly tokenBlacklistService: TokenBlacklistService,
private readonly passwordWorkflow: PasswordWorkflowService,
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
private readonly tokenService: AuthTokenService,
private readonly authRateLimitService: AuthRateLimitService,
private readonly healthService: AuthHealthService,
@ -129,10 +125,6 @@ export class AuthOrchestrator {
};
}
async linkWhmcsUser(linkData: LinkWhmcsRequest) {
return this.whmcsLinkWorkflow.linkWhmcsUser(linkData.email, linkData.password);
}
async checkPasswordNeeded(email: string) {
return this.passwordWorkflow.checkPasswordNeeded(email);
}

View File

@ -19,7 +19,6 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
// Orchestrator-level services (not owned by any feature module)
import { AuthOrchestrator } from "./application/auth-orchestrator.service.js";
import { AuthHealthService } from "./application/auth-health.service.js";
import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js";
// Controller
import { AuthController } from "./presentation/http/auth.controller.js";
@ -42,7 +41,7 @@ import { AuthController } from "./presentation/http/auth.controller.js";
WorkflowModule,
],
controllers: [AuthController],
providers: [AuthOrchestrator, AuthHealthService, WhmcsLinkWorkflowService],
providers: [AuthOrchestrator, AuthHealthService],
exports: [AuthOrchestrator, TokensModule, SharedAuthModule],
})
export class AuthModule {}

View File

@ -11,14 +11,8 @@ import { GetStartedCoordinator } from "../infra/workflows/get-started-coordinato
// Workflow services
import { VerificationWorkflowService } from "../infra/workflows/verification-workflow.service.js";
import { GuestEligibilityWorkflowService } from "../infra/workflows/guest-eligibility-workflow.service.js";
import { NewCustomerSignupWorkflowService } from "../infra/workflows/new-customer-signup-workflow.service.js";
import { SfCompletionWorkflowService } from "../infra/workflows/sf-completion-workflow.service.js";
import { WhmcsMigrationWorkflowService } from "../infra/workflows/whmcs-migration-workflow.service.js";
// Signup shared services
import { SignupAccountResolverService } from "../infra/workflows/signup/signup-account-resolver.service.js";
import { SignupValidationService } from "../infra/workflows/signup/signup-validation.service.js";
import { SignupWhmcsService } from "../infra/workflows/signup/signup-whmcs.service.js";
import { SignupUserCreationService } from "../infra/workflows/signup/signup-user-creation.service.js";
import { AccountCreationWorkflowService } from "../infra/workflows/account-creation-workflow.service.js";
import { AccountMigrationWorkflowService } from "../infra/workflows/account-migration-workflow.service.js";
// Step services
import {
ResolveSalesforceAccountStep,
@ -27,6 +21,8 @@ import {
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
CreateEligibilityCaseStep,
PortalUserCreationService,
WhmcsCleanupService,
} from "../infra/workflows/steps/index.js";
// Controller
import { GetStartedController } from "../presentation/http/get-started.controller.js";
@ -48,14 +44,8 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
// Workflow services
VerificationWorkflowService,
GuestEligibilityWorkflowService,
NewCustomerSignupWorkflowService,
SfCompletionWorkflowService,
WhmcsMigrationWorkflowService,
// Signup shared services
SignupAccountResolverService,
SignupValidationService,
SignupWhmcsService,
SignupUserCreationService,
AccountCreationWorkflowService,
AccountMigrationWorkflowService,
// Step services
ResolveSalesforceAccountStep,
CreateWhmcsClientStep,
@ -63,6 +53,8 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
CreateEligibilityCaseStep,
PortalUserCreationService,
WhmcsCleanupService,
],
exports: [GetStartedCoordinator],
})

View File

@ -1,2 +1,3 @@
export { OtpService, type OtpVerifyResult } from "./otp.service.js";
export { OtpEmailService } from "./otp-email.service.js";
export { GetStartedSessionService } from "./get-started-session.service.js";

View File

@ -0,0 +1,55 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EmailService } from "@bff/infra/email/email.service.js";
type OtpEmailContext = "login" | "verification";
/** Template config keys and email subjects per context */
const OTP_EMAIL_CONFIG: Record<
OtpEmailContext,
{ configKey: string; subject: string; fallbackWarning: string }
> = {
login: {
configKey: "EMAIL_TEMPLATE_LOGIN_OTP",
subject: "Your login verification code",
fallbackWarning: "If you didn't attempt to log in, please secure your account immediately.",
},
verification: {
configKey: "EMAIL_TEMPLATE_OTP_VERIFICATION",
subject: "Your verification code",
fallbackWarning: "If you didn't request this code, please ignore this email.",
},
};
@Injectable()
export class OtpEmailService {
constructor(
private readonly config: ConfigService,
private readonly emailService: EmailService
) {}
async sendOtpCode(email: string, code: string, context: OtpEmailContext): Promise<void> {
const { configKey, subject, fallbackWarning } = OTP_EMAIL_CONFIG[context];
const templateId = this.config.get<string>(configKey);
if (templateId) {
await this.emailService.sendEmail({
to: email,
subject,
templateId,
dynamicTemplateData: { code, expiresMinutes: "10" },
});
} else {
await this.emailService.sendEmail({
to: email,
subject,
html: `
<p>Your verification code is: <strong>${code}</strong></p>
<p>This code expires in 10 minutes.</p>
<p>${fallbackWarning}</p>
`,
});
}
}
}

View File

@ -0,0 +1,536 @@
import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import * as argon2 from "argon2";
import {
type CompleteAccountRequest,
type SignupWithEligibilityRequest,
} from "@customer-portal/domain/get-started";
import type { BilingualAddress } from "@customer-portal/domain/address";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { EmailService } from "@bff/infra/email/email.service.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import {
PORTAL_SOURCE_NEW_SIGNUP,
PORTAL_SOURCE_INTERNET_ELIGIBILITY,
} from "@bff/modules/auth/constants/portal.constants.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import {
ResolveSalesforceAccountStep,
CreateEligibilityCaseStep,
CreateWhmcsClientStep,
CreatePortalUserStep,
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
} from "./steps/index.js";
import { classifyError } from "./workflow-error.util.js";
/** Result type for the signupWithEligibility path */
export interface SignupWithEligibilityResult {
success: boolean;
message?: string;
errorCategory?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}
/**
* Account Creation Workflow Service
*
* Merged workflow that handles both:
* - completeAccount (SF_UNMAPPED / NEW_CUSTOMER without eligibility)
* - signupWithEligibility (NEW_CUSTOMER with eligibility case)
*
* Replaces SfCompletionWorkflowService and NewCustomerSignupWorkflowService.
* Also fixes the Birthdate/Sex__c gap by calling createContact on the SF PersonContact.
*/
@Injectable()
export class AccountCreationWorkflowService {
constructor(
private readonly config: ConfigService,
private readonly sessionService: GetStartedSessionService,
private readonly lockService: DistributedLockService,
private readonly usersService: UsersService,
private readonly emailService: EmailService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly addressWriter: AddressWriterService,
private readonly sfStep: ResolveSalesforceAccountStep,
private readonly caseStep: CreateEligibilityCaseStep,
private readonly whmcsStep: CreateWhmcsClientStep,
private readonly portalUserStep: CreatePortalUserStep,
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
private readonly authResultStep: GenerateAuthResultStep,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Execute account creation.
*
* - Without options: completeAccount path (exceptions propagate)
* - With { withEligibility: true }: signupWithEligibility path (errors caught and returned)
*/
async execute(
request: CompleteAccountRequest | SignupWithEligibilityRequest,
options?: { withEligibility?: boolean }
): Promise<AuthResultInternal | SignupWithEligibilityResult> {
if (options?.withEligibility) {
return this.executeWithEligibility(request as SignupWithEligibilityRequest);
}
return this.executeCompleteAccount(request as CompleteAccountRequest);
}
// ---------------------------------------------------------------------------
// completeAccount path — exceptions propagate directly
// ---------------------------------------------------------------------------
private async executeCompleteAccount(
request: CompleteAccountRequest
): Promise<AuthResultInternal> {
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken,
"complete_account"
);
if (!sessionResult.success) {
throw new BadRequestException(sessionResult.reason);
}
const session = sessionResult.session;
this.validateRequest(request, session);
const lockKey = `complete-account:${session.email}`;
try {
// Type assertion safe: executeCreation returns AuthResultInternal when withEligibility=false
return (await this.lockService.withLock(
lockKey,
async () => this.executeCreation(request, session, false),
{ ttlMs: 60_000 }
)) as AuthResultInternal;
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: session.email },
"Account completion failed"
);
throw error;
}
}
// ---------------------------------------------------------------------------
// signupWithEligibility path — errors caught and returned as result objects
// ---------------------------------------------------------------------------
private async executeWithEligibility(
request: SignupWithEligibilityRequest
): Promise<SignupWithEligibilityResult> {
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken,
"signup_with_eligibility"
);
if (!sessionResult.success) {
return { success: false, message: sessionResult.reason };
}
const normalizedEmail = sessionResult.session.email;
this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility");
const lockKey = `signup-email:${normalizedEmail}`;
try {
// Type assertion safe: executeCreation returns SignupWithEligibilityResult when withEligibility=true
return (await this.lockService.withLock(
lockKey,
async () => this.executeCreation(request, sessionResult.session, true),
{ ttlMs: 60_000 }
)) as SignupWithEligibilityResult;
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: normalizedEmail },
"Signup with eligibility failed"
);
const classified = classifyError(error);
return {
success: false,
errorCategory: classified.errorCategory,
message: classified.message,
};
}
}
// ---------------------------------------------------------------------------
// Shared creation logic
// ---------------------------------------------------------------------------
private async executeCreation(
request: CompleteAccountRequest | SignupWithEligibilityRequest,
session: {
email: string;
sfAccountId?: string | undefined;
firstName?: string | undefined;
lastName?: string | undefined;
address?: Record<string, unknown> | undefined;
},
withEligibility: boolean
): Promise<AuthResultInternal | SignupWithEligibilityResult> {
const { password, phone, dateOfBirth, gender } = request;
const email = session.email;
const isNewCustomer = !session.sfAccountId;
// Check for existing accounts
if (withEligibility) {
const existingCheck = await this.checkExistingAccountsSafe(email);
if (existingCheck) {
return existingCheck;
}
} else {
await this.ensureNoExistingAccounts(email);
}
// Hash password
const passwordHash = await argon2.hash(password);
// Resolve address and names based on path
let finalFirstName: string;
let finalLastName: string;
let address: NonNullable<CompleteAccountRequest["address"]> | BilingualAddress;
if (withEligibility) {
const eligibilityRequest = request as SignupWithEligibilityRequest;
finalFirstName = eligibilityRequest.firstName;
finalLastName = eligibilityRequest.lastName;
address = eligibilityRequest.address;
} else {
const completeRequest = request as CompleteAccountRequest;
address = this.resolveAddress(completeRequest.address, session.address);
const names = this.resolveNames(completeRequest.firstName, completeRequest.lastName, session);
finalFirstName = names.finalFirstName;
finalLastName = names.finalLastName;
}
// Step 1: Resolve SF Account (CRITICAL)
const sfResult = await this.sfStep.execute({
email,
...(session.sfAccountId != null && { existingAccountId: session.sfAccountId }),
firstName: finalFirstName,
lastName: finalLastName,
phone,
source: withEligibility ? PORTAL_SOURCE_INTERNET_ELIGIBILITY : PORTAL_SOURCE_NEW_SIGNUP,
...(withEligibility && { updateSourceIfExists: true }),
});
// Step 1.5: Write SF Address (DEGRADABLE) + Step 1.6: Create SF Contact (DEGRADABLE)
// + resolve WHMCS address (varies by path)
let eligibilityRequestId: string | undefined;
if (withEligibility) {
const bilingualAddress = address as BilingualAddress;
// SF address write + eligibility case creation (both DEGRADABLE, independent)
const [, caseId] = await Promise.all([
safeOperation(
async () => this.addressWriter.writeToSalesforce(sfResult.sfAccountId, bilingualAddress),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF address write",
logger: this.logger,
metadata: { email },
}
),
safeOperation(
async () => {
const caseResult = await this.caseStep.execute({
sfAccountId: sfResult.sfAccountId,
address: this.addressWriter.toCaseAddress(bilingualAddress),
});
return caseResult.caseId;
},
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined as string | undefined,
context: "Eligibility case creation",
logger: this.logger,
metadata: { email },
}
),
]);
eligibilityRequestId = caseId;
} else {
// SF address write only for new customers with prefectureJa
const completeAddress = address as NonNullable<CompleteAccountRequest["address"]>;
if (isNewCustomer && completeAddress.prefectureJa) {
await safeOperation(
async () =>
this.addressWriter.writeToSalesforce(
sfResult.sfAccountId,
completeAddress as BilingualAddress
),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF address write",
logger: this.logger,
metadata: { email },
}
);
}
}
// Step 1.6: Create SF Contact — Birthdate + Sex__c (DEGRADABLE, NEW)
if (dateOfBirth && gender) {
await safeOperation(
async () =>
this.salesforceAccountService.createContact({
accountId: sfResult.sfAccountId,
firstName: finalFirstName,
lastName: finalLastName,
email,
phone: phone ?? "",
gender: gender,
dateOfBirth,
}),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF contact creation (Birthdate/Sex__c)",
logger: this.logger,
metadata: { email },
}
);
}
// Step 3: Create WHMCS Client (CRITICAL)
let whmcsAddress;
if (withEligibility) {
whmcsAddress = this.addressWriter.resolveWhmcsAddressFromBilingual(
address as BilingualAddress
);
} else {
const completeAddress = address as NonNullable<CompleteAccountRequest["address"]>;
whmcsAddress = await this.addressWriter.resolveWhmcsAddress({
postcode: completeAddress.postcode,
townJa: completeAddress.townJa,
streetAddress: completeAddress.streetAddress || "",
buildingName: completeAddress.buildingName,
roomNumber: completeAddress.roomNumber,
residenceType: completeAddress.residenceType || "house",
});
}
const whmcsResult = await this.whmcsStep.execute({
firstName: finalFirstName,
lastName: finalLastName,
email,
password,
phone: phone ?? "",
address: this.addressWriter.toWhmcsStepAddress(whmcsAddress),
customerNumber: sfResult.customerNumber ?? null,
dateOfBirth,
gender,
});
// Step 4: Create Portal User (CRITICAL, rollback WHMCS on fail)
let portalUserResult: { userId: string };
try {
portalUserResult = await this.portalUserStep.execute({
email,
passwordHash,
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
});
} catch (portalError) {
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, email);
throw portalError;
}
// Step 5: Update SF Flags (DEGRADABLE)
await safeOperation(
async () =>
this.sfFlagsStep.execute({
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
}),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF flags update",
logger: this.logger,
metadata: { email },
}
);
// Step 6: Generate Auth Result
const auditSource = withEligibility
? "signup_with_eligibility"
: isNewCustomer
? "get_started_new_customer"
: "get_started_complete_account";
const authResult = await this.authResultStep.execute({
userId: portalUserResult.userId,
email,
auditSource,
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
});
// Invalidate session
await this.sessionService.invalidate(request.sessionToken);
// Send welcome email (only for eligibility path, DEGRADABLE)
if (withEligibility) {
await this.sendWelcomeWithEligibilityEmail(email, finalFirstName, eligibilityRequestId);
}
this.logger.log(
{
email,
userId: portalUserResult.userId,
isNewCustomer,
withEligibility,
...(eligibilityRequestId != null && { eligibilityRequestId }),
},
withEligibility
? "Signup with eligibility completed successfully"
: "Account completed successfully"
);
if (withEligibility) {
return {
success: true,
...(eligibilityRequestId != null && { eligibilityRequestId }),
authResult,
};
}
return authResult;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private validateRequest(
request: CompleteAccountRequest,
session: { sfAccountId?: string | undefined }
): void {
const isNewCustomer = !session.sfAccountId;
if (!isNewCustomer) {
return;
}
if (!request.firstName || !request.lastName) {
throw new BadRequestException("First name and last name are required for new accounts.");
}
if (!request.address) {
throw new BadRequestException("Address is required for new accounts.");
}
}
private async ensureNoExistingAccounts(email: string): Promise<void> {
const portalUser = await this.usersService.findByEmailInternal(email);
if (portalUser) {
throw new ConflictException("An account already exists. Please log in.");
}
}
private async checkExistingAccountsSafe(
email: string
): Promise<{ success: false; message: string } | null> {
const portalUser = await this.usersService.findByEmailInternal(email);
if (portalUser) {
return {
success: false,
message: "An account already exists with this email. Please log in.",
};
}
return null;
}
private resolveAddress(
requestAddress: CompleteAccountRequest["address"] | undefined,
sessionAddress: Record<string, unknown> | undefined
): NonNullable<CompleteAccountRequest["address"]> {
const address = requestAddress ?? sessionAddress;
if (!address || !address.postcode) {
throw new BadRequestException(
"Address information is incomplete. Please ensure postcode is provided."
);
}
return address as NonNullable<CompleteAccountRequest["address"]>;
}
private resolveNames(
firstName: string | undefined,
lastName: string | undefined,
session: { firstName?: string | undefined; lastName?: string | undefined }
): { finalFirstName: string; finalLastName: string } {
const finalFirstName = firstName ?? session.firstName;
const finalLastName = lastName ?? session.lastName;
if (!finalFirstName || !finalLastName) {
throw new BadRequestException("Name information is missing. Please provide your name.");
}
return { finalFirstName, finalLastName };
}
private async sendWelcomeWithEligibilityEmail(
email: string,
firstName: string,
eligibilityRequestId?: string
): Promise<void> {
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
const templateId = this.config.get<string>("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY");
try {
if (templateId) {
await this.emailService.sendEmail({
to: email,
subject: "Welcome! Your account is ready",
templateId,
dynamicTemplateData: {
firstName,
portalUrl: appBase,
dashboardUrl: `${appBase}/account`,
eligibilityRequestId: eligibilityRequestId ?? "",
},
});
} else {
await this.emailService.sendEmail({
to: email,
subject: "Welcome! Your account is ready",
html: `
<p>Hi ${firstName},</p>
<p>Welcome! Your account has been created successfully.</p>
<p>We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.</p>
${eligibilityRequestId ? `<p>Reference ID: ${eligibilityRequestId}</p>` : ""}
<p>Log in to your dashboard: <a href="${appBase}/account">${appBase}/account</a></p>
`,
});
}
} catch (emailError) {
this.logger.warn(
{ error: extractErrorMessage(emailError), email },
"Failed to send welcome email (non-critical)"
);
}
}
}

View File

@ -21,8 +21,11 @@ import { PORTAL_SOURCE_MIGRATED } from "@bff/modules/auth/constants/portal.const
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import { SignupUserCreationService } from "./signup/signup-user-creation.service.js";
import { UpdateSalesforceFlagsStep, GenerateAuthResultStep } from "./steps/index.js";
import {
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
CreatePortalUserStep,
} from "./steps/index.js";
/** WHMCS client update payload for account migration (password + optional custom fields) */
interface WhmcsMigrationClientUpdate {
@ -31,14 +34,14 @@ interface WhmcsMigrationClientUpdate {
}
/**
* WHMCS Migration Workflow Service
* Account Migration Workflow Service
*
* Handles WHMCS_UNMAPPED path passwordless migration for users
* who already have a WHMCS account. Email verification serves as
* identity proof. Creates Portal user + SF mapping.
*/
@Injectable()
export class WhmcsMigrationWorkflowService {
export class AccountMigrationWorkflowService {
constructor(
private readonly config: ConfigService,
private readonly sessionService: GetStartedSessionService,
@ -48,7 +51,7 @@ export class WhmcsMigrationWorkflowService {
private readonly salesforceFacade: SalesforceFacade,
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
private readonly whmcsClientService: WhmcsClientService,
private readonly userCreation: SignupUserCreationService,
private readonly portalUserStep: CreatePortalUserStep,
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
private readonly authResultStep: GenerateAuthResultStep,
@Inject(Logger) private readonly logger: Logger
@ -141,7 +144,7 @@ export class WhmcsMigrationWorkflowService {
await this.updateWhmcsClientForMigration(whmcsClientId, password, dateOfBirth, gender);
// Create portal user and ID mapping
const { userId } = await this.userCreation.createUserWithMapping({
const { userId } = await this.portalUserStep.execute({
email,
passwordHash,
whmcsClientId,

View File

@ -16,9 +16,8 @@ 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 { NewCustomerSignupWorkflowService } from "./new-customer-signup-workflow.service.js";
import { SfCompletionWorkflowService } from "./sf-completion-workflow.service.js";
import { WhmcsMigrationWorkflowService } from "./whmcs-migration-workflow.service.js";
import { AccountCreationWorkflowService } from "./account-creation-workflow.service.js";
import { AccountMigrationWorkflowService } from "./account-migration-workflow.service.js";
/**
* Get Started Coordinator
@ -32,9 +31,8 @@ export class GetStartedCoordinator {
constructor(
private readonly verification: VerificationWorkflowService,
private readonly guestEligibility: GuestEligibilityWorkflowService,
private readonly newCustomerSignup: NewCustomerSignupWorkflowService,
private readonly sfCompletion: SfCompletionWorkflowService,
private readonly whmcsMigration: WhmcsMigrationWorkflowService
private readonly accountCreation: AccountCreationWorkflowService,
private readonly accountMigration: AccountMigrationWorkflowService
) {}
async sendVerificationCode(
@ -56,7 +54,7 @@ export class GetStartedCoordinator {
}
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
return this.sfCompletion.execute(request);
return this.accountCreation.execute(request) as Promise<AuthResultInternal>;
}
async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{
@ -65,10 +63,15 @@ export class GetStartedCoordinator {
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}> {
return this.newCustomerSignup.execute(request);
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.whmcsMigration.execute(request);
return this.accountMigration.execute(request);
}
}

View File

@ -1,12 +1,11 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { EmailService } from "@/infra/email/email.service.js";
import { AuditService, AuditAction } from "@/infra/audit/audit.service.js";
import { extractErrorMessage } from "@/core/utils/error.util.js";
import { OtpService, type OtpVerifyResult } from "../otp/otp.service.js";
import { OtpEmailService } from "../otp/otp-email.service.js";
import { LoginSessionService } from "../login/login-session.service.js";
/**
@ -41,10 +40,9 @@ export interface LoginOtpVerifyError {
@Injectable()
export class LoginOtpWorkflowService {
constructor(
private readonly config: ConfigService,
private readonly otpService: OtpService,
private readonly sessionService: LoginSessionService,
private readonly emailService: EmailService,
private readonly otpEmailService: OtpEmailService,
private readonly auditService: AuditService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -74,7 +72,7 @@ export class LoginOtpWorkflowService {
const { sessionToken, expiresAt } = await this.sessionService.create(user, fingerprint);
// Send OTP email
await this.sendLoginOtpEmail(normalizedEmail, code);
await this.otpEmailService.sendOtpCode(normalizedEmail, code, "login");
// Audit log
await this.auditService.logAuthEvent(AuditAction.LOGIN_OTP_SENT, user.id, {
@ -218,36 +216,6 @@ export class LoginOtpWorkflowService {
};
}
/**
* Send login OTP email
*/
private async sendLoginOtpEmail(email: string, code: string): Promise<void> {
const templateId = this.config.get<string>("EMAIL_TEMPLATE_LOGIN_OTP");
if (templateId) {
await this.emailService.sendEmail({
to: email,
subject: "Your login verification code",
templateId,
dynamicTemplateData: {
code,
expiresMinutes: "10",
},
});
} else {
// Fallback to plain HTML
await this.emailService.sendEmail({
to: email,
subject: "Your login verification code",
html: `
<p>Your login verification code is: <strong>${code}</strong></p>
<p>This code expires in 10 minutes.</p>
<p>If you didn't attempt to log in, please secure your account immediately.</p>
`,
});
}
}
/**
* Mask email for display (e.g., j***e@e***.com)
*

View File

@ -1,307 +0,0 @@
import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import * as argon2 from "argon2";
import { type SignupWithEligibilityRequest } from "@customer-portal/domain/get-started";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { EmailService } from "@bff/infra/email/email.service.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import { PORTAL_SOURCE_INTERNET_ELIGIBILITY } from "@bff/modules/auth/constants/portal.constants.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import {
ResolveSalesforceAccountStep,
CreateEligibilityCaseStep,
CreateWhmcsClientStep,
CreatePortalUserStep,
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
} from "./steps/index.js";
import { classifyError } from "./workflow-error.util.js";
/**
* New Customer Signup Workflow Service
*
* Handles NEW_CUSTOMER signup path (and signupWithEligibility).
* Creates SF Account + Case + WHMCS client + Portal user in a
* distributed transaction with compensating rollbacks.
*/
@Injectable()
export class NewCustomerSignupWorkflowService {
constructor(
private readonly config: ConfigService,
private readonly sessionService: GetStartedSessionService,
private readonly lockService: DistributedLockService,
private readonly usersService: UsersService,
private readonly emailService: EmailService,
private readonly sfStep: ResolveSalesforceAccountStep,
private readonly caseStep: CreateEligibilityCaseStep,
private readonly whmcsStep: CreateWhmcsClientStep,
private readonly portalUserStep: CreatePortalUserStep,
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
private readonly authResultStep: GenerateAuthResultStep,
private readonly addressWriter: AddressWriterService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Full signup with eligibility check creates everything in one operation.
*
* Flow: acquire session lock by email distributed transaction
*/
async execute(request: SignupWithEligibilityRequest): Promise<{
success: boolean;
message?: string;
errorCategory?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}> {
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken,
"signup_with_eligibility"
);
if (!sessionResult.success) {
return { success: false, message: sessionResult.reason };
}
const normalizedEmail = sessionResult.session.email;
this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility");
const lockKey = `signup-email:${normalizedEmail}`;
try {
return await this.lockService.withLock(
lockKey,
async () => this.executeSignup(request, normalizedEmail),
{ ttlMs: 60_000 }
);
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: normalizedEmail },
"Signup with eligibility failed"
);
const classified = classifyError(error);
return {
success: false,
errorCategory: classified.errorCategory,
message: classified.message,
};
}
}
private async executeSignup(
request: SignupWithEligibilityRequest,
email: string
): Promise<{
success: boolean;
message?: string;
errorCategory?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}> {
// Check for existing accounts
const existingCheck = await this.checkExistingAccounts(email);
if (existingCheck) {
return existingCheck;
}
const passwordHash = await argon2.hash(request.password);
return this.executeWithSteps(request, email, passwordHash);
}
/**
* Execute the signup flow with steps and manual rollback coordination.
*
* Uses DistributedTransactionService for coordinated rollback while
* passing data between steps.
*/
private async executeWithSteps(
request: SignupWithEligibilityRequest,
email: string,
passwordHash: string
): Promise<{
success: boolean;
message?: string;
errorCategory?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}> {
const { firstName, lastName, address, phone, password, dateOfBirth, gender } = request;
// Step 1: Resolve SF account (CRITICAL)
const sfResult = await this.sfStep.execute({
email,
firstName,
lastName,
phone,
source: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
updateSourceIfExists: true,
});
// Steps 1.5 + 2: SF address write + eligibility case creation (both DEGRADABLE, independent)
const [, eligibilityRequestId] = await Promise.all([
safeOperation(
async () => this.addressWriter.writeToSalesforce(sfResult.sfAccountId, address),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF address write",
logger: this.logger,
metadata: { email },
}
),
safeOperation(
async () => {
const caseResult = await this.caseStep.execute({
sfAccountId: sfResult.sfAccountId,
address: this.addressWriter.toCaseAddress(address),
});
return caseResult.caseId;
},
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined as string | undefined,
context: "Eligibility case creation",
logger: this.logger,
metadata: { email },
}
),
]);
// Step 3: Create WHMCS client (CRITICAL, has rollback)
const whmcsAddress = this.addressWriter.resolveWhmcsAddressFromBilingual(address);
const whmcsResult = await this.whmcsStep.execute({
email,
password,
firstName,
lastName,
phone: phone ?? "",
address: this.addressWriter.toWhmcsStepAddress(whmcsAddress),
customerNumber: sfResult.customerNumber ?? null,
dateOfBirth,
gender,
});
// Step 4: Create portal user (CRITICAL, has rollback)
let portalUserResult: { userId: string };
try {
portalUserResult = await this.portalUserStep.execute({
email,
passwordHash,
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
});
} catch (portalError) {
// Rollback WHMCS client
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, email);
throw portalError;
}
// Step 5: Update SF flags (DEGRADABLE)
await safeOperation(
async () =>
this.sfFlagsStep.execute({
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
}),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF flags update",
logger: this.logger,
metadata: { email },
}
);
// Step 6: Generate auth result + finalize
const authResult = await this.authResultStep.execute({
userId: portalUserResult.userId,
email,
auditSource: "signup_with_eligibility",
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
});
await this.sessionService.invalidate(request.sessionToken);
await this.sendWelcomeWithEligibilityEmail(email, firstName, eligibilityRequestId);
this.logger.log(
{ email, userId: portalUserResult.userId, eligibilityRequestId },
"Signup with eligibility completed successfully"
);
return {
success: true,
...(eligibilityRequestId != null && { eligibilityRequestId }),
authResult,
};
}
private async checkExistingAccounts(
email: string
): Promise<{ success: false; message: string } | null> {
// Only check Portal — the verification phase already checked WHMCS,
// and the AddClient step will reject duplicates if one was created in the interim.
const portalUser = await this.usersService.findByEmailInternal(email);
if (portalUser) {
return {
success: false,
message: "An account already exists with this email. Please log in.",
};
}
return null;
}
private async sendWelcomeWithEligibilityEmail(
email: string,
firstName: string,
eligibilityRequestId?: string
): Promise<void> {
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
const templateId = this.config.get<string>("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY");
try {
if (templateId) {
await this.emailService.sendEmail({
to: email,
subject: "Welcome! Your account is ready",
templateId,
dynamicTemplateData: {
firstName,
portalUrl: appBase,
dashboardUrl: `${appBase}/account`,
eligibilityRequestId: eligibilityRequestId ?? "",
},
});
} else {
await this.emailService.sendEmail({
to: email,
subject: "Welcome! Your account is ready",
html: `
<p>Hi ${firstName},</p>
<p>Welcome! Your account has been created successfully.</p>
<p>We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.</p>
${eligibilityRequestId ? `<p>Reference ID: ${eligibilityRequestId}</p>` : ""}
<p>Log in to your dashboard: <a href="${appBase}/account">${appBase}/account</a></p>
`,
});
}
} catch (emailError) {
this.logger.warn(
{ error: extractErrorMessage(emailError), email },
"Failed to send welcome email (non-critical)"
);
}
}
}

View File

@ -1,265 +0,0 @@
import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import * as argon2 from "argon2";
import { type CompleteAccountRequest } from "@customer-portal/domain/get-started";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import { PORTAL_SOURCE_NEW_SIGNUP } from "@bff/modules/auth/constants/portal.constants.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
import type { BilingualAddress } from "@customer-portal/domain/address";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import {
ResolveSalesforceAccountStep,
CreateWhmcsClientStep,
CreatePortalUserStep,
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
} from "./steps/index.js";
/**
* SF Completion Workflow Service
*
* Handles account completion for SF_UNMAPPED and NEW_CUSTOMER paths
* (the completeAccount endpoint). Creates WHMCS client + Portal user
* and links to an existing or new SF account.
*/
@Injectable()
export class SfCompletionWorkflowService {
constructor(
private readonly sessionService: GetStartedSessionService,
private readonly lockService: DistributedLockService,
private readonly usersService: UsersService,
private readonly sfStep: ResolveSalesforceAccountStep,
private readonly whmcsStep: CreateWhmcsClientStep,
private readonly portalUserStep: CreatePortalUserStep,
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
private readonly authResultStep: GenerateAuthResultStep,
private readonly addressWriter: AddressWriterService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Complete account for users with SF account but no WHMCS/Portal.
* Also handles NEW_CUSTOMER who need a new SF account created.
*/
async execute(request: CompleteAccountRequest): Promise<AuthResultInternal> {
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken,
"complete_account"
);
if (!sessionResult.success) {
throw new BadRequestException(sessionResult.reason);
}
const session = sessionResult.session;
this.validateRequest(request, session);
const lockKey = `complete-account:${session.email}`;
try {
return await this.lockService.withLock(
lockKey,
async () => this.executeCompletion(request, session),
{ ttlMs: 60_000 }
);
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: session.email },
"Account completion failed"
);
throw error;
}
}
private validateRequest(
request: CompleteAccountRequest,
session: { sfAccountId?: string | undefined }
): void {
const isNewCustomer = !session.sfAccountId;
if (!isNewCustomer) {
return;
}
if (!request.firstName || !request.lastName) {
throw new BadRequestException("First name and last name are required for new accounts.");
}
if (!request.address) {
throw new BadRequestException("Address is required for new accounts.");
}
}
private async executeCompletion(
request: CompleteAccountRequest,
session: {
email: string;
sfAccountId?: string | undefined;
firstName?: string | undefined;
lastName?: string | undefined;
address?: Record<string, unknown> | undefined;
}
): Promise<AuthResultInternal> {
const {
password,
phone,
dateOfBirth,
gender,
firstName,
lastName,
address: requestAddress,
} = request;
const isNewCustomer = !session.sfAccountId;
// Check for existing accounts
await this.ensureNoExistingAccounts(session.email);
// Resolve address and names
const address = this.resolveAddress(requestAddress, session.address);
const { finalFirstName, finalLastName } = this.resolveNames(firstName, lastName, session);
// Step 1: Resolve SF account (CRITICAL)
const sfResult = await this.sfStep.execute({
email: session.email,
...(session.sfAccountId != null && { existingAccountId: session.sfAccountId }),
firstName: finalFirstName,
lastName: finalLastName,
phone,
source: PORTAL_SOURCE_NEW_SIGNUP,
});
// Steps 1.5 + 2: SF address write (DEGRADABLE) + WHMCS address resolve (CRITICAL) — independent
const [, whmcsAddress] = await Promise.all([
isNewCustomer && address.prefectureJa
? safeOperation(
async () =>
this.addressWriter.writeToSalesforce(
sfResult.sfAccountId,
address as BilingualAddress
),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF address write",
logger: this.logger,
metadata: { email: session.email },
}
)
: Promise.resolve(),
this.addressWriter.resolveWhmcsAddress({
postcode: address.postcode,
townJa: address.townJa,
streetAddress: address.streetAddress || "",
buildingName: address.buildingName,
roomNumber: address.roomNumber,
residenceType: address.residenceType || "house",
}),
]);
const whmcsResult = await this.whmcsStep.execute({
firstName: finalFirstName,
lastName: finalLastName,
email: session.email,
password,
phone: phone ?? "",
address: this.addressWriter.toWhmcsStepAddress(whmcsAddress),
customerNumber: sfResult.customerNumber ?? null,
dateOfBirth,
gender,
});
// Step 3: Create portal user (CRITICAL, has rollback)
const passwordHash = await argon2.hash(password);
let portalUserResult: { userId: string };
try {
portalUserResult = await this.portalUserStep.execute({
email: session.email,
passwordHash,
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
});
} catch (portalError) {
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, session.email);
throw portalError;
}
// Step 4: Update SF flags (DEGRADABLE)
await safeOperation(
async () =>
this.sfFlagsStep.execute({
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
}),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF flags update",
logger: this.logger,
metadata: { email: session.email },
}
);
// Step 5: Generate auth result
const authResult = await this.authResultStep.execute({
userId: portalUserResult.userId,
email: session.email,
auditSource: isNewCustomer ? "get_started_new_customer" : "get_started_complete_account",
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
});
await this.sessionService.invalidate(request.sessionToken);
this.logger.log(
{ email: session.email, userId: portalUserResult.userId, isNewCustomer },
"Account completed successfully"
);
return authResult;
}
private async ensureNoExistingAccounts(email: string): Promise<void> {
// Only check Portal — the verification phase already checked WHMCS,
// and the AddClient step will reject duplicates if one was created in the interim.
const portalUser = await this.usersService.findByEmailInternal(email);
if (portalUser) {
throw new ConflictException("An account already exists. Please log in.");
}
}
private resolveAddress(
requestAddress: CompleteAccountRequest["address"] | undefined,
sessionAddress: Record<string, unknown> | undefined
): NonNullable<CompleteAccountRequest["address"]> {
const address = requestAddress ?? sessionAddress;
if (!address || !address.postcode) {
throw new BadRequestException(
"Address information is incomplete. Please ensure postcode is provided."
);
}
return address as NonNullable<CompleteAccountRequest["address"]>;
}
private resolveNames(
firstName: string | undefined,
lastName: string | undefined,
session: { firstName?: string | undefined; lastName?: string | undefined }
): { finalFirstName: string; finalLastName: string } {
const finalFirstName = firstName ?? session.firstName;
const finalLastName = lastName ?? session.lastName;
if (!finalFirstName || !finalLastName) {
throw new BadRequestException("Name information is missing. Please provide your name.");
}
return { finalFirstName, finalLastName };
}
}

View File

@ -1,12 +0,0 @@
/**
* Signup workflow exports
*/
export { SignupAccountResolverService } from "./signup-account-resolver.service.js";
export { SignupValidationService } from "./signup-validation.service.js";
export { SignupWhmcsService } from "./signup-whmcs.service.js";
export { SignupUserCreationService } from "./signup-user-creation.service.js";
export type {
SignupAccountSnapshot,
SignupAccountCacheEntry,
SignupPreflightResult,
} from "./signup.types.js";

View File

@ -1,210 +0,0 @@
/**
* Service responsible for resolving and caching Salesforce account information during signup
*/
import { Injectable, Inject, BadRequestException, ConflictException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { ErrorCode } from "@customer-portal/domain/common";
import type { SignupRequest } from "@customer-portal/domain/auth";
import type { SignupAccountSnapshot, SignupAccountCacheEntry } from "./signup.types.js";
import { PORTAL_SOURCE_NEW_SIGNUP } from "@bff/modules/auth/constants/portal.constants.js";
@Injectable()
export class SignupAccountResolverService {
private readonly cacheTtlSeconds = 30;
private readonly cachePrefix = "auth:signup:account:";
constructor(
private readonly salesforceService: SalesforceFacade,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly cache: CacheService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Resolve a Salesforce account by customer number with caching,
* or create a new one if no customer number is provided.
*/
async resolveOrCreate(
signupData: SignupRequest
): Promise<{ snapshot: SignupAccountSnapshot; customerNumber: string | null }> {
const { sfNumber, email, firstName, lastName, phone, address, gender, dateOfBirth } =
signupData;
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
if (normalizedCustomerNumber) {
const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!resolved) {
throw new BadRequestException({
code: ErrorCode.CUSTOMER_NOT_FOUND,
message: `Salesforce account not found for Customer Number: ${normalizedCustomerNumber}`,
});
}
if (resolved.WH_Account__c && resolved.WH_Account__c.trim() !== "") {
throw new ConflictException(
"You already have an account. Please use the login page to access your existing account."
);
}
return { snapshot: resolved, customerNumber: normalizedCustomerNumber };
}
// No customer number - create new SF account
const normalizedEmail = email.toLowerCase().trim();
const existingAccount = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingAccount) {
throw new ConflictException(
"An account already exists for this email. Please sign in or transfer your account."
);
}
if (
!address?.address1 ||
!address?.city ||
!address?.state ||
!address?.postcode ||
!address?.country
) {
throw new BadRequestException(
"Complete address information is required for account creation"
);
}
if (!phone) {
throw new BadRequestException("Phone number is required for account creation");
}
if (!gender) {
throw new BadRequestException("Gender is required for account creation");
}
if (!dateOfBirth) {
throw new BadRequestException("Date of birth is required for account creation");
}
let created: { accountId: string; accountNumber: string };
try {
created = await this.salesforceAccountService.createAccount({
firstName,
lastName,
email: normalizedEmail,
phone,
portalSource: PORTAL_SOURCE_NEW_SIGNUP,
});
} catch (error) {
this.logger.error("Salesforce Account creation failed - blocking signup", {
email: normalizedEmail,
error: extractErrorMessage(error),
});
throw new BadRequestException(
"Failed to create customer account. Please try again or contact support."
);
}
try {
await this.salesforceAccountService.createContact({
accountId: created.accountId,
firstName,
lastName,
email: normalizedEmail,
phone,
gender,
dateOfBirth,
});
} catch (error) {
this.logger.error("PersonContact update failed after Account creation", {
accountId: created.accountId,
email: normalizedEmail,
error: extractErrorMessage(error),
note: "Salesforce Account was created but Contact update failed",
});
throw new BadRequestException("Failed to complete account setup. Please contact support.");
}
const snapshot: SignupAccountSnapshot = {
id: created.accountId,
Name: `${firstName} ${lastName}`,
WH_Account__c: null,
};
return { snapshot, customerNumber: created.accountNumber };
}
/**
* Resolve a Salesforce account by customer number with caching
*/
async getAccountSnapshot(sfNumber?: string | null): Promise<SignupAccountSnapshot | null> {
const normalized = this.normalizeCustomerNumber(sfNumber);
if (!normalized) {
return null;
}
const cacheKey = this.buildCacheKey(normalized);
const cached = await this.cache.get<SignupAccountCacheEntry | null>(cacheKey);
const unwrapped = this.unwrapCacheEntry(cached);
if (unwrapped.hit) {
this.logger.debug("Account snapshot cache hit", { sfNumber: normalized });
return unwrapped.value;
}
const resolved =
await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized);
await this.cache.set(cacheKey, this.wrapCacheEntry(resolved ?? null), this.cacheTtlSeconds);
return resolved;
}
/**
* Normalize customer number input
*/
normalizeCustomerNumber(sfNumber?: string | null): string | null {
if (typeof sfNumber !== "string") {
return null;
}
const trimmed = sfNumber.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Invalidate cached account snapshot
*/
async invalidateCache(sfNumber: string): Promise<void> {
const normalized = this.normalizeCustomerNumber(sfNumber);
if (normalized) {
const cacheKey = this.buildCacheKey(normalized);
await this.cache.del(cacheKey);
}
}
private buildCacheKey(customerNumber: string): string {
return `${this.cachePrefix}${customerNumber}`;
}
private unwrapCacheEntry(cached: SignupAccountCacheEntry | null): {
hit: boolean;
value: SignupAccountSnapshot | null;
} {
if (!cached) {
return { hit: false, value: null };
}
if (typeof cached === "object" && cached.__signupCache === true) {
return { hit: true, value: cached.value ?? null };
}
return { hit: true, value: (cached as unknown as SignupAccountSnapshot) ?? null };
}
private wrapCacheEntry(snapshot: SignupAccountSnapshot | null): SignupAccountCacheEntry {
return {
value: snapshot ?? null,
__signupCache: true,
};
}
}

View File

@ -1,321 +0,0 @@
/**
* Service responsible for validating signup data and checking for existing accounts
*/
import { Injectable, Inject, BadRequestException, ConflictException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Request } from "express";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SignupAccountResolverService } from "./signup-account-resolver.service.js";
import type { ValidateSignupRequest } from "@customer-portal/domain/auth";
import type { SignupPreflightResult } from "./signup.types.js";
/** Error message for duplicate account conflicts */
const DUPLICATE_ACCOUNT_MESSAGE =
"You already have an account. Please use the login page to access your existing account.";
/** Error message when customer number not found in Salesforce */
const CUSTOMER_NOT_FOUND_MESSAGE = "CUSTOMER_NOT_FOUND_MESSAGE";
@Injectable()
export class SignupValidationService {
// eslint-disable-next-line max-params -- NestJS DI requires individual constructor injection
constructor(
private readonly usersService: UsersService,
private readonly mappingsService: MappingsService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly accountResolver: SignupAccountResolverService,
private readonly auditService: AuditService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Validate customer number for signup
*/
async validateCustomerNumber(
validateData: ValidateSignupRequest,
request?: Request
): Promise<{ valid: boolean; sfAccountId?: string; message: string }> {
const { sfNumber } = validateData;
const normalizedCustomerNumber = this.accountResolver.normalizeCustomerNumber(sfNumber);
if (!normalizedCustomerNumber) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber: sfNumber ?? null, reason: "no_customer_number_provided" },
request,
true
);
return {
valid: true,
message: "Customer number is not required for signup",
};
}
try {
const accountSnapshot =
await this.accountResolver.getAccountSnapshot(normalizedCustomerNumber);
if (!accountSnapshot) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
request,
false,
CUSTOMER_NOT_FOUND_MESSAGE
);
throw new BadRequestException(CUSTOMER_NOT_FOUND_MESSAGE);
}
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
if (existingMapping) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: accountSnapshot.id, reason: "Already has mapping" },
request,
false,
"Customer number already registered"
);
throw new ConflictException(DUPLICATE_ACCOUNT_MESSAGE);
}
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{
sfNumber,
sfAccountId: accountSnapshot.id,
whAccount: accountSnapshot.WH_Account__c,
reason: "WH Account not empty",
},
request,
false,
"Account already has WHMCS integration"
);
throw new ConflictException(DUPLICATE_ACCOUNT_MESSAGE);
}
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
request,
true
);
return {
valid: true,
sfAccountId: accountSnapshot.id,
message: "Customer number validated successfully",
};
} catch (error) {
if (error instanceof BadRequestException || error instanceof ConflictException) {
throw error;
}
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber: normalizedCustomerNumber, error: extractErrorMessage(error) },
request,
false,
extractErrorMessage(error)
);
this.logger.error("Signup validation error", { error: extractErrorMessage(error) });
throw new BadRequestException("Validation failed");
}
}
/**
* Check if email is already registered in portal
*/
async checkExistingPortalUser(email: string): Promise<{
exists: boolean;
userId?: string;
hasMapping?: boolean;
needsPasswordSet?: boolean;
}> {
const normalizedEmail = email.toLowerCase().trim();
const existingUser = await this.usersService.findByEmailInternal(normalizedEmail);
if (!existingUser) {
return { exists: false };
}
const hasMapping = await this.mappingsService.hasMapping(existingUser.id);
return {
exists: true,
userId: existingUser.id,
hasMapping,
needsPasswordSet: !existingUser.passwordHash,
};
}
/**
* Comprehensive preflight check before signup
*/
async preflightCheck(email: string, sfNumber?: string | null): Promise<SignupPreflightResult> {
const normalizedEmail = email.toLowerCase().trim();
const normalizedCustomerNumber = this.accountResolver.normalizeCustomerNumber(sfNumber);
const result = this.createEmptyPreflightResult(normalizedEmail);
const portalCheckResult = await this.checkPortalUser(normalizedEmail, result);
if (portalCheckResult) return portalCheckResult;
if (!normalizedCustomerNumber) {
return this.checkWithoutCustomerNumber(normalizedEmail, result);
}
return this.checkWithCustomerNumber(normalizedEmail, normalizedCustomerNumber, result);
}
private createEmptyPreflightResult(normalizedEmail: string): SignupPreflightResult {
return {
canProceed: false,
nextAction: null,
messages: [],
normalized: { email: normalizedEmail },
portal: { userExists: false },
salesforce: { alreadyMapped: false },
whmcs: { clientExists: false },
};
}
private async checkPortalUser(
email: string,
result: SignupPreflightResult
): Promise<SignupPreflightResult | null> {
const portalUserAuth = await this.usersService.findByEmailInternal(email);
if (!portalUserAuth) return null;
result.portal.userExists = true;
const mapped = await this.mappingsService.hasMapping(portalUserAuth.id);
if (mapped) {
result.nextAction = "login";
result.messages.push("An account already exists. Please sign in.");
return result;
}
result.portal.needsPasswordSet = !portalUserAuth.passwordHash;
result.nextAction = portalUserAuth.passwordHash ? "login" : "fix_input";
result.messages.push(
portalUserAuth.passwordHash
? "An account exists without billing link. Please sign in to continue setup."
: "An account exists and needs password setup. Please set a password to continue."
);
return result;
}
private async checkWhmcsClientMapping(
clientId: number,
result: SignupPreflightResult
): Promise<SignupPreflightResult | null> {
result.whmcs.clientExists = true;
result.whmcs.clientId = clientId;
try {
const mapped = await this.mappingsService.findByWhmcsClientId(clientId);
if (mapped) {
result.nextAction = "login";
result.messages.push("This billing account is already linked. Please sign in.");
return result;
}
} catch {
// ignore; treat as unmapped
}
result.nextAction = "link_whmcs";
result.messages.push(
"We found an existing billing account for this email. Please transfer your account to continue."
);
return result;
}
private async checkWithoutCustomerNumber(
email: string,
result: SignupPreflightResult
): Promise<SignupPreflightResult> {
const client = await this.discoveryService.findClientByEmail(email);
if (client) {
const whmcsResult = await this.checkWhmcsClientMapping(client.id, result);
if (whmcsResult) return whmcsResult;
}
const sfCheckResult = await this.checkSalesforceAccount(email, result);
if (sfCheckResult) return sfCheckResult;
return this.markAsCanProceed(result);
}
private async checkSalesforceAccount(
email: string,
result: SignupPreflightResult
): Promise<SignupPreflightResult | null> {
try {
const existingSf = await this.salesforceAccountService.findByEmail(email);
if (!existingSf) return null;
return {
...result,
salesforce: { ...result.salesforce, accountId: existingSf.id },
nextAction: "complete_account",
messages: [
...result.messages,
"We found your existing account. Please verify your email to complete setup.",
],
};
} catch (sfErr) {
this.logger.warn("Salesforce preflight check failed", {
error: extractErrorMessage(sfErr),
});
return null;
}
}
private async checkWithCustomerNumber(
email: string,
customerNumber: string,
result: SignupPreflightResult
): Promise<SignupPreflightResult> {
const accountSnapshot = await this.accountResolver.getAccountSnapshot(customerNumber);
if (!accountSnapshot) {
result.nextAction = "fix_input";
result.messages.push(CUSTOMER_NOT_FOUND_MESSAGE);
return result;
}
result.salesforce.accountId = accountSnapshot.id;
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
if (existingMapping) {
result.salesforce.alreadyMapped = true;
result.nextAction = "login";
result.messages.push("This customer number is already registered. Please sign in.");
return result;
}
const client = await this.discoveryService.findClientByEmail(email);
if (client) {
const whmcsResult = await this.checkWhmcsClientMapping(client.id, result);
if (whmcsResult) return whmcsResult;
}
return this.markAsCanProceed(result);
}
private markAsCanProceed(result: SignupPreflightResult): SignupPreflightResult {
result.canProceed = true;
result.nextAction = "proceed_signup";
result.messages.push("All checks passed. Ready to create your account.");
return result;
}
}

View File

@ -1,51 +0,0 @@
/**
* Shared types for signup workflow services
*/
/**
* Snapshot of a Salesforce account for signup purposes
*/
export interface SignupAccountSnapshot {
id: string;
Name?: string | null | undefined;
WH_Account__c?: string | null | undefined;
}
/**
* Cache entry structure for account snapshots
*/
export interface SignupAccountCacheEntry {
value: SignupAccountSnapshot | null;
__signupCache: true;
}
/**
* Result of signup preflight check
*/
export interface SignupPreflightResult {
canProceed: boolean;
nextAction:
| "login"
| "proceed_signup"
| "link_whmcs"
| "fix_input"
| "blocked"
| "complete_account"
| null;
messages: string[];
normalized: {
email: string;
};
portal: {
userExists: boolean;
needsPasswordSet?: boolean | undefined;
};
salesforce: {
accountId?: string | undefined;
alreadyMapped: boolean;
};
whmcs: {
clientExists: boolean;
clientId?: number | undefined;
};
}

View File

@ -1,6 +1,6 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SignupUserCreationService } from "../signup/signup-user-creation.service.js";
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,7 +19,7 @@ export interface CreatePortalUserResult {
/**
* Step: Create a portal user with ID mapping.
*
* Delegates to SignupUserCreationService for the Prisma transaction
* Delegates to PortalUserCreationService for the Prisma transaction
* that creates both the User row and the IdMapping row atomically.
*
* Rollback deletes the user and associated ID mapping.
@ -27,13 +27,13 @@ export interface CreatePortalUserResult {
@Injectable()
export class CreatePortalUserStep {
constructor(
private readonly signupUserCreation: SignupUserCreationService,
private readonly portalUserCreation: PortalUserCreationService,
private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger
) {}
async execute(params: CreatePortalUserParams): Promise<CreatePortalUserResult> {
const { userId } = await this.signupUserCreation.createUserWithMapping({
const { userId } = await this.portalUserCreation.createUserWithMapping({
email: params.email,
passwordHash: params.passwordHash,
whmcsClientId: params.whmcsClientId,

View File

@ -1,7 +1,6 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SignupWhmcsService } from "../signup/signup-whmcs.service.js";
import type { WhmcsCreatedClient } from "../signup/signup-whmcs.service.js";
import { WhmcsCleanupService, type WhmcsCreatedClient } from "./whmcs-cleanup.service.js";
export interface CreateWhmcsClientParams {
email: string;
@ -31,13 +30,13 @@ export interface CreateWhmcsClientResult {
/**
* Step: Create a WHMCS billing client.
*
* Delegates to SignupWhmcsService for the actual API call.
* Delegates to WhmcsCleanupService for the actual API call.
* Rollback marks the created client as inactive for manual cleanup.
*/
@Injectable()
export class CreateWhmcsClientStep {
constructor(
private readonly signupWhmcs: SignupWhmcsService,
private readonly signupWhmcs: WhmcsCleanupService,
@Inject(Logger) private readonly logger: Logger
) {}

View File

@ -25,3 +25,7 @@ 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";

View File

@ -5,7 +5,7 @@ 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 { SignupWhmcsService } from "./signup-whmcs.service.js";
import { WhmcsCleanupService } from "./whmcs-cleanup.service.js";
export interface UserCreationParams {
email: string;
@ -19,10 +19,10 @@ export interface CreatedUserResult {
}
@Injectable()
export class SignupUserCreationService {
export class PortalUserCreationService {
constructor(
private readonly prisma: PrismaService,
private readonly whmcsService: SignupWhmcsService,
private readonly whmcsService: WhmcsCleanupService,
@Inject(Logger) private readonly logger: Logger
) {}

View File

@ -1,23 +1,15 @@
/**
* Service responsible for WHMCS client creation and cleanup during signup
* 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,
ConflictException,
HttpStatus,
} from "@nestjs/common";
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import { ErrorCode } from "@customer-portal/domain/common";
import { serializeWhmcsKeyValueMap } from "@customer-portal/domain/customer/providers";
import type { SignupRequest } from "@customer-portal/domain/auth";
export interface WhmcsClientCreationParams {
firstName: string;
@ -45,33 +37,13 @@ export interface WhmcsCreatedClient {
}
@Injectable()
export class SignupWhmcsService {
export class WhmcsCleanupService {
constructor(
private readonly whmcsClientService: WhmcsClientService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly mappingsService: MappingsService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Check if WHMCS client already exists for email
* Throws ConflictException or DomainHttpException if client exists
*/
async checkExistingClient(email: string): Promise<void> {
const existingWhmcs = await this.discoveryService.findClientByEmail(email);
if (!existingWhmcs) {
return;
}
const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id);
if (existingMapping) {
throw new ConflictException("You already have an account. Please sign in.");
}
throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
}
/**
* Create a new WHMCS client for signup
*/
@ -160,27 +132,4 @@ export class SignupWhmcsService {
});
}
}
/**
* Validate address data for WHMCS client creation
*/
validateAddressData(signupData: SignupRequest): void {
const { phone, address } = signupData;
if (
!address?.address1 ||
!address?.city ||
!address?.state ||
!address?.postcode ||
!address?.country
) {
throw new BadRequestException(
"Complete address information is required for billing account creation"
);
}
if (!phone) {
throw new BadRequestException("Phone number is required for billing account creation");
}
}
}

View File

@ -1,5 +1,4 @@
import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import {
@ -11,7 +10,6 @@ import {
type VerifyCodeResponse,
} from "@customer-portal/domain/get-started";
import { EmailService } from "@bff/infra/email/email.service.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
@ -19,6 +17,7 @@ import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/w
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { OtpService } from "../otp/otp.service.js";
import { OtpEmailService } from "../otp/otp-email.service.js";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
/**
@ -53,10 +52,9 @@ interface AccountStatusResult {
@Injectable()
export class VerificationWorkflowService {
constructor(
private readonly config: ConfigService,
private readonly otpService: OtpService,
private readonly otpEmailService: OtpEmailService,
private readonly sessionService: GetStartedSessionService,
private readonly emailService: EmailService,
private readonly usersService: UsersService,
private readonly mappingsService: MappingsService,
private readonly salesforceAccountService: SalesforceAccountService,
@ -82,7 +80,7 @@ export class VerificationWorkflowService {
await this.sessionService.create(normalizedEmail);
// Send email with OTP code
await this.sendOtpEmail(normalizedEmail, code);
await this.otpEmailService.sendOtpCode(normalizedEmail, code, "verification");
this.logger.log({ email: normalizedEmail }, "OTP verification code sent");
@ -161,32 +159,6 @@ export class VerificationWorkflowService {
// Private helpers
// ---------------------------------------------------------------------------
private async sendOtpEmail(email: string, code: string): Promise<void> {
const templateId = this.config.get<string>("EMAIL_TEMPLATE_OTP_VERIFICATION");
if (templateId) {
await this.emailService.sendEmail({
to: email,
subject: "Your verification code",
templateId,
dynamicTemplateData: {
code,
expiresMinutes: "10",
},
});
} else {
await this.emailService.sendEmail({
to: email,
subject: "Your verification code",
html: `
<p>Your verification code is: <strong>${code}</strong></p>
<p>This code expires in 10 minutes.</p>
<p>If you didn't request this code, please ignore this email.</p>
`,
});
}
}
private buildOtpErrorResponse(otpResult: {
reason?: "expired" | "invalid" | "max_attempts";
attemptsRemaining?: number;

View File

@ -1,156 +0,0 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { ErrorCode } from "@customer-portal/domain/common";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { getCustomFieldValue } from "@customer-portal/domain/customer/providers";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import type { User } from "@customer-portal/domain/customer";
import {
PORTAL_SOURCE_MIGRATED,
PORTAL_STATUS_ACTIVE,
} from "@bff/modules/auth/constants/portal.constants.js";
@Injectable()
export class WhmcsLinkWorkflowService {
constructor(
private readonly usersService: UsersService,
private readonly mappingsService: MappingsService,
private readonly whmcsClientService: WhmcsClientService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceFacade,
@Inject(Logger) private readonly logger: Logger
) {}
async linkWhmcsUser(email: string, password: string) {
const existingUser = await this.usersService.findByEmailInternal(email);
if (existingUser) {
if (!existingUser.passwordHash) {
this.logger.log("User exists but has no password - allowing password setup to continue", {
userId: existingUser.id,
});
return {
user: mapPrismaUserToDomain(existingUser),
needsPasswordSet: true,
};
}
throw new ConflictException(
"User already exists in portal and has completed setup. Please use the login page."
);
}
return safeOperation(
async () => {
const clientDetails = await this.discoveryService.findClientByEmail(email);
if (!clientDetails) {
throw new BadRequestException(
"No billing account found with this email address. Please check your email or contact support."
);
}
const clientNumericId = clientDetails.id;
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientNumericId);
if (existingMapping) {
throw new ConflictException("This billing account is already linked. Please sign in.");
}
this.logger.debug("Validating WHMCS credentials");
const validateResult = await this.whmcsClientService.validateLogin(email, password);
this.logger.debug("WHMCS validation successful");
if (!validateResult || !validateResult.userId) {
throw new BadRequestException("Invalid email or password. Please try again.");
}
const customerNumber =
getCustomFieldValue(clientDetails.customfields, "198")?.trim() ??
getCustomFieldValue(clientDetails.customfields, "Customer Number")?.trim();
if (!customerNumber) {
throw new BadRequestException({
code: ErrorCode.ACCOUNT_MAPPING_MISSING,
message: `Customer Number not found in WHMCS custom field 198 for client ${clientNumericId}`,
});
}
this.logger.log("Found Customer Number for WHMCS client", {
whmcsClientId: clientNumericId,
hasCustomerNumber: !!customerNumber,
});
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(customerNumber);
if (!sfAccount) {
throw new BadRequestException({
code: ErrorCode.CUSTOMER_NOT_FOUND,
message: `Salesforce account not found for Customer Number: ${customerNumber}`,
});
}
const createdUser = await this.usersService.create(
{
email,
passwordHash: null,
emailVerified: true,
},
{ includeProfile: false }
);
await this.mappingsService.createMapping({
userId: createdUser.id,
whmcsClientId: clientNumericId,
sfAccountId: sfAccount.id,
});
const prismaUser = await this.usersService.findByIdInternal(createdUser.id);
if (!prismaUser) {
throw new InternalServerErrorException("Failed to load newly linked user");
}
const userProfile: User = mapPrismaUserToDomain(prismaUser);
try {
await this.salesforceService.updateAccountPortalFields(sfAccount.id, {
status: PORTAL_STATUS_ACTIVE,
source: PORTAL_SOURCE_MIGRATED,
lastSignedInAt: new Date(),
});
} catch (error) {
this.logger.warn("Failed to update Salesforce portal flags after WHMCS link", {
accountId: sfAccount.id,
error: extractErrorMessage(error),
});
}
return {
user: userProfile,
needsPasswordSet: true,
};
},
{
criticality: OperationCriticality.CRITICAL,
context: "WHMCS account linking",
logger: this.logger,
rethrow: [
BadRequestException,
ConflictException,
InternalServerErrorException,
UnauthorizedException,
],
fallbackMessage: "Failed to link WHMCS account",
}
);
}
}

View File

@ -1,16 +1,18 @@
import { Module } from "@nestjs/common";
import { OtpService } from "../infra/otp/otp.service.js";
import { OtpEmailService } from "../infra/otp/otp-email.service.js";
import { GetStartedSessionService } from "../infra/otp/get-started-session.service.js";
/**
* OTP Module
*
* Owns OTP generation/verification and get-started session management.
* Both services are exported for use by LoginModule and GetStartedModule.
* Owns OTP generation/verification, OTP email sending,
* and get-started session management.
* Services are exported for use by LoginModule and GetStartedModule.
*/
@Module({
providers: [OtpService, GetStartedSessionService],
exports: [OtpService, GetStartedSessionService],
providers: [OtpService, OtpEmailService, GetStartedSessionService],
exports: [OtpService, OtpEmailService, GetStartedSessionService],
})
export class OtpModule {}

View File

@ -22,7 +22,6 @@ import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js";
import type { UserAuth } from "@customer-portal/domain/customer";
@ -49,14 +48,12 @@ import {
passwordResetRequestSchema,
passwordResetSchema,
setPasswordRequestSchema,
linkWhmcsRequestSchema,
changePasswordRequestSchema,
accountStatusRequestSchema,
ssoLinkRequestSchema,
checkPasswordNeededRequestSchema,
refreshTokenRequestSchema,
checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema,
loginVerifyOtpRequestSchema,
} from "@customer-portal/domain/auth";
@ -70,14 +67,12 @@ export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
class LinkWhmcsRequestDto extends createZodDto(linkWhmcsRequestSchema) {}
class SetPasswordRequestDto extends createZodDto(setPasswordRequestSchema) {}
class CheckPasswordNeededRequestDto extends createZodDto(checkPasswordNeededRequestSchema) {}
class PasswordResetRequestDto extends createZodDto(passwordResetRequestSchema) {}
class ResetPasswordRequestDto extends createZodDto(passwordResetSchema) {}
class ChangePasswordRequestDto extends createZodDto(changePasswordRequestSchema) {}
class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {}
class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {}
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {}
@ -284,16 +279,6 @@ export class AuthController {
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@Public()
@Post("migrate")
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto })
async migrateAccount(@Body() linkData: LinkWhmcsRequestDto) {
const result = await this.authOrchestrator.linkWhmcsUser(linkData);
return result;
}
@Public()
@Post("set-password")
@UseGuards(RateLimitGuard)

View File

@ -20,6 +20,7 @@ function LoginContent() {
const { loading, isAuthenticated } = useAuthStore();
const searchParams = useSearchParams();
const reasonParam = useMemo(() => searchParams?.get("reason"), [searchParams]);
const prefillEmail = useMemo(() => searchParams?.get("email") ?? undefined, [searchParams]);
const [logoutReason, setLogoutReason] = useState<LogoutReason | null>(null);
const [dismissed, setDismissed] = useState(false);
@ -71,7 +72,7 @@ function LoginContent() {
{logoutMessage.body}
</AlertBanner>
)}
<LoginForm />
<LoginForm initialEmail={prefillEmail} />
</AuthLayout>
{/* Full-page loading overlay during authentication */}