From 9941250cb5bcae7f613894e8d7cdc1dfb106c6b6 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 24 Feb 2026 14:37:23 +0900 Subject: [PATCH] refactor: streamline authentication workflows and remove legacy services - Replace SignupWorkflowService and GetStartedWorkflowService with new coordinator services for improved modularity and clarity. - Update auth controller to utilize the new GetStartedCoordinator. - Refactor account status handling in the GetStartedForm component to leverage XState for state management. - Introduce new hooks for managing the get-started flow, enhancing the overall user experience. - Remove deprecated services and clean up related imports to maintain code hygiene. --- .../whmcs-account-discovery.service.ts | 25 +- .../application/auth-orchestrator.service.ts | 20 - apps/bff/src/modules/auth/auth.module.ts | 33 +- .../get-started-coordinator.service.ts | 74 + .../workflows/get-started-workflow.service.ts | 1301 ----------------- .../guest-eligibility-workflow.service.ts | 168 +++ .../new-customer-signup-workflow.service.ts | 309 ++++ .../sf-completion-workflow.service.ts | 246 ++++ .../workflows/signup-workflow.service.ts | 251 ---- .../steps/create-eligibility-case.step.ts | 74 + .../steps/create-portal-user.step.ts | 63 + .../steps/create-whmcs-client.step.ts | 74 + .../steps/generate-auth-result.step.ts | 60 + .../auth/infra/workflows/steps/index.ts | 27 + .../steps/resolve-salesforce-account.step.ts | 81 + .../steps/update-salesforce-flags.step.ts | 52 + .../verification-workflow.service.ts | 277 ++++ .../whmcs-migration-workflow.service.ts | 241 +++ .../infra/workflows/workflow-error.util.ts | 68 + .../auth/presentation/http/auth.controller.ts | 16 - .../http/get-started.controller.ts | 10 +- apps/portal/package.json | 2 + .../GetStartedForm/GetStartedForm.tsx | 110 +- .../steps/AccountStatusStep.tsx | 249 +--- .../steps/CompleteAccountStep.tsx | 44 +- .../GetStartedForm/steps/EmailStep.tsx | 16 +- .../steps/MigrateAccountStep.tsx | 49 +- .../GetStartedForm/steps/SuccessStep.tsx | 14 +- .../GetStartedForm/steps/VerificationStep.tsx | 40 +- .../InlineGetStartedSection.tsx | 112 +- .../get-started/hooks/useGetStartedMachine.ts | 65 + apps/portal/src/features/get-started/index.ts | 12 +- .../machines/get-started.actors.ts | 85 ++ .../machines/get-started.machine.ts | 462 ++++++ .../get-started/machines/get-started.types.ts | 137 ++ .../get-started/stores/get-started.store.ts | 396 ----- .../get-started/views/GetStartedView.tsx | 86 +- .../common/providers/whmcs-utils/coerce.ts | 24 + pnpm-lock.yaml | 71 +- 39 files changed, 3000 insertions(+), 2444 deletions(-) create mode 100644 apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts delete mode 100644 apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/guest-eligibility-workflow.service.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/new-customer-signup-workflow.service.ts create 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-workflow.service.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/steps/create-eligibility-case.step.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/steps/index.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/steps/resolve-salesforce-account.step.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/steps/update-salesforce-flags.step.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/workflow-error.util.ts create mode 100644 apps/portal/src/features/get-started/hooks/useGetStartedMachine.ts create mode 100644 apps/portal/src/features/get-started/machines/get-started.actors.ts create mode 100644 apps/portal/src/features/get-started/machines/get-started.machine.ts create mode 100644 apps/portal/src/features/get-started/machines/get-started.types.ts delete mode 100644 apps/portal/src/features/get-started/stores/get-started.store.ts create mode 100644 packages/domain/common/providers/whmcs-utils/coerce.ts diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts index 7ad0d504..a32158dc 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts @@ -57,23 +57,24 @@ export class WhmcsAccountDiscoveryService { this.logger.log(`Discovered client by email: ${email}`); return client; } catch (error) { - // Handle "Not Found" specifically + // Handle "Not Found" specifically — this is expected for discovery if ( error instanceof NotFoundException || - (error instanceof Error && error.message.includes("not found")) + (error instanceof Error && error.message.toLowerCase().includes("not found")) ) { return null; } - // Log other errors but don't crash - return null to indicate lookup failed safely - this.logger.warn( + // Re-throw all other errors (auth failures, network issues, timeouts, etc.) + // to avoid silently masking problems like 403 permission errors + this.logger.error( { email, error: extractErrorMessage(error), }, "Failed to discover client by email" ); - return null; + throw error; } } @@ -119,14 +120,24 @@ export class WhmcsAccountDiscoveryService { clientId: Number(clientAssociation.id), }; } catch (error) { - this.logger.warn( + // Handle "Not Found" specifically — this is expected for discovery + if ( + error instanceof NotFoundException || + (error instanceof Error && error.message.toLowerCase().includes("not found")) + ) { + return null; + } + + // Re-throw all other errors (auth failures, network issues, timeouts, etc.) + // to avoid silently masking problems like 403 permission errors + this.logger.error( { email, error: extractErrorMessage(error), }, "Failed to discover user by email" ); - return null; + throw error; } } 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 22b65bd5..37b45a2d 100644 --- a/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts +++ b/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts @@ -9,8 +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 SignupRequest, - type ValidateSignupRequest, type LinkWhmcsRequest, type SetPasswordRequest, type ChangePasswordRequest, @@ -22,7 +20,6 @@ import type { Request } from "express"; import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js"; import { AuthTokenService } from "../infra/token/token.service.js"; import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service.js"; -import { SignupWorkflowService } from "../infra/workflows/signup-workflow.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 @@ -36,7 +33,6 @@ import { AuthLoginService } from "./auth-login.service.js"; * Delegates to specialized services for specific functionality: * - AuthHealthService: Health checks * - AuthLoginService: Login validation - * - SignupWorkflowService: Signup flow * - PasswordWorkflowService: Password operations * - WhmcsLinkWorkflowService: WHMCS account linking */ @@ -50,7 +46,6 @@ export class AuthOrchestrator { private readonly salesforceService: SalesforceFacade, private readonly auditService: AuditService, private readonly tokenBlacklistService: TokenBlacklistService, - private readonly signupWorkflow: SignupWorkflowService, private readonly passwordWorkflow: PasswordWorkflowService, private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService, private readonly tokenService: AuthTokenService, @@ -64,14 +59,6 @@ export class AuthOrchestrator { return this.healthService.check(); } - async validateSignup(validateData: ValidateSignupRequest, request?: Request) { - return this.signupWorkflow.validateSignup(validateData, request); - } - - async signup(signupData: SignupRequest, request?: Request) { - return this.signupWorkflow.signup(signupData, request); - } - /** * Original login method - validates credentials and completes login * Used by LocalAuthGuard flow @@ -313,11 +300,4 @@ export class AuthOrchestrator { tokens, }; } - - /** - * Preflight validation for signup - */ - async signupPreflight(signupData: SignupRequest) { - return this.signupWorkflow.signupPreflight(signupData); - } } diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index e7475847..64a1527a 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -16,7 +16,6 @@ import { PasswordResetTokenService } from "./infra/token/password-reset-token.se import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { AuthTokenService } from "./infra/token/token.service.js"; import { JoseJwtService } from "./infra/token/jose-jwt.service.js"; -import { SignupWorkflowService } from "./infra/workflows/signup-workflow.service.js"; import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js"; import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js"; import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard.js"; @@ -29,7 +28,20 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user- // Get Started flow import { OtpService } from "./infra/otp/otp.service.js"; import { GetStartedSessionService } from "./infra/otp/get-started-session.service.js"; -import { GetStartedWorkflowService } from "./infra/workflows/get-started-workflow.service.js"; +import { GetStartedCoordinator } from "./infra/workflows/get-started-coordinator.service.js"; +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"; +import { + ResolveSalesforceAccountStep, + CreateWhmcsClientStep, + CreatePortalUserStep, + UpdateSalesforceFlagsStep, + GenerateAuthResultStep, + CreateEligibilityCaseStep, +} from "./infra/workflows/steps/index.js"; import { GetStartedController } from "./presentation/http/get-started.controller.js"; import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; // Login OTP flow @@ -53,8 +65,7 @@ import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.serv AuthTokenService, JoseJwtService, PasswordResetTokenService, - // Signup workflow services - SignupWorkflowService, + // Signup shared services (reused by get-started workflows) SignupAccountResolverService, SignupValidationService, SignupWhmcsService, @@ -65,7 +76,19 @@ import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.serv // Get Started flow services OtpService, GetStartedSessionService, - GetStartedWorkflowService, + GetStartedCoordinator, + VerificationWorkflowService, + GuestEligibilityWorkflowService, + NewCustomerSignupWorkflowService, + SfCompletionWorkflowService, + WhmcsMigrationWorkflowService, + // Shared step services + ResolveSalesforceAccountStep, + CreateWhmcsClientStep, + CreatePortalUserStep, + UpdateSalesforceFlagsStep, + GenerateAuthResultStep, + CreateEligibilityCaseStep, // Login OTP flow services LoginSessionService, LoginOtpWorkflowService, 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 new file mode 100644 index 00000000..f116a983 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/get-started-coordinator.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from "@nestjs/common"; + +import type { + SendVerificationCodeRequest, + SendVerificationCodeResponse, + VerifyCodeRequest, + VerifyCodeResponse, + GuestEligibilityRequest, + GuestEligibilityResponse, + CompleteAccountRequest, + SignupWithEligibilityRequest, + MigrateWhmcsAccountRequest, +} from "@customer-portal/domain/get-started"; + +import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; + +import { VerificationWorkflowService } from "./verification-workflow.service.js"; +import { GuestEligibilityWorkflowService } from "./guest-eligibility-workflow.service.js"; +import { NewCustomerSignupWorkflowService } from "./new-customer-signup-workflow.service.js"; +import { SfCompletionWorkflowService } from "./sf-completion-workflow.service.js"; +import { WhmcsMigrationWorkflowService } from "./whmcs-migration-workflow.service.js"; + +/** + * Get Started Coordinator + * + * Thin routing layer that delegates to focused workflow services. + * Method signatures match the previous god class so the controller + * requires minimal changes. + */ +@Injectable() +export class GetStartedCoordinator { + constructor( + private readonly verification: VerificationWorkflowService, + private readonly guestEligibility: GuestEligibilityWorkflowService, + private readonly newCustomerSignup: NewCustomerSignupWorkflowService, + private readonly sfCompletion: SfCompletionWorkflowService, + private readonly whmcsMigration: WhmcsMigrationWorkflowService + ) {} + + async sendVerificationCode( + request: SendVerificationCodeRequest, + fingerprint?: string + ): Promise { + return this.verification.sendCode(request, fingerprint); + } + + async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise { + return this.verification.verifyCode(request, fingerprint); + } + + async guestEligibilityCheck( + request: GuestEligibilityRequest, + fingerprint?: string + ): Promise { + return this.guestEligibility.execute(request, fingerprint); + } + + async completeAccount(request: CompleteAccountRequest): Promise { + return this.sfCompletion.execute(request); + } + + async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{ + success: boolean; + message?: string; + eligibilityRequestId?: string; + authResult?: AuthResultInternal; + }> { + return this.newCustomerSignup.execute(request); + } + + async migrateWhmcsAccount(request: MigrateWhmcsAccountRequest): Promise { + return this.whmcsMigration.execute(request); + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts deleted file mode 100644 index c492fd00..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts +++ /dev/null @@ -1,1301 +0,0 @@ -import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Logger } from "nestjs-pino"; -import * as argon2 from "argon2"; - -import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; - -import { - ACCOUNT_STATUS, - type AccountStatus, - type SendVerificationCodeRequest, - type SendVerificationCodeResponse, - type VerifyCodeRequest, - type VerifyCodeResponse, - type BilingualEligibilityAddress, - type GuestEligibilityRequest, - type GuestEligibilityResponse, - type CompleteAccountRequest, - type SignupWithEligibilityRequest, - type MigrateWhmcsAccountRequest, -} from "@customer-portal/domain/get-started"; -import { - getCustomFieldValue, - serializeWhmcsKeyValueMap, -} from "@customer-portal/domain/customer/providers"; - -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 { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; -import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; -import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; -import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.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 { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; -import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; - -import { OtpService } from "../otp/otp.service.js"; -import { GetStartedSessionService } from "../otp/get-started-session.service.js"; -import { AuthTokenService } from "../token/token.service.js"; -import { SignupWhmcsService } from "./signup/signup-whmcs.service.js"; -import { SignupUserCreationService } from "./signup/signup-user-creation.service.js"; -import { - PORTAL_SOURCE_NEW_SIGNUP, - PORTAL_SOURCE_INTERNET_ELIGIBILITY, - PORTAL_SOURCE_MIGRATED, - PORTAL_STATUS_ACTIVE, - PORTAL_STATUS_NOT_YET, -} from "@bff/modules/auth/constants/portal.constants.js"; -import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; - -/** Error message for failed user load - used in multiple places */ -const ERROR_FAILED_TO_LOAD_USER = "Failed to load created user"; - -/** - * Remove undefined properties from an object (for exactOptionalPropertyTypes compatibility) - */ -function removeUndefined>(obj: T): Partial { - return Object.fromEntries( - Object.entries(obj).filter(([, value]) => value !== undefined) - ) as Partial; -} - -/** - * Get Started Workflow Service - * - * Orchestrates the unified "Get Started" flow: - * 1. Email verification via OTP - * 2. Account status detection (Portal, WHMCS, SF) - * 3. Guest eligibility check (creates SF Account + Case without OTP) - * 4. Account completion for SF-only users - * 5. Full signup with eligibility (SF + Case + WHMCS + Portal) - */ -@Injectable() -export class GetStartedWorkflowService { - // eslint-disable-next-line max-params -- NestJS DI requires individual constructor parameters - constructor( - private readonly config: ConfigService, - private readonly otpService: OtpService, - private readonly sessionService: GetStartedSessionService, - private readonly emailService: EmailService, - private readonly usersService: UsersService, - private readonly mappingsService: MappingsService, - private readonly auditService: AuditService, - private readonly salesforceAccountService: SalesforceAccountService, - private readonly salesforceService: SalesforceFacade, - private readonly opportunityResolution: OpportunityResolutionService, - private readonly workflowCases: WorkflowCaseManager, - private readonly whmcsDiscovery: WhmcsAccountDiscoveryService, - private readonly whmcsClientService: WhmcsClientService, - private readonly whmcsSignup: SignupWhmcsService, - private readonly userCreation: SignupUserCreationService, - private readonly tokenService: AuthTokenService, - private readonly lockService: DistributedLockService, - @Inject(Logger) private readonly logger: Logger - ) {} - - // ============================================================================ - // Email Verification - // ============================================================================ - - /** - * Send OTP verification code to email - * - * @param request - The request containing the email - * @param fingerprint - Optional request fingerprint for session binding - */ - async sendVerificationCode( - request: SendVerificationCodeRequest, - fingerprint?: string - ): Promise { - const { email } = request; - const normalizedEmail = email.toLowerCase().trim(); - - try { - // Generate OTP and store in Redis (with fingerprint for binding) - const code = await this.otpService.generateAndStore(normalizedEmail, fingerprint); - - // Create session for this verification flow (token returned in verifyCode) - await this.sessionService.create(normalizedEmail); - - // Send email with OTP code - await this.sendOtpEmail(normalizedEmail, code); - - this.logger.log({ email: normalizedEmail }, "OTP verification code sent"); - - return { - sent: true, - message: "Verification code sent to your email", - }; - } catch (error) { - this.logger.error( - { error: extractErrorMessage(error), email: normalizedEmail }, - "Failed to send verification code" - ); - - return { - sent: false, - message: "Failed to send verification code. Please try again.", - }; - } - } - - /** - * Verify OTP code and determine account status - * - * @param request - The request containing email and code - * @param fingerprint - Optional request fingerprint for session binding check - */ - async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise { - const { email, code, handoffToken } = request; - const normalizedEmail = email.toLowerCase().trim(); - - // Verify OTP (with fingerprint check - logs warning if different context) - const otpResult = await this.otpService.verify(normalizedEmail, code, fingerprint); - - if (!otpResult.valid) { - return this.buildOtpErrorResponse(otpResult); - } - - // Create verified session - const sessionToken = await this.sessionService.create(normalizedEmail); - - // Check account status across all systems - const accountStatus = await this.determineAccountStatus(normalizedEmail); - - // Get prefill data (including handoff token data if provided) - const prefill = await this.resolvePrefillData(normalizedEmail, accountStatus, handoffToken); - - // Update session with verified status and account info - const prefillData = this.buildSessionPrefillData(prefill, accountStatus); - await this.sessionService.markEmailVerified( - sessionToken, - accountStatus.status, - Object.keys(prefillData).length > 0 ? prefillData : undefined - ); - - this.logger.log( - { email: normalizedEmail, accountStatus: accountStatus.status, hasHandoff: !!handoffToken }, - "Email verified and account status determined" - ); - - return { - verified: true, - sessionToken, - accountStatus: accountStatus.status, - prefill, - }; - } - - // ============================================================================ - // Guest Eligibility Check (No OTP Required) - // ============================================================================ - - /** - * Guest eligibility check - NO email verification required - * - * Allows users to check availability without verifying email first. - * Creates SF Account + eligibility case immediately. - * Email verification happens later when user creates an account. - * - * Security: - * - Email-level lock prevents concurrent requests creating duplicate SF accounts - * - * @param request - Guest eligibility request with name, email, address - * @param fingerprint - Request fingerprint for logging/abuse detection - */ - async guestEligibilityCheck( - request: GuestEligibilityRequest, - fingerprint?: string - ): Promise { - const { email, firstName, lastName, address, phone, continueToAccount } = request; - const normalizedEmail = email.toLowerCase().trim(); - - this.logger.log( - { email: normalizedEmail, continueToAccount, fingerprint }, - "Guest eligibility check initiated" - ); - - // Email-level lock to prevent concurrent requests for the same email - const lockKey = `guest-eligibility:${normalizedEmail}`; - - try { - return await this.lockService.withLock( - lockKey, - async () => { - // Check if SF account already exists for this email - let sfAccountId: string; - - const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); - - if (existingSf) { - sfAccountId = existingSf.id; - this.logger.log( - { email: normalizedEmail, sfAccountId }, - "Using existing SF account for guest eligibility check" - ); - - // Set portal source for existing accounts going through eligibility - // This ensures we can track that they came through internet eligibility - await this.salesforceService.updateAccountPortalFields(sfAccountId, { - status: PORTAL_STATUS_NOT_YET, - source: PORTAL_SOURCE_INTERNET_ELIGIBILITY, - }); - } else { - // Create new SF Account with portal fields set to "Not Yet" + "Internet Eligibility" - const { accountId } = await this.salesforceAccountService.createAccount({ - firstName, - lastName, - email: normalizedEmail, - phone: phone ?? "", - portalSource: PORTAL_SOURCE_INTERNET_ELIGIBILITY, - }); - sfAccountId = accountId; - this.logger.log( - { email: normalizedEmail, sfAccountId }, - "Created SF account for guest eligibility check" - ); - } - - // Save Japanese address to SF Contact (if Japanese address fields provided) - if (address.prefectureJa || address.cityJa || address.townJa) { - await this.salesforceAccountService.updateContactAddress(sfAccountId, { - mailingStreet: `${address.townJa || ""}${address.streetAddress || ""}`.trim(), - mailingCity: address.cityJa || address.city, - mailingState: address.prefectureJa || address.state, - mailingPostalCode: address.postcode, - mailingCountry: "Japan", - buildingName: address.buildingName ?? null, - roomNumber: address.roomNumber ?? null, - }); - this.logger.debug({ sfAccountId }, "Updated SF Contact with Japanese address"); - } - - // Create eligibility case - const { caseId } = await this.createEligibilityCase(sfAccountId, address); - - // Update Account eligibility status to Pending - this.updateAccountEligibilityStatus(sfAccountId); - - // If user wants to continue to account creation, generate a handoff token - let handoffToken: string | undefined; - if (continueToAccount) { - handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, { - firstName, - lastName, - address: removeUndefined(address), - ...(phone && { phone }), - sfAccountId, - }); - this.logger.debug( - { email: normalizedEmail, handoffToken }, - "Created handoff token for account creation" - ); - } - - return { - submitted: true, - requestId: caseId, - sfAccountId, - handoffToken, - message: "Eligibility check submitted. We'll notify you of the results.", - }; - }, - { ttlMs: 30_000 } // 30 second lock timeout - ); - } catch (error) { - this.logger.error( - { error: extractErrorMessage(error), email: normalizedEmail }, - "Guest eligibility check failed" - ); - - return { - submitted: false, - message: "Failed to submit eligibility check. Please try again.", - }; - } - } - - // ============================================================================ - // Account Completion (SF-Only Users) - // ============================================================================ - - /** - * Complete account for users with SF account but no WHMCS/Portal - * Creates WHMCS client and Portal user, links to existing SF account - * - * Security: - * - Session is locked to prevent double submissions - * - Email-level lock prevents concurrent account creation - */ - async completeAccount(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.validateCompleteAccountRequest(request, session); - - const lockKey = `complete-account:${session.email}`; - - try { - return await this.lockService.withLock( - lockKey, - async () => this.executeCompleteAccount(request, session), - { ttlMs: 60_000 } - ); - } catch (error) { - this.logger.error( - { error: extractErrorMessage(error), email: session.email }, - "Account completion failed" - ); - throw error; - } - } - - /** - * Validate complete account request - */ - private validateCompleteAccountRequest( - 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."); - } - } - - /** - * Execute the account completion within the lock - */ - private async executeCompleteAccount( - 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.ensureNoExistingAccountsForCompletion(session.email); - - // Resolve SF account - const { sfAccountId, customerNumber } = await this.resolveSalesforceAccount({ - email: session.email, - existingAccountId: session.sfAccountId, - firstName, - lastName, - phone, - portalSource: PORTAL_SOURCE_NEW_SIGNUP, - }); - - // Validate and resolve address/name - const address = this.validateAndResolveAddress(requestAddress, session.address); - const { finalFirstName, finalLastName } = this.resolveNames(firstName, lastName, session); - - // Create WHMCS client - const whmcsClient = await this.whmcsSignup.createClient({ - firstName: finalFirstName, - lastName: finalLastName, - email: session.email, - password, - phone, - address: { - address1: address.address1, - ...(address.address2 && { address2: address.address2 }), - city: address.city, - state: address.state, - postcode: address.postcode, - country: address.country ?? "Japan", - }, - customerNumber: customerNumber ?? null, - dateOfBirth, - gender, - }); - - // Create user and get auth result - const passwordHash = await argon2.hash(password); - const authResult = await this.createUserWithAuthResult({ - email: session.email, - passwordHash, - whmcsClientId: whmcsClient.clientId, - sfAccountId, - auditSource: isNewCustomer ? "get_started_new_customer" : "get_started_complete_account", - }); - - // Finalize - await this.updateSalesforcePortalFlags(sfAccountId, whmcsClient.clientId); - await this.sessionService.invalidate(request.sessionToken); - - this.logger.log( - { email: session.email, userId: authResult.user.id, isNewCustomer }, - "Account completed successfully" - ); - - return authResult; - } - - /** - * Ensure no existing Portal or WHMCS accounts exist (throws on conflict) - */ - private async ensureNoExistingAccountsForCompletion(email: string): Promise { - const { portalExists, whmcsExists } = await this.checkExistingAccounts(email); - - if (whmcsExists) { - throw new ConflictException( - "A billing account already exists. Please use the account migration flow." - ); - } - if (portalExists) { - throw new ConflictException("An account already exists. Please log in."); - } - } - - /** - * Validate and resolve address from request or session - */ - private validateAndResolveAddress( - requestAddress: CompleteAccountRequest["address"] | undefined, - sessionAddress: Record | undefined - ): NonNullable { - const address = requestAddress ?? sessionAddress; - - if (!address || !address.address1 || !address.city || !address.state || !address.postcode) { - throw new BadRequestException( - "Address information is incomplete. Please ensure all required fields (address, city, prefecture, postcode) are provided." - ); - } - - // After validation, we know all required fields are present - return address as NonNullable; - } - - /** - * Resolve names from request or session - */ - 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 }; - } - - // ============================================================================ - // Full Signup with Eligibility (Inline Flow) - // ============================================================================ - - /** - * Full signup with eligibility check - creates everything in one operation - * - * This is the primary signup path from the eligibility check page. - * Creates SF Account + Case + WHMCS + Portal after email verification. - * - * Security: - * - Session is locked to prevent double submissions (race condition protection) - * - Email-level lock prevents concurrent signups for the same email - * - Session is invalidated on success, cleared on partial failure for retry - * - * @param request - Signup request with all required data - */ - async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{ - success: boolean; - message?: 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.executeSignupWithEligibility(request, normalizedEmail), - { ttlMs: 60_000 } - ); - } catch (error) { - this.logger.error( - { error: extractErrorMessage(error), email: normalizedEmail }, - "Signup with eligibility failed" - ); - return { - success: false, - message: "Account creation failed. Please verify your email again to retry.", - }; - } - } - - /** - * Execute signup with eligibility within the lock - */ - private async executeSignupWithEligibility( - request: SignupWithEligibilityRequest, - email: string - ): Promise<{ - success: boolean; - message?: string; - eligibilityRequestId?: string; - authResult?: AuthResultInternal; - }> { - const { firstName, lastName, address, phone, password, dateOfBirth, gender } = request; - - // Check for existing accounts - const existingCheck = await this.checkExistingAccountsForSignup(email); - if (existingCheck) { - return existingCheck; - } - - // Resolve SF account - const { sfAccountId, customerNumber } = await this.resolveSalesforceAccount({ - email, - firstName, - lastName, - phone, - portalSource: PORTAL_SOURCE_INTERNET_ELIGIBILITY, - updateSourceIfExists: true, - }); - - // Create eligibility case - const { caseId: eligibilityRequestId } = await this.createEligibilityCase(sfAccountId, address); - - // Create WHMCS client - const whmcsClient = await this.whmcsSignup.createClient({ - firstName, - lastName, - email, - password, - phone, - address: { - address1: address.address1, - ...(address.address2 && { address2: address.address2 }), - city: address.city, - state: address.state ?? "", - postcode: address.postcode, - country: address.country ?? "Japan", - }, - customerNumber: customerNumber ?? null, - dateOfBirth, - gender, - }); - - // Create user and get auth result - const passwordHash = await argon2.hash(password); - const authResult = await this.createUserWithAuthResult({ - email, - passwordHash, - whmcsClientId: whmcsClient.clientId, - sfAccountId, - auditSource: "signup_with_eligibility", - }); - - // Finalize - await this.updateSalesforcePortalFlags(sfAccountId, whmcsClient.clientId); - await this.sessionService.invalidate(request.sessionToken); - await this.sendWelcomeWithEligibilityEmail(email, firstName, eligibilityRequestId); - - this.logger.log( - { email, userId: authResult.user.id, eligibilityRequestId }, - "Signup with eligibility completed successfully" - ); - - return { - success: true, - eligibilityRequestId, - authResult, - }; - } - - /** - * Check for existing accounts and return error response if found - */ - private async checkExistingAccountsForSignup( - email: string - ): Promise<{ success: false; message: string } | null> { - const { portalExists, whmcsExists } = await this.checkExistingAccounts(email); - - if (portalExists) { - return { - success: false, - message: "An account already exists with this email. Please log in.", - }; - } - - if (whmcsExists) { - return { - success: false, - message: - "A billing account already exists with this email. Please use account linking instead.", - }; - } - - return null; - } - - // ============================================================================ - // WHMCS Migration (Passwordless) - // ============================================================================ - - /** - * Migrate WHMCS account to portal without legacy password validation - * - * For whmcs_unmapped users after email verification. - * Email verification serves as identity proof - no legacy password needed. - * - * Flow: - * 1. Validate session has WHMCS client ID - * 2. Verify WHMCS client still exists - * 3. Find Salesforce account (by email or customer number) - * 4. Update WHMCS client with new password (DOB/gender only if provided) - * 5. Create portal user with hashed password - * 6. Create ID mapping - * 7. Update Salesforce portal flags - * 8. Return auth tokens - * - * Security: - * - Session is locked to prevent double submissions - * - Email-level lock prevents concurrent migrations - */ - async migrateWhmcsAccount(request: MigrateWhmcsAccountRequest): Promise { - // Atomically acquire session lock and mark as used - const sessionResult = await this.sessionService.acquireAndMarkAsUsed( - request.sessionToken, - "migrate_whmcs_account" - ); - - if (!sessionResult.success) { - throw new BadRequestException(sessionResult.reason); - } - - const session = sessionResult.session; - - // Verify session has WHMCS client ID (should be set during verifyCode for whmcs_unmapped) - if (!session.whmcsClientId) { - throw new BadRequestException( - "No WHMCS account found in session. Please verify your email again." - ); - } - - const { password, dateOfBirth, gender } = request; - const lockKey = `migrate-whmcs:${session.email}`; - - try { - return await this.lockService.withLock( - lockKey, - async () => { - // Verify WHMCS client still exists and matches session - const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(session.email); - if (!whmcsClient || whmcsClient.id !== session.whmcsClientId) { - throw new BadRequestException("WHMCS account verification failed. Please start over."); - } - - // Check for existing portal user - const existingPortalUser = await this.usersService.findByEmailInternal(session.email); - if (existingPortalUser) { - throw new ConflictException("An account already exists. Please log in."); - } - - // Find Salesforce account for mapping (required for ID mapping) - const sfAccount = await this.findSalesforceAccountForMigration( - session.email, - whmcsClient.id - ); - - if (!sfAccount) { - throw new BadRequestException( - "Unable to find your Salesforce account. Please contact support." - ); - } - - // Hash password for portal storage - const passwordHash = await argon2.hash(password); - - // Update WHMCS client with new password (DOB/gender only if provided) - await this.updateWhmcsClientForMigration(whmcsClient.id, password, dateOfBirth, gender); - - // Create portal user and ID mapping - const { userId } = await this.userCreation.createUserWithMapping({ - email: session.email, - passwordHash, - whmcsClientId: whmcsClient.id, - sfAccountId: sfAccount.id, - }); - - // Fetch fresh user and generate tokens - const freshUser = await this.usersService.findByIdInternal(userId); - if (!freshUser) { - throw new Error(ERROR_FAILED_TO_LOAD_USER); - } - - await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { - email: session.email, - whmcsClientId: whmcsClient.id, - source: "whmcs_migration", - }); - - const profile = mapPrismaUserToDomain(freshUser); - const tokens = await this.tokenService.generateTokenPair({ - id: profile.id, - email: profile.email, - }); - - // Update Salesforce portal flags if SF account exists - if (sfAccount) { - await this.updateSalesforcePortalFlags( - sfAccount.id, - whmcsClient.id, - PORTAL_SOURCE_MIGRATED - ); - } - - // Invalidate session - await this.sessionService.invalidate(request.sessionToken); - - this.logger.log( - { email: session.email, userId, whmcsClientId: whmcsClient.id }, - "WHMCS account migrated successfully" - ); - - return { - user: profile, - tokens, - }; - }, - { ttlMs: 60_000 } - ); - } catch (error) { - this.logger.error( - { error: extractErrorMessage(error), email: session.email }, - "WHMCS migration failed" - ); - throw error; - } - } - - /** - * Find Salesforce account for WHMCS migration - * Tries by email first, then by customer number from WHMCS - */ - private async findSalesforceAccountForMigration( - email: string, - whmcsClientId: number - ): Promise<{ id: string } | null> { - try { - // First try to find SF account by email - const sfAccount = await this.salesforceAccountService.findByEmail(email); - if (sfAccount) { - return { id: sfAccount.id }; - } - - // If no SF account found by email, try by customer number from WHMCS - const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId); - - const customerNumber = - getCustomFieldValue(whmcsClient.customfields, "198")?.trim() ?? - getCustomFieldValue(whmcsClient.customfields, "Customer Number")?.trim(); - - if (!customerNumber) { - this.logger.warn( - { whmcsClientId, email }, - "No customer number found in WHMCS for SF lookup" - ); - return null; - } - - const sfAccountByNumber = - await this.salesforceService.findAccountByCustomerNumber(customerNumber); - if (sfAccountByNumber) { - return { id: sfAccountByNumber.id }; - } - - this.logger.warn( - { whmcsClientId, email, customerNumber }, - "No Salesforce account found for WHMCS migration" - ); - return null; - } catch (error) { - this.logger.warn( - { error: extractErrorMessage(error), email, whmcsClientId }, - "Failed to find Salesforce account for migration" - ); - return null; - } - } - - /** - * Update WHMCS client with new password and profile data - */ - private async updateWhmcsClientForMigration( - clientId: number, - password: string, - dateOfBirth?: string, - gender?: string - ): Promise { - const dobFieldId = this.config.get("WHMCS_DOB_FIELD_ID"); - const genderFieldId = this.config.get("WHMCS_GENDER_FIELD_ID"); - - const customfieldsMap: Record = {}; - if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth; - if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender; - - const updateData: Record = { - password2: password, - }; - - if (Object.keys(customfieldsMap).length > 0) { - updateData["customfields"] = serializeWhmcsKeyValueMap(customfieldsMap); - } - - await this.whmcsClientService.updateClient(clientId, updateData); - - this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data"); - } - - // ============================================================================ - // 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 { - // Fallback to plain HTML - 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 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"); - - if (templateId) { - await this.emailService.sendEmail({ - to: email, - subject: "Welcome! Your account is ready", - templateId, - dynamicTemplateData: { - firstName, - portalUrl: appBase, - dashboardUrl: `${appBase}/account/dashboard`, - 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.

-

Reference ID: ${eligibilityRequestId}

-

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

- `, - }); - } - } - - private async determineAccountStatus( - email: string - ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { - // Check Portal user first - const portalUser = await this.usersService.findByEmailInternal(email); - if (portalUser) { - const hasMapping = await this.mappingsService.hasMapping(portalUser.id); - if (hasMapping) { - return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; - } - } - - // Check WHMCS client or user (sub-account) - const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(email); - if (whmcsClient) { - // Check if WHMCS is already mapped - const mapping = await this.mappingsService.findByWhmcsClientId(whmcsClient.id); - if (mapping) { - return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; - } - return { status: ACCOUNT_STATUS.WHMCS_UNMAPPED, whmcsClientId: whmcsClient.id }; - } - - // Check Salesforce account - const sfAccount = await this.salesforceAccountService.findByEmail(email); - if (sfAccount) { - // Check if SF is already mapped - const mapping = await this.mappingsService.findBySfAccountId(sfAccount.id); - if (mapping) { - return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; - } - return { status: ACCOUNT_STATUS.SF_UNMAPPED, sfAccountId: sfAccount.id }; - } - - // No account exists - return { status: ACCOUNT_STATUS.NEW_CUSTOMER }; - } - - private getPrefillData( - email: string, - accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number } - ): VerifyCodeResponse["prefill"] { - // For SF-only accounts, we could query SF for the stored address/name - // For now, return minimal data - frontend will handle input - if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) { - // TODO: Query SF for stored account details - return { - email, - }; - } - - return undefined; - } - - /** - * Build error response for invalid OTP - */ - private buildOtpErrorResponse(otpResult: { - reason?: "expired" | "invalid" | "max_attempts"; - attemptsRemaining?: number; - }): VerifyCodeResponse { - let error: string; - switch (otpResult.reason) { - case "expired": - error = "Code expired. Please request a new one."; - break; - case "max_attempts": - error = "Too many failed attempts. Please request a new code."; - break; - default: - error = "Invalid code. Please try again."; - } - - return { - verified: false, - error, - attemptsRemaining: otpResult.attemptsRemaining, - }; - } - - /** - * Resolve prefill data including handoff token data if provided - */ - private async resolvePrefillData( - email: string, - accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }, - handoffToken?: string - ): Promise { - let prefill = this.getPrefillData(email, accountStatus); - - if (!handoffToken) { - return prefill; - } - - const handoffData = await this.sessionService.getGuestHandoffToken(handoffToken, email); - - if (!handoffData) { - return prefill; - } - - this.logger.debug({ email, handoffToken }, "Applying handoff token data to session"); - - // Merge handoff data with prefill (handoff takes precedence as it's more recent) - prefill = { - ...prefill, - firstName: handoffData.firstName, - lastName: handoffData.lastName, - phone: handoffData.phone ?? prefill?.phone, - address: handoffData.address, - }; - - // Update account status SF ID if not already set - if (!accountStatus.sfAccountId && handoffData.sfAccountId) { - accountStatus.sfAccountId = handoffData.sfAccountId; - } - - // Invalidate the handoff token after use (one-time use) - await this.sessionService.invalidateHandoffToken(handoffToken); - - return prefill; - } - - /** - * Build session prefill data object without undefined values - */ - private buildSessionPrefillData( - prefill: VerifyCodeResponse["prefill"], - accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number } - ): Record { - return { - ...(prefill?.firstName && { firstName: prefill.firstName }), - ...(prefill?.lastName && { lastName: prefill.lastName }), - ...(prefill?.phone && { phone: prefill.phone }), - ...(prefill?.address && { address: prefill.address }), - ...(accountStatus.sfAccountId && { sfAccountId: accountStatus.sfAccountId }), - ...(accountStatus.whmcsClientId !== undefined && { - whmcsClientId: accountStatus.whmcsClientId, - }), - ...(prefill?.eligibilityStatus && { eligibilityStatus: prefill.eligibilityStatus }), - }; - } - - private async createEligibilityCase( - sfAccountId: string, - address: BilingualEligibilityAddress | SignupWithEligibilityRequest["address"] - ): Promise<{ caseId: string; caseNumber: string }> { - // Find or create Opportunity for Internet eligibility - const { opportunityId, wasCreated: opportunityCreated } = - await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); - - // Create eligibility case via workflow manager - // Pass streetAddress for duplicate detection (available on BilingualEligibilityAddress) - const streetAddress = - "streetAddress" in address && address.streetAddress ? address.streetAddress : undefined; - - await this.workflowCases.notifyEligibilityCheck({ - accountId: sfAccountId, - opportunityId, - opportunityCreated, - address: { - address1: address.address1, - ...(address.address2 ? { address2: address.address2 } : {}), - city: address.city, - state: address.state, - postcode: address.postcode, - ...(address.country ? { country: address.country } : {}), - }, - ...(streetAddress ? { streetAddress } : {}), - }); - - // Update Account eligibility status to Pending - this.updateAccountEligibilityStatus(sfAccountId); - - // Generate a reference ID for the eligibility request - // (WorkflowCaseManager.notifyEligibilityCheck doesn't return case ID as it's non-critical) - const caseId = `eligibility:${sfAccountId}:${Date.now()}`; - const caseNumber = `ELG-${Date.now().toString(36).toUpperCase()}`; - - return { caseId, caseNumber }; - } - - private updateAccountEligibilityStatus(sfAccountId: string): void { - // TODO: Implement SF Account eligibility status update - // This would update the SF Account eligibility fields using salesforceService - this.logger.debug({ sfAccountId }, "Updating account eligibility status to Pending"); - } - - private async updateSalesforcePortalFlags( - accountId: string, - whmcsClientId: number, - /** Optional source override - if not provided, source is not updated (preserves existing) */ - source?: - | typeof PORTAL_SOURCE_NEW_SIGNUP - | typeof PORTAL_SOURCE_INTERNET_ELIGIBILITY - | typeof PORTAL_SOURCE_MIGRATED - ): Promise { - try { - await this.salesforceService.updateAccountPortalFields(accountId, { - status: PORTAL_STATUS_ACTIVE, - // Only update source if explicitly provided - preserves existing source for eligibility flows - ...(source && { source }), - lastSignedInAt: new Date(), - whmcsAccountId: whmcsClientId, - }); - } catch (error) { - this.logger.warn( - { error: extractErrorMessage(error), accountId }, - "Failed to update Salesforce portal flags" - ); - } - } - - // ============================================================================ - // Shared Account Creation Helpers - // ============================================================================ - - /** - * Ensure no existing Portal or WHMCS accounts exist for the email - * Returns error message if account exists, undefined if clear to proceed - */ - private async checkExistingAccounts( - email: string - ): Promise<{ portalExists: boolean; whmcsExists: boolean }> { - const [portalUser, whmcsClient] = await Promise.all([ - this.usersService.findByEmailInternal(email), - this.whmcsDiscovery.findClientByEmail(email), - ]); - - return { - portalExists: !!portalUser, - whmcsExists: !!whmcsClient, - }; - } - - /** - * Find or create Salesforce account - */ - private async resolveSalesforceAccount(params: { - email: string; - existingAccountId?: string | undefined; - firstName?: string | undefined; - lastName?: string | undefined; - phone?: string | undefined; - portalSource: string; - updateSourceIfExists?: boolean | undefined; - }): Promise<{ sfAccountId: string; customerNumber: string | undefined }> { - const { - email, - existingAccountId, - firstName, - lastName, - phone, - portalSource, - updateSourceIfExists, - } = params; - - // If existing SF account ID provided, verify it still exists - if (existingAccountId) { - const existingSf = await this.salesforceAccountService.findByEmail(email); - if (!existingSf || existingSf.id !== existingAccountId) { - throw new BadRequestException("Account verification failed. Please start over."); - } - return { sfAccountId: existingSf.id, customerNumber: existingSf.accountNumber }; - } - - // Check if SF account exists by email - const existingSf = await this.salesforceAccountService.findByEmail(email); - if (existingSf) { - this.logger.log({ email, sfAccountId: existingSf.id }, "Using existing SF account"); - - if (updateSourceIfExists) { - await this.salesforceService.updateAccountPortalFields(existingSf.id, { - source: portalSource, - }); - } - - return { sfAccountId: existingSf.id, customerNumber: existingSf.accountNumber }; - } - - // Create new SF Account - if (!firstName || !lastName) { - throw new BadRequestException("Name is required to create an account."); - } - - const { accountId, accountNumber } = await this.salesforceAccountService.createAccount({ - firstName, - lastName, - email, - phone: phone ?? "", - portalSource, - }); - - this.logger.log({ email, sfAccountId: accountId }, "Created new SF account"); - return { sfAccountId: accountId, customerNumber: accountNumber }; - } - - /** - * Create portal user with mapping and generate auth tokens - */ - private async createUserWithAuthResult(params: { - email: string; - passwordHash: string; - whmcsClientId: number; - sfAccountId: string; - auditSource: string; - }): Promise { - const { email, passwordHash, whmcsClientId, sfAccountId, auditSource } = params; - - const { userId } = await this.userCreation.createUserWithMapping({ - email, - passwordHash, - whmcsClientId, - sfAccountId, - }); - - const freshUser = await this.usersService.findByIdInternal(userId); - if (!freshUser) { - throw new Error(ERROR_FAILED_TO_LOAD_USER); - } - - await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { - email, - whmcsClientId, - source: auditSource, - }); - - const profile = mapPrismaUserToDomain(freshUser); - const tokens = await this.tokenService.generateTokenPair({ - id: profile.id, - email: profile.email, - }); - - return { user: profile, tokens }; - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/guest-eligibility-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/guest-eligibility-workflow.service.ts new file mode 100644 index 00000000..b117a488 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/guest-eligibility-workflow.service.ts @@ -0,0 +1,168 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; + +import { + type GuestEligibilityRequest, + type GuestEligibilityResponse, +} from "@customer-portal/domain/get-started"; + +import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; +import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; +import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { + PORTAL_SOURCE_INTERNET_ELIGIBILITY, + PORTAL_STATUS_NOT_YET, +} from "@bff/modules/auth/constants/portal.constants.js"; + +import { GetStartedSessionService } from "../otp/get-started-session.service.js"; +import { CreateEligibilityCaseStep } from "./steps/index.js"; + +/** + * Remove undefined properties from an object (for exactOptionalPropertyTypes compatibility) + */ +function removeUndefined>(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined) + ) as Partial; +} + +/** + * Guest Eligibility Workflow Service + * + * Handles guest eligibility checks — creates SF Account + eligibility case + * without OTP verification. Uses shared CreateEligibilityCaseStep. + */ +@Injectable() +export class GuestEligibilityWorkflowService { + constructor( + private readonly sessionService: GetStartedSessionService, + private readonly salesforceAccountService: SalesforceAccountService, + private readonly salesforceFacade: SalesforceFacade, + private readonly lockService: DistributedLockService, + private readonly eligibilityCaseStep: CreateEligibilityCaseStep, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Guest eligibility check — NO email verification required + * + * Allows users to check availability without verifying email first. + * Creates SF Account + eligibility case immediately. + */ + async execute( + request: GuestEligibilityRequest, + fingerprint?: string + ): Promise { + const { email, firstName, lastName, address, phone, continueToAccount } = request; + const normalizedEmail = email.toLowerCase().trim(); + + this.logger.log( + { email: normalizedEmail, continueToAccount, fingerprint }, + "Guest eligibility check initiated" + ); + + const lockKey = `guest-eligibility:${normalizedEmail}`; + + try { + return await this.lockService.withLock( + lockKey, + async () => { + // Check if SF account already exists for this email + let sfAccountId: string; + + const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); + + if (existingSf) { + sfAccountId = existingSf.id; + this.logger.log( + { email: normalizedEmail, sfAccountId }, + "Using existing SF account for guest eligibility check" + ); + + await this.salesforceFacade.updateAccountPortalFields(sfAccountId, { + status: PORTAL_STATUS_NOT_YET, + source: PORTAL_SOURCE_INTERNET_ELIGIBILITY, + }); + } else { + const { accountId } = await this.salesforceAccountService.createAccount({ + firstName, + lastName, + email: normalizedEmail, + phone: phone ?? "", + portalSource: PORTAL_SOURCE_INTERNET_ELIGIBILITY, + }); + sfAccountId = accountId; + this.logger.log( + { email: normalizedEmail, sfAccountId }, + "Created SF account for guest eligibility check" + ); + } + + // Save Japanese address to SF Contact (if Japanese address fields provided) + if (address.prefectureJa || address.cityJa || address.townJa) { + await this.salesforceAccountService.updateContactAddress(sfAccountId, { + mailingStreet: `${address.townJa || ""}${address.streetAddress || ""}`.trim(), + mailingCity: address.cityJa || address.city, + mailingState: address.prefectureJa || address.state, + mailingPostalCode: address.postcode, + mailingCountry: "Japan", + buildingName: address.buildingName ?? null, + roomNumber: address.roomNumber ?? null, + }); + this.logger.debug({ sfAccountId }, "Updated SF Contact with Japanese address"); + } + + // Create eligibility case via shared step + const { caseId } = await this.eligibilityCaseStep.execute({ + sfAccountId, + address: { + address1: address.address1, + ...(address.address2 ? { address2: address.address2 } : {}), + city: address.city, + state: address.state, + postcode: address.postcode, + ...(address.country ? { country: address.country } : {}), + ...(address.streetAddress ? { streetAddress: address.streetAddress } : {}), + }, + }); + + // If user wants to continue to account creation, generate a handoff token + let handoffToken: string | undefined; + if (continueToAccount) { + handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, { + firstName, + lastName, + address: removeUndefined(address), + ...(phone && { phone }), + sfAccountId, + }); + this.logger.debug( + { email: normalizedEmail, handoffToken }, + "Created handoff token for account creation" + ); + } + + return { + submitted: true, + requestId: caseId, + sfAccountId, + handoffToken, + message: "Eligibility check submitted. We'll notify you of the results.", + }; + }, + { ttlMs: 30_000 } + ); + } catch (error) { + this.logger.error( + { error: extractErrorMessage(error), email: normalizedEmail }, + "Guest eligibility check failed" + ); + + return { + submitted: false, + message: "Failed to submit eligibility check. Please try again.", + }; + } + } +} 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 new file mode 100644 index 00000000..af50b051 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/new-customer-signup-workflow.service.ts @@ -0,0 +1,309 @@ +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 { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; +import { extractErrorMessage } from "@bff/core/utils/error.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 { 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 whmcsDiscovery: WhmcsAccountDiscoveryService, + 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, + @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, + }); + + // Step 2: Create eligibility case (DEGRADABLE) + let eligibilityRequestId: string | undefined; + try { + const caseResult = await this.caseStep.execute({ + sfAccountId: sfResult.sfAccountId, + address: { + address1: address.address1, + ...(address.address2 && { address2: address.address2 }), + city: address.city, + state: address.state, + postcode: address.postcode, + ...(address.country ? { country: address.country } : {}), + }, + }); + eligibilityRequestId = caseResult.caseId; + } catch (caseError) { + this.logger.warn( + { error: extractErrorMessage(caseError), email }, + "Eligibility case creation failed (non-critical, continuing)" + ); + } + + // Step 3: Create WHMCS client (CRITICAL, has rollback) + const whmcsResult = await this.whmcsStep.execute({ + email, + password, + firstName, + lastName, + phone: phone ?? "", + address: { + address1: address.address1, + ...(address.address2 && { address2: address.address2 }), + city: address.city, + state: address.state ?? "", + postcode: address.postcode, + country: address.country ?? "Japan", + }, + 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) + try { + await this.sfFlagsStep.execute({ + sfAccountId: sfResult.sfAccountId, + whmcsClientId: whmcsResult.whmcsClientId, + }); + } catch (flagsError) { + this.logger.warn( + { error: extractErrorMessage(flagsError), email }, + "SF flags update failed (non-critical, continuing)" + ); + } + + // 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> { + const [portalUser, whmcsClient] = await Promise.all([ + this.usersService.findByEmailInternal(email), + this.whmcsDiscovery.findClientByEmail(email), + ]); + + if (portalUser) { + return { + success: false, + message: "An account already exists with this email. Please log in.", + }; + } + + if (whmcsClient) { + return { + success: false, + message: + "A billing account already exists with this email. Please use account linking instead.", + }; + } + + 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 new file mode 100644 index 00000000..2c912e2f --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/sf-completion-workflow.service.ts @@ -0,0 +1,246 @@ +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 { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; +import { extractErrorMessage } from "@bff/core/utils/error.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 { 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 whmcsDiscovery: WhmcsAccountDiscoveryService, + private readonly sfStep: ResolveSalesforceAccountStep, + private readonly whmcsStep: CreateWhmcsClientStep, + private readonly portalUserStep: CreatePortalUserStep, + private readonly sfFlagsStep: UpdateSalesforceFlagsStep, + private readonly authResultStep: GenerateAuthResultStep, + @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, + }); + + // Step 2: Create WHMCS client (CRITICAL, has rollback) + const whmcsResult = await this.whmcsStep.execute({ + firstName: finalFirstName, + lastName: finalLastName, + email: session.email, + password, + phone: phone ?? "", + address: { + address1: address.address1, + ...(address.address2 && { address2: address.address2 }), + city: address.city, + state: address.state, + postcode: address.postcode, + country: address.country ?? "Japan", + }, + 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) + try { + await this.sfFlagsStep.execute({ + sfAccountId: sfResult.sfAccountId, + whmcsClientId: whmcsResult.whmcsClientId, + }); + } catch (flagsError) { + this.logger.warn( + { error: extractErrorMessage(flagsError), email: session.email }, + "SF flags update failed (non-critical, continuing)" + ); + } + + // 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 { + const [portalUser, whmcsClient] = await Promise.all([ + this.usersService.findByEmailInternal(email), + this.whmcsDiscovery.findClientByEmail(email), + ]); + + if (whmcsClient) { + throw new ConflictException( + "A billing account already exists. Please use the account migration flow." + ); + } + 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.address1 || !address.city || !address.state || !address.postcode) { + throw new BadRequestException( + "Address information is incomplete. Please ensure all required fields (address, city, prefecture, postcode) are 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-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts deleted file mode 100644 index ded3b934..00000000 --- a/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { ConflictException, Inject, Injectable } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import * as argon2 from "argon2"; -import type { Request } from "express"; -import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; -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 { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; -import { AuthTokenService } from "../token/token.service.js"; -import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { - signupRequestSchema, - type SignupRequest, - type ValidateSignupRequest, -} from "@customer-portal/domain/auth"; -import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; -import { - PORTAL_SOURCE_NEW_SIGNUP, - PORTAL_STATUS_ACTIVE, - type PortalRegistrationSource, -} from "@bff/modules/auth/constants/portal.constants.js"; -import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; -import { - SignupAccountResolverService, - SignupValidationService, - SignupWhmcsService, - SignupUserCreationService, -} from "./signup/index.js"; - -/** - * Signup Workflow Service - * - * Orchestrates the signup process by coordinating: - * - SignupValidationService: Validates customer numbers and preflight checks - * - SignupAccountResolverService: Resolves or creates Salesforce accounts and contacts - * - SignupWhmcsService: Creates WHMCS clients - * - SignupUserCreationService: Creates portal users with ID mappings - */ -@Injectable() -export class SignupWorkflowService { - constructor( - private readonly usersService: UsersService, - private readonly mappingsService: MappingsService, - private readonly salesforceService: SalesforceFacade, - private readonly auditService: AuditService, - private readonly tokenService: AuthTokenService, - private readonly authRateLimitService: AuthRateLimitService, - private readonly accountResolver: SignupAccountResolverService, - private readonly signupValidation: SignupValidationService, - private readonly whmcsSignup: SignupWhmcsService, - private readonly userCreation: SignupUserCreationService, - private readonly lockService: DistributedLockService, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Validate customer number for signup - */ - async validateSignup(validateData: ValidateSignupRequest, request?: Request) { - return this.signupValidation.validateCustomerNumber(validateData, request); - } - - /** - * Execute the full signup workflow - */ - async signup(signupData: SignupRequest, request?: Request): Promise { - if (request) { - await this.authRateLimitService.consumeSignupAttempt(request); - } - - // Validate signup data using schema - signupRequestSchema.parse(signupData); - - const { - email, - password, - firstName, - lastName, - company, - phone, - address, - nationality, - dateOfBirth, - gender, - } = signupData; - - // Check for existing portal user - const existingUser = await this.usersService.findByEmailInternal(email); - if (existingUser) { - const mapped = await this.mappingsService.hasMapping(existingUser.id); - const message = mapped - ? "You already have an account. Please sign in." - : "You already have an account with us. Please sign in to continue setup."; - await this.auditService.logAuthEvent( - AuditAction.SIGNUP, - existingUser.id, - { email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" }, - request, - false, - message - ); - throw new ConflictException(message); - } - - const passwordHash = await argon2.hash(password); - - const lockKey = `signup:${email.toLowerCase().trim()}`; - return await this.lockService.withLock( - lockKey, - async () => { - try { - // Step 1: Check for existing WHMCS client before provisioning in Salesforce - await this.whmcsSignup.checkExistingClient(email.toLowerCase().trim()); - - // Step 2: Validate WHMCS data requirements - this.whmcsSignup.validateAddressData(signupData); - - // Step 3: Resolve or create Salesforce account - const { snapshot: accountSnapshot, customerNumber: customerNumberForWhmcs } = - await this.accountResolver.resolveOrCreate(signupData); - - const normalizedCustomerNumber = this.accountResolver.normalizeCustomerNumber( - signupData.sfNumber - ); - if (normalizedCustomerNumber) { - const existingMapping = await this.mappingsService.findBySfAccountId( - accountSnapshot.id - ); - if (existingMapping) { - throw new ConflictException( - "You already have an account. Please use the login page to access your existing account." - ); - } - } - - // Step 4: Create WHMCS client - // Address fields are validated by validateAddressData, safe to assert non-null - const whmcsClient = await this.whmcsSignup.createClient({ - firstName, - lastName, - email, - password, - ...(company ? { company } : {}), - phone: phone, - address: { - address1: address!.address1!, - ...(address?.address2 ? { address2: address.address2 } : {}), - city: address!.city!, - state: address!.state!, - postcode: address!.postcode!, - country: address!.country!, - }, - customerNumber: customerNumberForWhmcs, - ...(dateOfBirth ? { dateOfBirth } : {}), - ...(gender ? { gender } : {}), - ...(nationality ? { nationality } : {}), - }); - - // Step 5: Create user and mapping in database - const { userId } = await this.userCreation.createUserWithMapping({ - email, - passwordHash, - whmcsClientId: whmcsClient.clientId, - sfAccountId: accountSnapshot.id, - }); - - // Step 6: Fetch fresh user and generate tokens - const freshUser = await this.usersService.findByIdInternal(userId); - if (!freshUser) { - throw new Error("Failed to load created user"); - } - - await this.auditService.logAuthEvent( - AuditAction.SIGNUP, - userId, - { email, whmcsClientId: whmcsClient.clientId }, - request, - true - ); - - const profile = mapPrismaUserToDomain(freshUser); - const tokens = await this.tokenService.generateTokenPair({ - id: profile.id, - email: profile.email, - }); - - // Step 7: Update Salesforce portal flags (non-blocking) - await this.updateSalesforcePortalFlags( - accountSnapshot.id, - PORTAL_SOURCE_NEW_SIGNUP, - whmcsClient.clientId - ); - - return { - user: profile, - tokens, - }; - } catch (error) { - await this.auditService.logAuthEvent( - AuditAction.SIGNUP, - undefined, - { email, error: extractErrorMessage(error) }, - request, - false, - extractErrorMessage(error) - ); - - this.logger.error("Signup error", { error: extractErrorMessage(error) }); - throw error; - } - }, - { ttlMs: 60_000 } - ); - } - - /** - * Preflight check before signup - delegates to SignupValidationService - */ - async signupPreflight(signupData: SignupRequest) { - const { email, sfNumber } = signupData; - const preflightResult = await this.signupValidation.preflightCheck(email, sfNumber); - - // Return with additional 'ok' field for API compatibility - return { - ok: true, - ...preflightResult, - }; - } - - private async updateSalesforcePortalFlags( - accountId: string, - source: PortalRegistrationSource, - whmcsAccountId?: number - ): Promise { - try { - await this.salesforceService.updateAccountPortalFields(accountId, { - status: PORTAL_STATUS_ACTIVE, - source, - lastSignedInAt: new Date(), - ...(whmcsAccountId === undefined ? {} : { whmcsAccountId }), - }); - } catch (error) { - this.logger.warn("Failed to update Salesforce portal flags after signup", { - accountId, - error: extractErrorMessage(error), - }); - } - } -} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/create-eligibility-case.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/create-eligibility-case.step.ts new file mode 100644 index 00000000..9dac45e7 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/steps/create-eligibility-case.step.ts @@ -0,0 +1,74 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js"; +import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; + +export interface CreateEligibilityCaseAddress { + address1: string; + address2?: string; + city: string; + state?: string; + postcode: string; + country?: string; + /** Japanese full street address — used for duplicate detection */ + streetAddress?: string; +} + +export interface CreateEligibilityCaseParams { + sfAccountId: string; + address: CreateEligibilityCaseAddress; +} + +export interface CreateEligibilityCaseResult { + caseId: string; + caseNumber: string; +} + +/** + * Step: Create a Salesforce eligibility case. + * + * Finds or creates an Opportunity for internet eligibility, + * then creates a notification case via WorkflowCaseManager. + * + * No rollback — cases are idempotent. + */ +@Injectable() +export class CreateEligibilityCaseStep { + constructor( + private readonly workflowCaseManager: WorkflowCaseManager, + private readonly opportunityResolution: OpportunityResolutionService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async execute(params: CreateEligibilityCaseParams): Promise { + const { sfAccountId, address } = params; + + // Find or create Opportunity for Internet eligibility + const { opportunityId, wasCreated: opportunityCreated } = + await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); + + // Create eligibility case via workflow manager + await this.workflowCaseManager.notifyEligibilityCheck({ + accountId: sfAccountId, + opportunityId, + opportunityCreated, + address: { + address1: address.address1, + ...(address.address2 ? { address2: address.address2 } : {}), + city: address.city, + state: address.state ?? "", + postcode: address.postcode, + ...(address.country ? { country: address.country } : {}), + }, + ...(address.streetAddress ? { streetAddress: address.streetAddress } : {}), + }); + + // Generate a reference ID for the eligibility request + const caseId = `eligibility:${sfAccountId}:${Date.now()}`; + const caseNumber = `ELG-${Date.now().toString(36).toUpperCase()}`; + + this.logger.log({ sfAccountId, caseId, opportunityId }, "Eligibility case created"); + + return { caseId, caseNumber }; + } +} 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 new file mode 100644 index 00000000..7d969043 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/steps/create-portal-user.step.ts @@ -0,0 +1,63 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SignupUserCreationService } from "../signup/signup-user-creation.service.js"; + +import { PrismaService } from "@bff/infra/database/prisma.service.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; + +export interface CreatePortalUserParams { + email: string; + passwordHash: string; + sfAccountId: string; + whmcsClientId: number; +} + +export interface CreatePortalUserResult { + userId: string; +} + +/** + * Step: Create a portal user with ID mapping. + * + * Delegates to SignupUserCreationService for the Prisma transaction + * that creates both the User row and the IdMapping row atomically. + * + * Rollback deletes the user and associated ID mapping. + */ +@Injectable() +export class CreatePortalUserStep { + constructor( + private readonly signupUserCreation: SignupUserCreationService, + private readonly prisma: PrismaService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async execute(params: CreatePortalUserParams): Promise { + const { userId } = await this.signupUserCreation.createUserWithMapping({ + email: params.email, + passwordHash: params.passwordHash, + whmcsClientId: params.whmcsClientId, + sfAccountId: params.sfAccountId, + }); + + this.logger.log({ userId, email: params.email }, "Portal user created with ID mapping"); + + return { userId }; + } + + async rollback(userId: string): Promise { + this.logger.warn({ userId }, "Rolling back portal user creation"); + try { + await this.prisma.$transaction(async tx => { + await tx.idMapping.deleteMany({ where: { userId } }); + await tx.user.delete({ where: { id: userId } }); + }); + this.logger.log({ userId }, "Portal user and mapping deleted in rollback"); + } catch (error) { + this.logger.error( + { userId, error: extractErrorMessage(error) }, + "Failed to rollback portal user — manual cleanup required" + ); + } + } +} 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 new file mode 100644 index 00000000..7d6f5a69 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/steps/create-whmcs-client.step.ts @@ -0,0 +1,74 @@ +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"; + +export interface CreateWhmcsClientParams { + email: string; + password: string; + firstName: string; + lastName: string; + phone: string; + address: { + address1: string; + address2?: string; + city: string; + state: string; + postcode: string; + country: string; + }; + customerNumber: string | null; + company?: string; + dateOfBirth?: string; + gender?: string; + nationality?: string; +} + +export interface CreateWhmcsClientResult { + whmcsClientId: number; +} + +/** + * Step: Create a WHMCS billing client. + * + * Delegates to SignupWhmcsService for the actual API call. + * Rollback marks the created client as inactive for manual cleanup. + */ +@Injectable() +export class CreateWhmcsClientStep { + constructor( + private readonly signupWhmcs: SignupWhmcsService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async execute(params: CreateWhmcsClientParams): Promise { + const result: WhmcsCreatedClient = await this.signupWhmcs.createClient({ + firstName: params.firstName, + lastName: params.lastName, + email: params.email, + password: params.password, + phone: params.phone, + address: params.address, + customerNumber: params.customerNumber, + ...(params.company != null && { company: params.company }), + ...(params.dateOfBirth != null && { dateOfBirth: params.dateOfBirth }), + ...(params.gender != null && { gender: params.gender }), + ...(params.nationality != null && { nationality: params.nationality }), + }); + + this.logger.log( + { email: params.email, whmcsClientId: result.clientId }, + "WHMCS client created" + ); + + return { whmcsClientId: result.clientId }; + } + + async rollback(whmcsClientId: number, email?: string): Promise { + this.logger.warn( + { whmcsClientId, email }, + "Rolling back WHMCS client creation — marking for cleanup" + ); + await this.signupWhmcs.markClientForCleanup(whmcsClientId, email ?? "unknown"); + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts new file mode 100644 index 00000000..fa1e81cd --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts @@ -0,0 +1,60 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; +import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; +import { AuthTokenService } from "../../token/token.service.js"; +import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; +import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; + +export interface GenerateAuthResultParams { + userId: string; + email: string; + auditSource: string; + auditDetails?: Record; +} + +/** + * Step: Generate authentication result (tokens + profile). + * + * Loads the freshly created user, logs an audit event, + * and generates a JWT access/refresh token pair. + * + * No rollback — stateless token generation. + */ +@Injectable() +export class GenerateAuthResultStep { + constructor( + private readonly usersService: UsersService, + private readonly auditService: AuditService, + private readonly authTokenService: AuthTokenService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async execute(params: GenerateAuthResultParams): Promise { + const { userId, email, auditSource, auditDetails } = params; + + // Load fresh user from DB + const freshUser = await this.usersService.findByIdInternal(userId); + if (!freshUser) { + throw new Error("Failed to load created user"); + } + + // Log audit event + await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { + email, + source: auditSource, + ...auditDetails, + }); + + // Generate token pair + const profile = mapPrismaUserToDomain(freshUser); + const tokens = await this.authTokenService.generateTokenPair({ + id: profile.id, + email: profile.email, + }); + + this.logger.log({ userId, email }, "Auth result generated"); + + return { user: profile, tokens }; + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/index.ts b/apps/bff/src/modules/auth/infra/workflows/steps/index.ts new file mode 100644 index 00000000..b24a1394 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/steps/index.ts @@ -0,0 +1,27 @@ +export { ResolveSalesforceAccountStep } from "./resolve-salesforce-account.step.js"; +export type { + ResolveSalesforceAccountParams, + ResolveSalesforceAccountResult, +} from "./resolve-salesforce-account.step.js"; + +export { CreateWhmcsClientStep } from "./create-whmcs-client.step.js"; +export type { + CreateWhmcsClientParams, + CreateWhmcsClientResult, +} from "./create-whmcs-client.step.js"; + +export { CreatePortalUserStep } from "./create-portal-user.step.js"; +export type { CreatePortalUserParams, CreatePortalUserResult } from "./create-portal-user.step.js"; + +export { UpdateSalesforceFlagsStep } from "./update-salesforce-flags.step.js"; +export type { UpdateSalesforceFlagsParams } from "./update-salesforce-flags.step.js"; + +export { GenerateAuthResultStep } from "./generate-auth-result.step.js"; +export type { GenerateAuthResultParams } from "./generate-auth-result.step.js"; + +export { CreateEligibilityCaseStep } from "./create-eligibility-case.step.js"; +export type { + CreateEligibilityCaseParams, + CreateEligibilityCaseAddress, + CreateEligibilityCaseResult, +} from "./create-eligibility-case.step.js"; diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/resolve-salesforce-account.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/resolve-salesforce-account.step.ts new file mode 100644 index 00000000..a8090a2e --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/steps/resolve-salesforce-account.step.ts @@ -0,0 +1,81 @@ +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; +import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; + +export interface ResolveSalesforceAccountParams { + email: string; + firstName: string; + lastName: string; + phone?: string; + customerNumber?: string; + source: string; + existingAccountId?: string; + updateSourceIfExists?: boolean; +} + +export interface ResolveSalesforceAccountResult { + sfAccountId: string; + customerNumber: string | undefined; +} + +/** + * Step: Resolve or create a Salesforce account. + * + * Finds an existing SF account by email (or verifies a known ID), + * or creates a new one when no match is found. + * + * No rollback — SF accounts are reusable across retries. + */ +@Injectable() +export class ResolveSalesforceAccountStep { + constructor( + private readonly salesforceAccountService: SalesforceAccountService, + private readonly salesforceFacade: SalesforceFacade, + @Inject(Logger) private readonly logger: Logger + ) {} + + async execute(params: ResolveSalesforceAccountParams): Promise { + const { email, firstName, lastName, phone, source, existingAccountId, updateSourceIfExists } = + params; + + // If an existing SF account ID is provided, verify it still exists + if (existingAccountId) { + const existing = await this.salesforceAccountService.findByEmail(email); + if (!existing || existing.id !== existingAccountId) { + throw new BadRequestException("Account verification failed. Please start over."); + } + return { sfAccountId: existing.id, customerNumber: existing.accountNumber }; + } + + // Check if SF account exists by email + const existingSf = await this.salesforceAccountService.findByEmail(email); + if (existingSf) { + this.logger.log({ email, sfAccountId: existingSf.id }, "Using existing SF account"); + + if (updateSourceIfExists) { + await this.salesforceFacade.updateAccountPortalFields(existingSf.id, { + source, + }); + } + + return { sfAccountId: existingSf.id, customerNumber: existingSf.accountNumber }; + } + + // Create new SF Account + if (!firstName || !lastName) { + throw new BadRequestException("Name is required to create an account."); + } + + const { accountId, accountNumber } = await this.salesforceAccountService.createAccount({ + firstName, + lastName, + email, + phone: phone ?? "", + portalSource: source, + }); + + this.logger.log({ email, sfAccountId: accountId }, "Created new SF account"); + return { sfAccountId: accountId, customerNumber: accountNumber }; + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/update-salesforce-flags.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/update-salesforce-flags.step.ts new file mode 100644 index 00000000..d3445b41 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/steps/update-salesforce-flags.step.ts @@ -0,0 +1,52 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { + PORTAL_STATUS_ACTIVE, + type PortalRegistrationSource, +} from "@bff/modules/auth/constants/portal.constants.js"; + +export interface UpdateSalesforceFlagsParams { + sfAccountId: string; + whmcsClientId?: number; + source?: PortalRegistrationSource; +} + +/** + * Step: Update Salesforce portal status flags after account creation. + * + * Sets the account to "Active" and optionally updates the registration source + * and WHMCS account link. + * + * No rollback — this is retry-safe and idempotent. + */ +@Injectable() +export class UpdateSalesforceFlagsStep { + constructor( + private readonly salesforceFacade: SalesforceFacade, + @Inject(Logger) private readonly logger: Logger + ) {} + + async execute(params: UpdateSalesforceFlagsParams): Promise { + const { sfAccountId, whmcsClientId, source } = params; + + try { + await this.salesforceFacade.updateAccountPortalFields(sfAccountId, { + status: PORTAL_STATUS_ACTIVE, + ...(source && { source }), + lastSignedInAt: new Date(), + ...(whmcsClientId !== undefined && { whmcsAccountId: whmcsClientId }), + }); + + this.logger.log({ sfAccountId, source, whmcsClientId }, "Salesforce portal flags updated"); + } catch (error) { + // Non-critical: log warning but do not throw. + // The account is already created; SF flags can be fixed later. + this.logger.warn( + { sfAccountId, error: extractErrorMessage(error) }, + "Failed to update Salesforce portal flags" + ); + } + } +} 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 new file mode 100644 index 00000000..37c7d5fd --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts @@ -0,0 +1,277 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; + +import { + ACCOUNT_STATUS, + type AccountStatus, + type SendVerificationCodeRequest, + type SendVerificationCodeResponse, + type VerifyCodeRequest, + 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"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; + +import { OtpService } from "../otp/otp.service.js"; +import { GetStartedSessionService } from "../otp/get-started-session.service.js"; + +/** + * Verification Workflow Service + * + * Handles OTP send/verify and account status detection. + * No distributed transaction needed — simple request-response operations. + */ +@Injectable() +export class VerificationWorkflowService { + constructor( + private readonly config: ConfigService, + private readonly otpService: OtpService, + private readonly sessionService: GetStartedSessionService, + private readonly emailService: EmailService, + private readonly usersService: UsersService, + private readonly mappingsService: MappingsService, + private readonly salesforceAccountService: SalesforceAccountService, + private readonly whmcsDiscovery: WhmcsAccountDiscoveryService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Send OTP verification code to email + */ + async sendCode( + request: SendVerificationCodeRequest, + fingerprint?: string + ): Promise { + const { email } = request; + const normalizedEmail = email.toLowerCase().trim(); + + try { + // Generate OTP and store in Redis (with fingerprint for binding) + const code = await this.otpService.generateAndStore(normalizedEmail, fingerprint); + + // Create session for this verification flow (token returned in verifyCode) + await this.sessionService.create(normalizedEmail); + + // Send email with OTP code + await this.sendOtpEmail(normalizedEmail, code); + + this.logger.log({ email: normalizedEmail }, "OTP verification code sent"); + + return { + sent: true, + message: "Verification code sent to your email", + }; + } catch (error) { + this.logger.error( + { error: extractErrorMessage(error), email: normalizedEmail }, + "Failed to send verification code" + ); + + return { + sent: false, + message: "Failed to send verification code. Please try again.", + }; + } + } + + /** + * Verify OTP code and determine account status + */ + async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise { + const { email, code, handoffToken } = request; + const normalizedEmail = email.toLowerCase().trim(); + + // Verify OTP (with fingerprint check - logs warning if different context) + const otpResult = await this.otpService.verify(normalizedEmail, code, fingerprint); + + if (!otpResult.valid) { + return this.buildOtpErrorResponse(otpResult); + } + + // Create verified session + const sessionToken = await this.sessionService.create(normalizedEmail); + + // Check account status across all systems + const accountStatus = await this.determineAccountStatus(normalizedEmail); + + // Get prefill data (including handoff token data if provided) + const prefill = await this.resolvePrefillData(normalizedEmail, accountStatus, handoffToken); + + // Update session with verified status and account info + const prefillData = this.buildSessionPrefillData(prefill, accountStatus); + await this.sessionService.markEmailVerified( + sessionToken, + accountStatus.status, + Object.keys(prefillData).length > 0 ? prefillData : undefined + ); + + this.logger.log( + { email: normalizedEmail, accountStatus: accountStatus.status, hasHandoff: !!handoffToken }, + "Email verified and account status determined" + ); + + return { + verified: true, + sessionToken, + accountStatus: accountStatus.status, + prefill, + }; + } + + // --------------------------------------------------------------------------- + // 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; + }): VerifyCodeResponse { + let error: string; + switch (otpResult.reason) { + case "expired": + error = "Code expired. Please request a new one."; + break; + case "max_attempts": + error = "Too many failed attempts. Please request a new code."; + break; + default: + error = "Invalid code. Please try again."; + } + + return { + verified: false, + error, + attemptsRemaining: otpResult.attemptsRemaining, + }; + } + + private async determineAccountStatus( + email: string + ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { + // Check Portal user first + const portalUser = await this.usersService.findByEmailInternal(email); + if (portalUser) { + const hasMapping = await this.mappingsService.hasMapping(portalUser.id); + if (hasMapping) { + return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; + } + } + + // Check WHMCS client or user (sub-account) + const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(email); + if (whmcsClient) { + const mapping = await this.mappingsService.findByWhmcsClientId(whmcsClient.id); + if (mapping) { + return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; + } + return { status: ACCOUNT_STATUS.WHMCS_UNMAPPED, whmcsClientId: whmcsClient.id }; + } + + // Check Salesforce account + const sfAccount = await this.salesforceAccountService.findByEmail(email); + if (sfAccount) { + const mapping = await this.mappingsService.findBySfAccountId(sfAccount.id); + if (mapping) { + return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; + } + return { status: ACCOUNT_STATUS.SF_UNMAPPED, sfAccountId: sfAccount.id }; + } + + return { status: ACCOUNT_STATUS.NEW_CUSTOMER }; + } + + private getPrefillData( + email: string, + accountStatus: { status: AccountStatus; sfAccountId?: string } + ): VerifyCodeResponse["prefill"] { + if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) { + return { email }; + } + return undefined; + } + + private async resolvePrefillData( + email: string, + accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }, + handoffToken?: string + ): Promise { + let prefill = this.getPrefillData(email, accountStatus); + + if (!handoffToken) { + return prefill; + } + + const handoffData = await this.sessionService.getGuestHandoffToken(handoffToken, email); + + if (!handoffData) { + return prefill; + } + + this.logger.debug({ email, handoffToken }, "Applying handoff token data to session"); + + prefill = { + ...prefill, + firstName: handoffData.firstName, + lastName: handoffData.lastName, + phone: handoffData.phone ?? prefill?.phone, + address: handoffData.address, + }; + + if (!accountStatus.sfAccountId && handoffData.sfAccountId) { + accountStatus.sfAccountId = handoffData.sfAccountId; + } + + await this.sessionService.invalidateHandoffToken(handoffToken); + + return prefill; + } + + private buildSessionPrefillData( + prefill: VerifyCodeResponse["prefill"], + accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number } + ): Record { + return { + ...(prefill?.firstName && { firstName: prefill.firstName }), + ...(prefill?.lastName && { lastName: prefill.lastName }), + ...(prefill?.phone && { phone: prefill.phone }), + ...(prefill?.address && { address: prefill.address }), + ...(accountStatus.sfAccountId && { sfAccountId: accountStatus.sfAccountId }), + ...(accountStatus.whmcsClientId !== undefined && { + whmcsClientId: accountStatus.whmcsClientId, + }), + ...(prefill?.eligibilityStatus && { eligibilityStatus: prefill.eligibilityStatus }), + }; + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts new file mode 100644 index 00000000..7e9b5403 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts @@ -0,0 +1,241 @@ +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 MigrateWhmcsAccountRequest } from "@customer-portal/domain/get-started"; +import { + getCustomFieldValue, + serializeWhmcsKeyValueMap, +} from "@customer-portal/domain/customer/providers"; + +import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; +import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; +import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.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 { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { PORTAL_SOURCE_MIGRATED } from "@bff/modules/auth/constants/portal.constants.js"; +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"; + +/** + * WHMCS 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 { + constructor( + private readonly config: ConfigService, + private readonly sessionService: GetStartedSessionService, + private readonly lockService: DistributedLockService, + private readonly usersService: UsersService, + private readonly salesforceAccountService: SalesforceAccountService, + private readonly salesforceFacade: SalesforceFacade, + private readonly whmcsDiscovery: WhmcsAccountDiscoveryService, + private readonly whmcsClientService: WhmcsClientService, + private readonly userCreation: SignupUserCreationService, + private readonly sfFlagsStep: UpdateSalesforceFlagsStep, + private readonly authResultStep: GenerateAuthResultStep, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Migrate WHMCS account to portal without legacy password validation. + * + * Flow: validate session → verify WHMCS client → find SF account → + * update WHMCS password → create portal user → update SF flags → auth tokens + */ + async execute(request: MigrateWhmcsAccountRequest): Promise { + const sessionResult = await this.sessionService.acquireAndMarkAsUsed( + request.sessionToken, + "migrate_whmcs_account" + ); + + if (!sessionResult.success) { + throw new BadRequestException(sessionResult.reason); + } + + const session = sessionResult.session; + + if (!session.whmcsClientId) { + throw new BadRequestException( + "No WHMCS account found in session. Please verify your email again." + ); + } + + const { email, whmcsClientId } = session as { email: string; whmcsClientId: number }; + const { password, dateOfBirth, gender } = request; + const lockKey = `migrate-whmcs:${email}`; + + try { + return await this.lockService.withLock( + lockKey, + async () => + this.executeMigration( + { email, whmcsClientId }, + request.sessionToken, + password, + dateOfBirth, + gender + ), + { ttlMs: 60_000 } + ); + } catch (error) { + this.logger.error({ error: extractErrorMessage(error), email }, "WHMCS migration failed"); + throw error; + } + } + + private async executeMigration( + session: { email: string; whmcsClientId: number }, + sessionToken: string, + password: string, + dateOfBirth?: string, + gender?: string + ): Promise { + const { email, whmcsClientId } = session; + + // Verify WHMCS client still exists and matches session + const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(email); + if (!whmcsClient || whmcsClient.id !== whmcsClientId) { + throw new BadRequestException("WHMCS account verification failed. Please start over."); + } + + // Check for existing portal user + const existingPortalUser = await this.usersService.findByEmailInternal(email); + if (existingPortalUser) { + throw new ConflictException("An account already exists. Please log in."); + } + + // Find Salesforce account for mapping + const sfAccount = await this.findSalesforceAccountForMigration(email, whmcsClientId); + if (!sfAccount) { + throw new BadRequestException( + "Unable to find your Salesforce account. Please contact support." + ); + } + + // Hash password for portal storage + const passwordHash = await argon2.hash(password); + + // Update WHMCS client with new password (DOB/gender only if provided) + await this.updateWhmcsClientForMigration(whmcsClientId, password, dateOfBirth, gender); + + // Create portal user and ID mapping + const { userId } = await this.userCreation.createUserWithMapping({ + email, + passwordHash, + whmcsClientId, + sfAccountId: sfAccount.id, + }); + + // Update Salesforce portal flags (DEGRADABLE) + try { + await this.sfFlagsStep.execute({ + sfAccountId: sfAccount.id, + whmcsClientId, + source: PORTAL_SOURCE_MIGRATED, + }); + } catch (flagsError) { + this.logger.warn( + { error: extractErrorMessage(flagsError), email }, + "SF flags update failed (non-critical, continuing)" + ); + } + + // Generate auth result + const authResult = await this.authResultStep.execute({ + userId, + email, + auditSource: "whmcs_migration", + auditDetails: { whmcsClientId }, + }); + + // Invalidate session + await this.sessionService.invalidate(sessionToken); + + this.logger.log({ email, userId, whmcsClientId }, "WHMCS account migrated successfully"); + + return authResult; + } + + private async findSalesforceAccountForMigration( + email: string, + whmcsClientId: number + ): Promise<{ id: string } | null> { + try { + // First try by email + const sfAccount = await this.salesforceAccountService.findByEmail(email); + if (sfAccount) { + return { id: sfAccount.id }; + } + + // Try by customer number from WHMCS + const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId); + + const customerNumber = + getCustomFieldValue(whmcsClient.customfields, "198")?.trim() ?? + getCustomFieldValue(whmcsClient.customfields, "Customer Number")?.trim(); + + if (!customerNumber) { + this.logger.warn( + { whmcsClientId, email }, + "No customer number found in WHMCS for SF lookup" + ); + return null; + } + + const sfAccountByNumber = + await this.salesforceFacade.findAccountByCustomerNumber(customerNumber); + if (sfAccountByNumber) { + return { id: sfAccountByNumber.id }; + } + + this.logger.warn( + { whmcsClientId, email, customerNumber }, + "No Salesforce account found for WHMCS migration" + ); + return null; + } catch (error) { + this.logger.warn( + { error: extractErrorMessage(error), email, whmcsClientId }, + "Failed to find Salesforce account for migration" + ); + return null; + } + } + + private async updateWhmcsClientForMigration( + clientId: number, + password: string, + dateOfBirth?: string, + gender?: string + ): Promise { + const dobFieldId = this.config.get("WHMCS_DOB_FIELD_ID"); + const genderFieldId = this.config.get("WHMCS_GENDER_FIELD_ID"); + + const customfieldsMap: Record = {}; + if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth; + if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender; + + const updateData: Record = { + password2: password, + }; + + if (Object.keys(customfieldsMap).length > 0) { + updateData["customfields"] = serializeWhmcsKeyValueMap(customfieldsMap); + } + + await this.whmcsClientService.updateClient(clientId, updateData); + + this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data"); + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/workflow-error.util.ts b/apps/bff/src/modules/auth/infra/workflows/workflow-error.util.ts new file mode 100644 index 00000000..525235d6 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/workflow-error.util.ts @@ -0,0 +1,68 @@ +import { + ConflictException, + BadRequestException, + NotFoundException, + ForbiddenException, + UnauthorizedException, +} from "@nestjs/common"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; + +export enum ErrorCategory { + /** Network timeout, rate limit — "Try again in a moment" */ + RETRIABLE = "RETRIABLE", + /** Email already registered, invalid data — specific message */ + PERMANENT = "PERMANENT", + /** SF down, WHMCS unavailable — "We're having issues, try later" */ + TRANSIENT = "TRANSIENT", +} + +export interface ClassifiedError { + errorCategory: ErrorCategory; + message: string; +} + +/** + * Classify an error into a category for the frontend. + * + * - ConflictException / BadRequestException / ForbiddenException / UnauthorizedException → PERMANENT + * - Timeout / ECONNREFUSED / rate-limit → RETRIABLE + * - Everything else → TRANSIENT + */ +export function classifyError(error: unknown): ClassifiedError { + // Permanent: business-rule violations the user can't retry past + if ( + error instanceof ConflictException || + error instanceof BadRequestException || + error instanceof ForbiddenException || + error instanceof UnauthorizedException || + error instanceof NotFoundException + ) { + return { + errorCategory: ErrorCategory.PERMANENT, + message: extractErrorMessage(error), + }; + } + + const msg = extractErrorMessage(error).toLowerCase(); + + // Retriable: transient network issues the user can try again + if ( + msg.includes("timeout") || + msg.includes("econnrefused") || + msg.includes("econnreset") || + msg.includes("rate limit") || + msg.includes("too many requests") || + msg.includes("429") + ) { + return { + errorCategory: ErrorCategory.RETRIABLE, + message: "A temporary error occurred. Please try again in a moment.", + }; + } + + // Default: infrastructure issues + return { + errorCategory: ErrorCategory.TRANSIENT, + message: "We're experiencing technical difficulties. Please try again later.", + }; +} 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 5cbf49c7..ae51e6b0 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -46,7 +46,6 @@ import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device. // Import Zod schemas from domain import { - signupRequestSchema, passwordResetRequestSchema, passwordResetSchema, setPasswordRequestSchema, @@ -69,7 +68,6 @@ type RequestWithCookies = Omit & { // Re-export for backward compatibility with tests export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE }; -class SignupRequestDto extends createZodDto(signupRequestSchema) {} class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {} class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {} class LinkWhmcsRequestDto extends createZodDto(linkWhmcsRequestSchema) {} @@ -108,20 +106,6 @@ export class AuthController { return this.authOrchestrator.getAccountStatus(body.email); } - @Public() - @Post("signup") - @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) - @RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation) - async signup( - @Body() signupData: SignupRequestDto, - @Req() req: Request, - @Res({ passthrough: true }) res: Response - ) { - const result = await this.authOrchestrator.signup(signupData, req); - setAuthCookies(res, result.tokens); - return { user: result.user, session: buildSessionInfo(result.tokens) }; - } - /** * POST /auth/login - Initiate login with credentials * diff --git a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts index acff25ec..15e5c23e 100644 --- a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts @@ -2,7 +2,11 @@ import { Controller, Post, Body, UseGuards, Res, Req, HttpCode } from "@nestjs/c import type { Request, Response } from "express"; import { createZodDto } from "nestjs-zod"; -import { RateLimitGuard, RateLimit, getRateLimitFingerprint } from "@bff/core/rate-limiting/index.js"; +import { + RateLimitGuard, + RateLimit, + getRateLimitFingerprint, +} from "@bff/core/rate-limiting/index.js"; import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; import { Public } from "../../decorators/public.decorator.js"; @@ -20,7 +24,7 @@ import { } from "@customer-portal/domain/get-started"; import type { User } from "@customer-portal/domain/customer"; -import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js"; +import { GetStartedCoordinator } from "../../infra/workflows/get-started-coordinator.service.js"; import { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js"; // DTO classes using Zod schemas @@ -54,7 +58,7 @@ interface AuthSuccessResponse { */ @Controller("auth/get-started") export class GetStartedController { - constructor(private readonly workflow: GetStartedWorkflowService) {} + constructor(private readonly workflow: GetStartedCoordinator) {} /** * Send OTP verification code to email diff --git a/apps/portal/package.json b/apps/portal/package.json index 835f2d7a..73a0bb5f 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -21,6 +21,7 @@ "@customer-portal/domain": "workspace:*", "@heroicons/react": "^2.2.0", "@tanstack/react-query": "^5.90.20", + "@xstate/react": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "geist": "^1.5.1", @@ -30,6 +31,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.4.0", "world-countries": "^5.1.0", + "xstate": "^5.28.0", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx index 76f9eb32..fc74ca1c 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx @@ -1,13 +1,13 @@ /** * GetStartedForm - Main form component for the unified get-started flow * - * Flow: Email → OTP Verification → Account Status → Complete Account → Success + * Flow: Email -> OTP Verification -> Account Status -> Complete Account -> Success */ "use client"; import { useEffect } from "react"; -import { useGetStartedStore, type GetStartedStep } from "../../stores/get-started.store"; +import { useGetStartedMachine } from "../../hooks/useGetStartedMachine"; import { EmailStep, VerificationStep, @@ -17,16 +17,9 @@ import { SuccessStep, } from "./steps"; -const stepComponents: Record = { - email: EmailStep, - verification: VerificationStep, - "account-status": AccountStatusStep, - "complete-account": CompleteAccountStep, - "migrate-account": MigrateAccountStep, - success: SuccessStep, -}; +type StepName = string; -const stepTitles: Record = { +const stepTitles: Record = { email: { title: "Get Started", subtitle: "Enter your email to begin", @@ -35,15 +28,19 @@ const stepTitles: Record = title: "Verify Your Email", subtitle: "Enter the code we sent to your email", }, - "account-status": { + accountStatus: { title: "Welcome", subtitle: "Let's get you set up", }, - "complete-account": { + loginRedirect: { + title: "Welcome", + subtitle: "Let's get you set up", + }, + completeAccount: { title: "Create Your Account", subtitle: "Just a few more details", }, - "migrate-account": { + migrateAccount: { title: "Set Up Your Account", subtitle: "Create your portal password", }, @@ -53,36 +50,91 @@ const stepTitles: Record = }, }; +function getTopLevelState(stateValue: unknown): string { + if (typeof stateValue === "string") return stateValue; + if (typeof stateValue === "object" && stateValue !== null) { + return Object.keys(stateValue)[0] ?? "idle"; + } + return "idle"; +} + interface GetStartedFormProps { /** Callback when step changes (for parent to update title) */ - onStepChange?: (step: GetStartedStep, meta: { title: string; subtitle: string }) => void; + onStepChange?: (step: StepName, meta: { title: string; subtitle: string }) => void; } export function GetStartedForm({ onStepChange }: GetStartedFormProps) { - const { step, reset } = useGetStartedStore(); + const { state, send } = useGetStartedMachine(); + + const topState = getTopLevelState(state.value); // Reset form on mount to ensure clean state (but not if coming from handoff) useEffect(() => { - // Check if user is coming from eligibility check handoff const hasHandoffParam = window.location.search.includes("handoff="); const hasHandoffToken = sessionStorage.getItem("get-started-handoff-token"); + const hasVerifiedParam = window.location.search.includes("verified="); - // Don't reset if we have handoff data - let GetStartedView pre-fill the form - if (!hasHandoffParam && !hasHandoffToken) { - reset(); + if (!hasHandoffParam && !hasHandoffToken && !hasVerifiedParam) { + send({ type: "RESET" }); } - }, [reset]); + }, [send]); // Notify parent of step changes useEffect(() => { - onStepChange?.(step, stepTitles[step]); - }, [step, onStepChange]); + const meta = stepTitles[topState] ?? stepTitles["email"]!; + onStepChange?.(topState, meta); + }, [topState, onStepChange]); - const StepComponent = stepComponents[step]; + // Auto-start the machine if it's in idle state + useEffect(() => { + if (topState === "idle") { + send({ type: "START" }); + } + }, [topState, send]); - return ( -
- -
- ); + switch (topState) { + case "email": + return ( +
+ +
+ ); + case "verification": + return ( +
+ +
+ ); + case "accountStatus": + case "loginRedirect": + return ( +
+ +
+ ); + case "completeAccount": + return ( +
+ +
+ ); + case "migrateAccount": + return ( +
+ +
+ ); + case "success": + return ( +
+ +
+ ); + default: + return ( +
+ +
+ ); + } } diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx index bd885fed..70b4f161 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx @@ -1,248 +1,58 @@ /** - * AccountStatusStep - Shows account status and routes to appropriate next step + * AccountStatusStep - Shows login UI for portal_exists accounts * - * Routes based on account status: - * - portal_exists: Show login form inline (or redirect link in full-page mode) - * - whmcs_unmapped: Go to migrate-account step (passwordless, email verification = identity proof) - * - sf_unmapped: Go to complete-account step (pre-filled form) - * - new_customer: Go to complete-account step (full signup) + * With XState's `always` transitions on accountStatus, only portal_exists + * reaches this component (via loginRedirect state). Other statuses + * (whmcs_unmapped, sf_unmapped, new_customer) auto-route to their + * respective steps (migrateAccount, completeAccount). */ "use client"; import { Button } from "@/components/atoms"; -import { - CheckCircleIcon, - UserCircleIcon, - ArrowRightIcon, - DocumentCheckIcon, -} from "@heroicons/react/24/outline"; -import { CheckCircle2 } from "lucide-react"; +import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm"; import { getSafeRedirect } from "@/features/auth/utils/route-protection"; -import { useGetStartedStore } from "../../../stores/get-started.store"; +import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; export function AccountStatusStep() { - const { accountStatus, formData, goToStep, prefill, inline, redirectTo, serviceContext } = - useGetStartedStore(); + const { state } = useGetStartedMachine(); + const { formData, inline, redirectTo, serviceContext } = state.context; - // Compute effective redirect URL from store state (with validation) - const effectiveRedirectTo = getSafeRedirect( - redirectTo || serviceContext?.redirectTo, - "/account/dashboard" - ); + // Compute effective redirect URL from machine context (with validation) + const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); - // Portal exists - show login form inline or redirect to login page - if (accountStatus === "portal_exists") { - // Inline mode: render login form directly - if (inline) { - return ( -
-
-
-
- -
-
- -
-

Account Found

-

- You already have a portal account with this email. Please log in to continue. -

-
-
- - -
- ); - } - - // Full-page mode: redirect to login page - const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`; - return ( -
-
-
- -
-
- -
-

Account Found

-

- You already have a portal account with this email. Please log in to continue. -

-
- - -
- ); - } - - // WHMCS exists but not mapped - go to migrate-account step (passwordless) - // Email verification already proves identity - no legacy password needed - if (accountStatus === "whmcs_unmapped") { + // Inline mode: render login form directly + if (inline) { return (
-
- +
+
-

