From 451d58d4367dd04a4a57874395823cc6405ff8ac Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 3 Mar 2026 17:58:06 +0900 Subject: [PATCH] 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 --- .../security/middleware/csrf.middleware.ts | 1 - .../application/auth-orchestrator.service.ts | 8 - apps/bff/src/modules/auth/auth.module.ts | 3 +- .../auth/get-started/get-started.module.ts | 24 +- apps/bff/src/modules/auth/infra/otp/index.ts | 1 + .../auth/infra/otp/otp-email.service.ts | 55 ++ .../account-creation-workflow.service.ts | 536 ++++++++++++++++++ ... => account-migration-workflow.service.ts} | 15 +- .../get-started-coordinator.service.ts | 21 +- .../workflows/login-otp-workflow.service.ts | 38 +- .../new-customer-signup-workflow.service.ts | 307 ---------- .../sf-completion-workflow.service.ts | 265 --------- .../auth/infra/workflows/signup/index.ts | 12 - .../signup/signup-account-resolver.service.ts | 210 ------- .../signup/signup-validation.service.ts | 321 ----------- .../infra/workflows/signup/signup.types.ts | 51 -- .../steps/create-portal-user.step.ts | 8 +- .../steps/create-whmcs-client.step.ts | 7 +- .../auth/infra/workflows/steps/index.ts | 4 + .../portal-user-creation.service.ts} | 6 +- .../whmcs-cleanup.service.ts} | 63 +- .../verification-workflow.service.ts | 34 +- .../workflows/whmcs-link-workflow.service.ts | 156 ----- apps/bff/src/modules/auth/otp/otp.module.ts | 10 +- .../auth/presentation/http/auth.controller.ts | 15 - .../src/features/auth/views/LoginView.tsx | 3 +- 26 files changed, 656 insertions(+), 1518 deletions(-) create mode 100644 apps/bff/src/modules/auth/infra/otp/otp-email.service.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts rename apps/bff/src/modules/auth/infra/workflows/{whmcs-migration-workflow.service.ts => account-migration-workflow.service.ts} (95%) delete mode 100644 apps/bff/src/modules/auth/infra/workflows/new-customer-signup-workflow.service.ts delete mode 100644 apps/bff/src/modules/auth/infra/workflows/sf-completion-workflow.service.ts delete mode 100644 apps/bff/src/modules/auth/infra/workflows/signup/index.ts delete mode 100644 apps/bff/src/modules/auth/infra/workflows/signup/signup-account-resolver.service.ts delete mode 100644 apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts delete mode 100644 apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts rename apps/bff/src/modules/auth/infra/workflows/{signup/signup-user-creation.service.ts => steps/portal-user-creation.service.ts} (93%) rename apps/bff/src/modules/auth/infra/workflows/{signup/signup-whmcs.service.ts => steps/whmcs-cleanup.service.ts} (69%) delete mode 100644 apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index a8408ebb..03e5ffe3 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -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 diff --git a/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts b/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts index 37b45a2d..f751b945 100644 --- a/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts +++ b/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts @@ -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); } diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 4ede642c..50f0204a 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/auth/get-started/get-started.module.ts b/apps/bff/src/modules/auth/get-started/get-started.module.ts index ae6c2722..5efb7aa9 100644 --- a/apps/bff/src/modules/auth/get-started/get-started.module.ts +++ b/apps/bff/src/modules/auth/get-started/get-started.module.ts @@ -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], }) diff --git a/apps/bff/src/modules/auth/infra/otp/index.ts b/apps/bff/src/modules/auth/infra/otp/index.ts index b9675083..a60f753f 100644 --- a/apps/bff/src/modules/auth/infra/otp/index.ts +++ b/apps/bff/src/modules/auth/infra/otp/index.ts @@ -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"; diff --git a/apps/bff/src/modules/auth/infra/otp/otp-email.service.ts b/apps/bff/src/modules/auth/infra/otp/otp-email.service.ts new file mode 100644 index 00000000..8e3cb394 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/otp/otp-email.service.ts @@ -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 { + const { configKey, subject, fallbackWarning } = OTP_EMAIL_CONFIG[context]; + const templateId = this.config.get(configKey); + + if (templateId) { + await this.emailService.sendEmail({ + to: email, + subject, + templateId, + dynamicTemplateData: { code, expiresMinutes: "10" }, + }); + } else { + await this.emailService.sendEmail({ + to: email, + subject, + html: ` +

Your verification code is: ${code}

+

This code expires in 10 minutes.

+

${fallbackWarning}

+ `, + }); + } + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts new file mode 100644 index 00000000..e06c6b1a --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts @@ -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 { + 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 { + 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 { + 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 | undefined; + }, + withEligibility: boolean + ): Promise { + 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 | 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; + 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; + 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 { + 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 | undefined + ): NonNullable { + const address = requestAddress ?? sessionAddress; + + if (!address || !address.postcode) { + throw new BadRequestException( + "Address information is incomplete. Please ensure postcode is provided." + ); + } + + return address as NonNullable; + } + + 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 { + const appBase = this.config.get("APP_BASE_URL", "http://localhost:3000"); + const templateId = this.config.get("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: ` +

Hi ${firstName},

+

Welcome! Your account has been created successfully.

+

We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.

+ ${eligibilityRequestId ? `

Reference ID: ${eligibilityRequestId}

` : ""} +

Log in to your dashboard: ${appBase}/account

+ `, + }); + } + } catch (emailError) { + this.logger.warn( + { error: extractErrorMessage(emailError), email }, + "Failed to send welcome email (non-critical)" + ); + } + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/account-migration-workflow.service.ts similarity index 95% rename from apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts rename to apps/bff/src/modules/auth/infra/workflows/account-migration-workflow.service.ts index 581bde7e..4b398eac 100644 --- a/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/account-migration-workflow.service.ts @@ -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, diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts index f116a983..e065a179 100644 --- a/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts @@ -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 { - return this.sfCompletion.execute(request); + return this.accountCreation.execute(request) as Promise; } 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 { - return this.whmcsMigration.execute(request); + return this.accountMigration.execute(request); } } diff --git a/apps/bff/src/modules/auth/infra/workflows/login-otp-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/login-otp-workflow.service.ts index c37b8302..05c9b397 100644 --- a/apps/bff/src/modules/auth/infra/workflows/login-otp-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/login-otp-workflow.service.ts @@ -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 { - const templateId = this.config.get("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: ` -

Your login verification code is: ${code}

-

This code expires in 10 minutes.

-

If you didn't attempt to log in, please secure your account immediately.

- `, - }); - } - } - /** * Mask email for display (e.g., j***e@e***.com) * diff --git a/apps/bff/src/modules/auth/infra/workflows/new-customer-signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/new-customer-signup-workflow.service.ts deleted file mode 100644 index b9d443fc..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/new-customer-signup-workflow.service.ts +++ /dev/null @@ -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 { - const appBase = this.config.get("APP_BASE_URL", "http://localhost:3000"); - const templateId = this.config.get("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: ` -

Hi ${firstName},

-

Welcome! Your account has been created successfully.

-

We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.

- ${eligibilityRequestId ? `

Reference ID: ${eligibilityRequestId}

` : ""} -

Log in to your dashboard: ${appBase}/account