- {prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"} -

+

Account Found

- We found your existing billing account. Set up your new portal password to continue. + You already have a portal account with this email. Please log in to continue.

- {/* Show what's pre-filled vs what's needed */} -
-
-

Your account info:

-
    -
  • - - Email verified -
  • - {(prefill?.firstName || prefill?.lastName) && ( -
  • - - Name on file -
  • - )} - {prefill?.phone && ( -
  • - - Phone number on file -
  • - )} - {prefill?.address && ( -
  • - - Address on file -
  • - )} -
-
- -
-

What you'll add:

-
    -
  • - - New portal password -
  • -
-
-
- - +
); } - // SF exists but not mapped - complete account with pre-filled data - if (accountStatus === "sf_unmapped") { - return ( -
-
-
-
- -
-
- -
-

- {prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"} -

-

- We found your information from a previous inquiry. Complete a few more details to - activate your account. -

-
-
- - {/* Show what's pre-filled vs what's needed */} -
-
-

What we have:

-
    -
  • - - Name and email verified -
  • - {prefill?.address && ( -
  • - - Address from your inquiry -
  • - )} -
-
- -
-

What you'll add:

-
    -
  • - - Phone number -
  • -
  • - - Date of birth -
  • -
  • - - Password -
  • -
-
-
- - {prefill?.eligibilityStatus && ( -

- Eligibility Status: {prefill.eligibilityStatus} -

- )} - - -
- ); - } - - // New customer - proceed to full signup + // Full-page mode: redirect to login page + const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`; return (
@@ -252,18 +62,19 @@ export function AccountStatusStep() {
-

Email Verified!

+

Account Found

- Great! Let's set up your account so you can access all our services. + You already have a portal account with this email. Please log in to continue.

); diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx index 748ef27c..1c26ea33 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx @@ -11,7 +11,7 @@ import { Button } from "@/components/atoms"; import { getSafeRedirect } from "@/features/auth/utils/route-protection"; import { TermsCheckbox, MarketingCheckbox } from "@/features/auth/components"; -import { useGetStartedStore } from "../../../stores/get-started.store"; +import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; import { useRouter } from "next/navigation"; import { PrefilledUserInfo, @@ -21,27 +21,16 @@ import { PasswordSection, useCompleteAccountForm, } from "./complete-account"; +import type { GetStartedFormData } from "../../../machines/get-started.types"; export function CompleteAccountStep() { const router = useRouter(); - const { - formData, - updateFormData, - completeAccount, - prefill, - accountStatus, - loading, - error, - clearError, - goBack, - redirectTo, - serviceContext, - } = useGetStartedStore(); + const { state, send } = useGetStartedMachine(); - const effectiveRedirectTo = getSafeRedirect( - redirectTo || serviceContext?.redirectTo, - "/account/dashboard" - ); + const { formData, prefill, accountStatus, redirectTo, serviceContext, error } = state.context; + const loading = state.matches({ completeAccount: "loading" }); + + const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); const isNewCustomer = accountStatus === "new_customer"; const hasPrefill = !!(prefill?.firstName || prefill?.lastName); @@ -57,6 +46,10 @@ export function CompleteAccountStep() { const isSfUnmappedWithIncompleteAddress = accountStatus === "sf_unmapped" && !hasCompleteAddress; const needsAddress = isNewCustomer || isSfUnmappedWithIncompleteAddress; + const updateFormData = (data: Partial) => { + send({ type: "UPDATE_FORM_DATA", data }); + }; + const form = useCompleteAccountForm({ initialValues: { firstName: formData.firstName || prefill?.firstName, @@ -72,15 +65,18 @@ export function CompleteAccountStep() { updateFormData, }); - const handleSubmit = async () => { - clearError(); + const handleSubmit = () => { if (!form.validate()) return; - updateFormData(form.getFormData()); - const result = await completeAccount(); - if (result) router.push(effectiveRedirectTo); + const completeFormData = { ...formData, ...form.getFormData() }; + send({ type: "COMPLETE", formData: completeFormData as GetStartedFormData }); }; + // Redirect on success + if (state.matches("success")) { + router.push(effectiveRedirectTo); + } + return (
@@ -173,7 +169,7 @@ export function CompleteAccountStep() {