- `, - }); - } - } catch (emailError) { - this.logger.warn( - { error: extractErrorMessage(emailError), email }, - "Failed to send welcome email (non-critical)" - ); - } - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/sf-completion-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/sf-completion-workflow.service.ts deleted file mode 100644 index 2ac71d27..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/sf-completion-workflow.service.ts +++ /dev/null @@ -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 { - 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 | undefined; - } - ): Promise { - 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 { - // 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 | undefined - ): NonNullable { - const address = requestAddress ?? sessionAddress; - - if (!address || !address.postcode) { - throw new BadRequestException( - "Address information is incomplete. Please ensure postcode is provided." - ); - } - - return address as NonNullable; - } - - 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 }; - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/index.ts b/apps/bff/src/modules/auth/infra/workflows/signup/index.ts deleted file mode 100644 index 20f232f3..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/signup/index.ts +++ /dev/null @@ -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"; diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup-account-resolver.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup-account-resolver.service.ts deleted file mode 100644 index f6f551fb..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup-account-resolver.service.ts +++ /dev/null @@ -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 { - const normalized = this.normalizeCustomerNumber(sfNumber); - if (!normalized) { - return null; - } - - const cacheKey = this.buildCacheKey(normalized); - const cached = await this.cache.get(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 { - 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, - }; - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts deleted file mode 100644 index 362fc2ba..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts deleted file mode 100644 index 887355d9..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts +++ /dev/null @@ -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; - }; -} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts index 7d969043..5be84afe 100644 --- a/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts @@ -1,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 { - const { userId } = await this.signupUserCreation.createUserWithMapping({ + const { userId } = await this.portalUserCreation.createUserWithMapping({ email: params.email, passwordHash: params.passwordHash, whmcsClientId: params.whmcsClientId, diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts index 7d6f5a69..272b05a4 100644 --- a/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts @@ -1,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 ) {} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/index.ts b/apps/bff/src/modules/auth/infra/workflows/steps/index.ts index b24a1394..41eb43f7 100644 --- a/apps/bff/src/modules/auth/infra/workflows/steps/index.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/index.ts @@ -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"; diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup-user-creation.service.ts b/apps/bff/src/modules/auth/infra/workflows/steps/portal-user-creation.service.ts similarity index 93% rename from apps/bff/src/modules/auth/infra/workflows/signup/signup-user-creation.service.ts rename to apps/bff/src/modules/auth/infra/workflows/steps/portal-user-creation.service.ts index 85767477..359c3536 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup-user-creation.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/portal-user-creation.service.ts @@ -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 ) {} diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup-whmcs.service.ts b/apps/bff/src/modules/auth/infra/workflows/steps/whmcs-cleanup.service.ts similarity index 69% rename from apps/bff/src/modules/auth/infra/workflows/signup/signup-whmcs.service.ts rename to apps/bff/src/modules/auth/infra/workflows/steps/whmcs-cleanup.service.ts index dca2635b..9c427caf 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup-whmcs.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/whmcs-cleanup.service.ts @@ -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 { - 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"); - } - } } diff --git a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts index 416cc337..90c1b9f0 100644 --- a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts @@ -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 { - const templateId = this.config.get("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: ` -

Your verification code is: ${code}

-

This code expires in 10 minutes.

-

If you didn't request this code, please ignore this email.

- `, - }); - } - } - private buildOtpErrorResponse(otpResult: { reason?: "expired" | "invalid" | "max_attempts"; attemptsRemaining?: number; diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts deleted file mode 100644 index 001268a5..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts +++ /dev/null @@ -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", - } - ); - } -} diff --git a/apps/bff/src/modules/auth/otp/otp.module.ts b/apps/bff/src/modules/auth/otp/otp.module.ts index 76812c83..9db701aa 100644 --- a/apps/bff/src/modules/auth/otp/otp.module.ts +++ b/apps/bff/src/modules/auth/otp/otp.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index ae51e6b0..a20f4cb9 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -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) diff --git a/apps/portal/src/features/auth/views/LoginView.tsx b/apps/portal/src/features/auth/views/LoginView.tsx index dc1cf7f4..05cc98ae 100644 --- a/apps/portal/src/features/auth/views/LoginView.tsx +++ b/apps/portal/src/features/auth/views/LoginView.tsx @@ -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(null); const [dismissed, setDismissed] = useState(false); @@ -71,7 +72,7 @@ function LoginContent() { {logoutMessage.body} )} - + {/* Full-page loading overlay during authentication */}