diff --git a/apps/bff/src/integrations/salesforce/constants/field-maps.ts b/apps/bff/src/integrations/salesforce/constants/field-maps.ts index 8d8cbb39..f0c2bc77 100644 --- a/apps/bff/src/integrations/salesforce/constants/field-maps.ts +++ b/apps/bff/src/integrations/salesforce/constants/field-maps.ts @@ -399,6 +399,9 @@ export const CASE_FIELDS = { suppliedEmail: "SuppliedEmail", suppliedName: "SuppliedName", suppliedPhone: "SuppliedPhone", + + // Eligibility address key for duplicate detection (postcode:streetAddress) + eligibilityAddressKey: "Eligibility_Address_Key__c", } as const; export type CaseFieldKey = keyof typeof CASE_FIELDS; diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index eba4e972..d81fd388 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -66,6 +66,8 @@ export interface CreateCaseParams { contactId?: string | undefined; /** Optional Opportunity ID for workflow cases */ opportunityId?: string | undefined; + /** Eligibility address key for duplicate detection (postcode:streetAddress) */ + eligibilityAddressKey?: string | undefined; } /** @@ -210,6 +212,11 @@ export class SalesforceCaseService { casePayload[CASE_FIELDS.opportunityId] = safeOpportunityId; } + // Add eligibility address key for duplicate detection (if provided) + if (params.eligibilityAddressKey) { + casePayload[CASE_FIELDS.eligibilityAddressKey] = params.eligibilityAddressKey; + } + try { const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; diff --git a/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts b/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts index bde2d74a..7b684c06 100644 --- a/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts +++ b/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts @@ -14,6 +14,7 @@ import type { WhmcsAddClientResponse, WhmcsValidateLoginResponse, WhmcsSsoResponse, + WhmcsGetUsersResponse, } from "@customer-portal/domain/customer/providers"; import type { WhmcsGetInvoicesParams, @@ -204,6 +205,17 @@ export class WhmcsConnectionFacade implements OnModuleInit { return this.makeRequest("ValidateLogin", params); } + /** + * Get users by email address + * Used to discover if a user account exists in WHMCS + */ + async getUsersByEmail(email: string): Promise { + return this.makeRequest("GetUsers", { + search: email, + limitnum: 10, + }); + } + // ========================================== // INVOICE API METHODS // ========================================== 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 fe29b6fd..7ad0d504 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 @@ -3,7 +3,10 @@ import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { WhmcsConnectionFacade } from "../facades/whmcs.facade.js"; import { WhmcsCacheService } from "../cache/whmcs-cache.service.js"; -import { transformWhmcsClientResponse } from "@customer-portal/domain/customer/providers"; +import { + transformWhmcsClientResponse, + whmcsGetUsersResponseSchema, +} from "@customer-portal/domain/customer/providers"; import type { WhmcsClient } from "@customer-portal/domain/customer"; /** @@ -74,6 +77,81 @@ export class WhmcsAccountDiscoveryService { } } + /** + * Find a user by email address. + * WHMCS users are sub-accounts under clients. If a user exists, we return the associated client ID. + * This handles cases where the email belongs to a user (sub-account) rather than the main client. + * + * @see https://developers.whmcs.com/api-reference/getusers/ + */ + async findUserByEmail(email: string): Promise<{ userId: number; clientId: number } | null> { + try { + const rawResponse = await this.connectionService.getUsersByEmail(email); + + // Validate response matches expected schema + const response = whmcsGetUsersResponseSchema.parse(rawResponse); + + if (!response.users || response.users.length === 0) { + return null; + } + + // Find an exact email match (search parameter matches start of email/name, so we need exact match) + const exactMatch = response.users.find( + user => user.email.toLowerCase() === email.toLowerCase() + ); + + if (!exactMatch) { + return null; + } + + // Get the first associated client (users can belong to multiple clients) + const clientAssociation = exactMatch.clients?.[0]; + if (!clientAssociation) { + this.logger.warn(`User ${exactMatch.id} found but has no associated clients`); + return null; + } + + this.logger.log( + `Discovered user by email: ${email} (user: ${exactMatch.id}, client: ${clientAssociation.id})` + ); + return { + userId: Number(exactMatch.id), + clientId: Number(clientAssociation.id), + }; + } catch (error) { + this.logger.warn( + { + email, + error: extractErrorMessage(error), + }, + "Failed to discover user by email" + ); + return null; + } + } + + /** + * Find either a client or user by email. + * First checks for a client, then falls back to checking for a user. + * Returns the client data if found via either method. + */ + async findAccountByEmail(email: string): Promise { + // First, try to find a client directly + const client = await this.findClientByEmail(email); + if (client) { + return client; + } + + // If no client found, check for a user (sub-account) + const user = await this.findUserByEmail(email); + if (user) { + // User found - fetch the associated client + return this.getClientDetailsById(user.clientId); + } + + return null; + } + /** * Helper to get details by ID, reusing the cache logic from ClientService logic * We duplicate this small fetch to avoid circular dependency or tight coupling with WhmcsClientService diff --git a/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts index eec3c4d1..554872bf 100644 --- a/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts +++ b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts @@ -22,7 +22,7 @@ interface SessionData extends Omit { /** Timestamp when session was marked as used (for one-time operations) */ usedAt?: string; /** The operation that used this session */ - usedFor?: "signup_with_eligibility" | "complete_account"; + usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account"; } /** @@ -134,12 +134,25 @@ export class GetStartedSessionService { const sessionData = await this.cache.get(this.buildKey(sessionToken)); if (!sessionData) return false; - const updatedData: SessionData = { - ...sessionData, - emailVerified: true, - accountStatus, - ...prefillData, - }; + // Use buildSessionData to properly filter undefined values + const updatedData = buildSessionData( + { + id: sessionData.id, + email: sessionData.email, + emailVerified: true, + createdAt: sessionData.createdAt, + }, + { + accountStatus, + firstName: prefillData?.firstName, + lastName: prefillData?.lastName, + phone: prefillData?.phone, + address: prefillData?.address, + sfAccountId: prefillData?.sfAccountId, + whmcsClientId: prefillData?.whmcsClientId, + eligibilityStatus: prefillData?.eligibilityStatus, + } + ); const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt); await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl); @@ -222,6 +235,45 @@ export class GetStartedSessionService { return tokenId; } + /** + * Retrieve and validate a guest handoff token + * + * Returns the token data if valid, null otherwise. + * The token email must match the verified email for security. + */ + async getGuestHandoffToken( + tokenId: string, + verifiedEmail: string + ): Promise { + const tokenData = await this.cache.get(this.buildHandoffKey(tokenId)); + + if (!tokenData) { + this.logger.debug({ tokenId }, "Guest handoff token not found or expired"); + return null; + } + + // Security: Ensure the token email matches the verified email + const normalizedVerifiedEmail = verifiedEmail.toLowerCase().trim(); + if (tokenData.email !== normalizedVerifiedEmail) { + this.logger.warn( + { tokenId, tokenEmail: tokenData.email, verifiedEmail: normalizedVerifiedEmail }, + "Guest handoff token email mismatch - possible security issue" + ); + return null; + } + + this.logger.debug({ tokenId, email: normalizedVerifiedEmail }, "Guest handoff token retrieved"); + return tokenData; + } + + /** + * Delete a guest handoff token after it has been used + */ + async invalidateHandoffToken(tokenId: string): Promise { + await this.cache.del(this.buildHandoffKey(tokenId)); + this.logger.debug({ tokenId }, "Guest handoff token invalidated"); + } + // ============================================================================ // Session Locking (Idempotency Protection) // ============================================================================ 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 index 67f418ca..aad7fca7 100644 --- 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 @@ -17,7 +17,12 @@ import { 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"; @@ -27,6 +32,7 @@ import { SalesforceAccountService } from "@bff/integrations/salesforce/services/ 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"; @@ -39,6 +45,7 @@ import { SignupUserCreationService } from "./signup/signup-user-creation.service 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"; @@ -78,6 +85,7 @@ export class GetStartedWorkflowService { 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, @@ -138,7 +146,7 @@ export class GetStartedWorkflowService { * @param fingerprint - Optional request fingerprint for session binding check */ async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise { - const { email, code } = request; + const { email, code, handoffToken } = request; const normalizedEmail = email.toLowerCase().trim(); // Verify OTP (with fingerprint check - logs warning if different context) @@ -164,7 +172,39 @@ export class GetStartedWorkflowService { const accountStatus = await this.determineAccountStatus(normalizedEmail); // Get prefill data if account exists - const prefill = this.getPrefillData(normalizedEmail, accountStatus); + let prefill = this.getPrefillData(normalizedEmail, accountStatus); + + // If handoff token provided (from guest eligibility check), retrieve and apply its data + if (handoffToken) { + const handoffData = await this.sessionService.getGuestHandoffToken( + handoffToken, + normalizedEmail + ); + + if (handoffData) { + this.logger.debug( + { email: normalizedEmail, 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); + } + } // Update session with verified status and account info // Build prefill data object without undefined values (exactOptionalPropertyTypes) @@ -186,7 +226,7 @@ export class GetStartedWorkflowService { ); this.logger.log( - { email: normalizedEmail, accountStatus: accountStatus.status }, + { email: normalizedEmail, accountStatus: accountStatus.status, hasHandoff: !!handoffToken }, "Email verified and account status determined" ); @@ -351,25 +391,36 @@ export class GetStartedWorkflowService { } const session = sessionResult.session; + const { + password, + phone, + dateOfBirth, + gender, + firstName, + lastName, + address: requestAddress, + } = request; - if (!session.sfAccountId) { - throw new BadRequestException("No Salesforce account found. Please check eligibility first."); + // Determine if this is a new customer (no SF account) or SF-only user + const isNewCustomer = !session.sfAccountId; + + // For new customers, name and address must be provided in request + if (isNewCustomer) { + if (!firstName || !lastName) { + throw new BadRequestException("First name and last name are required for new accounts."); + } + if (!requestAddress) { + throw new BadRequestException("Address is required for new accounts."); + } } - const { password, phone, dateOfBirth, gender } = request; const lockKey = `complete-account:${session.email}`; try { return await this.lockService.withLock( lockKey, async () => { - // Verify SF account still exists - const existingSf = await this.salesforceAccountService.findByEmail(session.email); - if (!existingSf || existingSf.id !== session.sfAccountId) { - throw new BadRequestException("Account verification failed. Please start over."); - } - - // Check for existing WHMCS client (shouldn't exist for SF-only flow) + // Check for existing WHMCS client const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(session.email); if (existingWhmcs) { throw new ConflictException( @@ -383,18 +434,72 @@ export class GetStartedWorkflowService { throw new ConflictException("An account already exists. Please log in."); } + // Resolve SF account (existing or create new) + let sfAccountId: string; + let customerNumber: string | undefined; + + if (session.sfAccountId) { + // SF-only user: Verify SF account still exists + const existingSf = await this.salesforceAccountService.findByEmail(session.email); + if (!existingSf || existingSf.id !== session.sfAccountId) { + throw new BadRequestException("Account verification failed. Please start over."); + } + sfAccountId = existingSf.id; + customerNumber = existingSf.accountNumber; + } else { + // New customer: Check if SF account exists or create new one + const existingSf = await this.salesforceAccountService.findByEmail(session.email); + if (existingSf) { + sfAccountId = existingSf.id; + customerNumber = existingSf.accountNumber; + } else { + // Create new SF Account + const { accountId, accountNumber } = + await this.salesforceAccountService.createAccount({ + firstName: firstName!, + lastName: lastName!, + email: session.email, + phone: phone ?? "", + portalSource: PORTAL_SOURCE_NEW_SIGNUP, + }); + sfAccountId = accountId; + customerNumber = accountNumber; + + this.logger.log( + { email: session.email, sfAccountId }, + "Created SF account for new customer" + ); + } + } + const passwordHash = await argon2.hash(password); - // Get address from session or SF - const address = session.address; - if (!address || !address.address1 || !address.city || !address.postcode) { - throw new BadRequestException("Address information is incomplete."); + // Use address from request if provided, otherwise from session + const address = requestAddress ?? session.address; + 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." + ); + } + + // Use name from request if provided, otherwise from session + const finalFirstName = firstName ?? session.firstName; + const finalLastName = lastName ?? session.lastName; + + if (!finalFirstName || !finalLastName) { + throw new BadRequestException("Name information is missing. Please provide your name."); } // Create WHMCS client const whmcsClient = await this.whmcsSignup.createClient({ - firstName: session.firstName!, - lastName: session.lastName!, + firstName: finalFirstName, + lastName: finalLastName, email: session.email, password, phone, @@ -402,11 +507,11 @@ export class GetStartedWorkflowService { address1: address.address1, ...(address.address2 && { address2: address.address2 }), city: address.city, - state: address.state ?? "", + state: address.state, postcode: address.postcode, country: address.country ?? "Japan", }, - customerNumber: existingSf.accountNumber, + customerNumber: customerNumber ?? null, dateOfBirth, gender, }); @@ -416,7 +521,7 @@ export class GetStartedWorkflowService { email: session.email, passwordHash, whmcsClientId: whmcsClient.clientId, - sfAccountId: session.sfAccountId, + sfAccountId, }); // Fetch fresh user and generate tokens @@ -428,7 +533,7 @@ export class GetStartedWorkflowService { await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { email: session.email, whmcsClientId: whmcsClient.clientId, - source: "get_started_complete_account", + source: isNewCustomer ? "get_started_new_customer" : "get_started_complete_account", }); const profile = mapPrismaUserToDomain(freshUser); @@ -438,14 +543,14 @@ export class GetStartedWorkflowService { }); // Update Salesforce portal flags - await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId); + await this.updateSalesforcePortalFlags(sfAccountId, whmcsClient.clientId); // Invalidate session (fully done) await this.sessionService.invalidate(request.sessionToken); this.logger.log( - { email: session.email, userId }, - "Account completed successfully for SF-only user" + { email: session.email, userId, isNewCustomer }, + "Account completed successfully" ); return { @@ -672,6 +777,225 @@ export class GetStartedWorkflowService { } } + // ============================================================================ + // 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 + * 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 + 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("Failed to load created 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) customfieldsMap[dobFieldId] = dateOfBirth; + if (genderFieldId) 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 // ============================================================================ @@ -750,8 +1074,8 @@ export class GetStartedWorkflowService { } } - // Check WHMCS client - const whmcsClient = await this.whmcsDiscovery.findClientByEmail(email); + // 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); @@ -801,6 +1125,10 @@ export class GetStartedWorkflowService { 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, @@ -813,6 +1141,7 @@ export class GetStartedWorkflowService { postcode: address.postcode, ...(address.country ? { country: address.country } : {}), }, + ...(streetAddress ? { streetAddress } : {}), }); // Update Account eligibility status to Pending @@ -836,7 +1165,10 @@ export class GetStartedWorkflowService { 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 + source?: + | typeof PORTAL_SOURCE_NEW_SIGNUP + | typeof PORTAL_SOURCE_INTERNET_ELIGIBILITY + | typeof PORTAL_SOURCE_MIGRATED ): Promise { try { await this.salesforceService.updateAccountPortalFields(accountId, { 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 cfc9333c..f3cad3f1 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 @@ -16,6 +16,7 @@ import { completeAccountRequestSchema, signupWithEligibilityRequestSchema, signupWithEligibilityResponseSchema, + migrateWhmcsAccountRequestSchema, } from "@customer-portal/domain/get-started"; import type { User } from "@customer-portal/domain/customer"; @@ -32,6 +33,7 @@ class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseS class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {} class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {} class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {} +class MigrateWhmcsAccountRequestDto extends createZodDto(migrateWhmcsAccountRequestSchema) {} interface AuthSuccessResponse { user: User; @@ -167,4 +169,30 @@ export class GetStartedController { session: buildSessionInfo(result.authResult.tokens), }; } + + /** + * Migrate WHMCS account to portal (passwordless) + * + * For whmcs_unmapped users after email verification. + * Email verification serves as identity proof - no legacy password needed. + * Creates portal user, syncs password to WHMCS, and returns auth tokens. + */ + @Public() + @Post("migrate-whmcs-account") + @HttpCode(200) + @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) + @RateLimit({ limit: 5, ttl: 900 }) + async migrateWhmcsAccount( + @Body() body: MigrateWhmcsAccountRequestDto, + @Res({ passthrough: true }) res: Response + ): Promise { + const result = await this.workflow.migrateWhmcsAccount(body); + + setAuthCookies(res, result.tokens); + + return { + user: result.user, + session: buildSessionInfo(result.tokens), + }; + } } diff --git a/apps/bff/src/modules/services/application/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts index 4c6f0d70..e66bc4dd 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.service.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.service.ts @@ -116,6 +116,7 @@ export class InternetEligibilityService { if (request.address.postcode) eligibilityAddress["postcode"] = request.address.postcode; if (request.address.country) eligibilityAddress["country"] = request.address.country; + // Note: streetAddress not passed in this flow (basic address type) - duplicate detection will be skipped await this.workflowCases.notifyEligibilityCheck({ accountId: sfAccountId, opportunityId, diff --git a/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts index e8c8ba3b..9182e686 100644 --- a/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts +++ b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts @@ -92,9 +92,12 @@ export class WorkflowCaseManager { /** * Create a case for internet eligibility check request. * Non-critical: logs warning on failure, does not throw. + * + * Stores an address key (postcode:streetAddress) for Salesforce Flow + * to use for duplicate detection. */ async notifyEligibilityCheck(params: EligibilityCheckCaseParams): Promise { - const { accountId, address, opportunityId, opportunityCreated } = params; + const { accountId, address, streetAddress, opportunityId, opportunityCreated } = params; try { const opportunityLink = opportunityId @@ -107,6 +110,9 @@ export class WorkflowCaseManager { const formattedAddress = this.formatAddress(address); + // Create address key for Salesforce Flow duplicate detection + const addressKey = this.createAddressKey(address.postcode, streetAddress); + const description = this.buildDescription([ "Customer requested to check if internet service is available at the following address:", "", @@ -122,12 +128,15 @@ export class WorkflowCaseManager { subject: "Internet availability check request (Portal)", description, origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, + // Store address key for Salesforce Flow duplicate detection + eligibilityAddressKey: addressKey ?? undefined, }); this.logger.log("Created eligibility check case", { accountIdTail: accountId.slice(-4), opportunityIdTail: opportunityId?.slice(-4), opportunityCreated, + hasAddressKey: !!addressKey, }); } catch (error) { this.logger.warn("Failed to create eligibility check case", { @@ -328,4 +337,24 @@ export class WorkflowCaseManager { .filter(Boolean) .join(", "); } + + /** + * Create an address key for Salesforce Flow duplicate detection. + * + * Format: "{postcode}:{streetAddress}" + * Example: "1060045:1-5-3" + * + * @param postcode - ZIP code (e.g., "1060045") + * @param streetAddress - Street address detail (e.g., "1-5-3") + * @returns Combined address key, or null if either value is missing + */ + private createAddressKey( + postcode: string | undefined | null, + streetAddress: string | undefined | null + ): string | null { + if (!postcode || !streetAddress) { + return null; + } + return `${postcode}:${streetAddress}`; + } } diff --git a/apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts b/apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts index 54c131c4..db689984 100644 --- a/apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts +++ b/apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts @@ -44,6 +44,12 @@ export interface EligibilityCheckCaseParams extends BaseWorkflowCaseParams { postcode?: string; country?: string; }; + /** + * Street address detail from Japan Post form (e.g., "1-5-3"). + * Used with postcode for duplicate detection. + * Optional for backward compatibility with existing callers. + */ + streetAddress?: string; /** Whether a new Opportunity was created for this request */ opportunityCreated: boolean; } diff --git a/apps/portal/src/features/get-started/api/get-started.api.ts b/apps/portal/src/features/get-started/api/get-started.api.ts index f43aad2f..7c7fb2d9 100644 --- a/apps/portal/src/features/get-started/api/get-started.api.ts +++ b/apps/portal/src/features/get-started/api/get-started.api.ts @@ -19,6 +19,7 @@ import { type CompleteAccountRequest, type SignupWithEligibilityRequest, type SignupWithEligibilityResponse, + type MigrateWhmcsAccountRequest, } from "@customer-portal/domain/get-started"; import { authResponseSchema, @@ -136,3 +137,20 @@ export function isSignupSuccess( ): response is SignupWithEligibilitySuccessResponse { return response.success === true && "user" in response && "session" in response; } + +/** + * Migrate WHMCS account to portal (passwordless) + * + * For whmcs_unmapped users after email verification. + * Email verification serves as identity proof - no legacy password needed. + * Creates portal user, syncs password to WHMCS, and returns auth tokens. + */ +export async function migrateWhmcsAccount( + request: MigrateWhmcsAccountRequest +): Promise { + const response = await apiClient.POST(`${BASE_PATH}/migrate-whmcs-account`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to migrate account"); + return authResponseSchema.parse(data); +} 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 5df9cb11..76f9eb32 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx @@ -13,6 +13,7 @@ import { VerificationStep, AccountStatusStep, CompleteAccountStep, + MigrateAccountStep, SuccessStep, } from "./steps"; @@ -21,6 +22,7 @@ const stepComponents: Record = { verification: VerificationStep, "account-status": AccountStatusStep, "complete-account": CompleteAccountStep, + "migrate-account": MigrateAccountStep, success: SuccessStep, }; @@ -41,6 +43,10 @@ const stepTitles: Record = title: "Create Your Account", subtitle: "Just a few more details", }, + "migrate-account": { + title: "Set Up Your Account", + subtitle: "Create your portal password", + }, success: { title: "Account Created!", subtitle: "You're all set", 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 1efcf09f..13f0266f 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 @@ -3,14 +3,13 @@ * * Routes based on account status: * - portal_exists: Show login form inline (or redirect link in full-page mode) - * - whmcs_unmapped: Show migrate 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) */ "use client"; -import { useRouter } from "next/navigation"; import { Button } from "@/components/atoms"; import { CheckCircleIcon, @@ -20,16 +19,18 @@ import { } from "@heroicons/react/24/outline"; import { CheckCircle2 } from "lucide-react"; import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm"; -import { LinkWhmcsForm } from "@/features/auth/components/LinkWhmcsForm/LinkWhmcsForm"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; import { useGetStartedStore } from "../../../stores/get-started.store"; export function AccountStatusStep() { - const router = useRouter(); const { accountStatus, formData, goToStep, prefill, inline, redirectTo, serviceContext } = useGetStartedStore(); - // Compute effective redirect URL from store state - const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard"; + // Compute effective redirect URL from store state (with validation) + const effectiveRedirectTo = getSafeRedirect( + redirectTo || serviceContext?.redirectTo, + "/account/dashboard" + ); // Portal exists - show login form inline or redirect to login page if (accountStatus === "portal_exists") { @@ -91,71 +92,79 @@ export function AccountStatusStep() { ); } - // WHMCS exists but not mapped - show migrate form inline or redirect to migrate page + // 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 migrate form directly - if (inline) { - return ( -
-
-
-
- -
-
- -
-

Existing Account Found

-

- We found an existing billing account with this email. Please verify your password to - link it to your new portal account. -

-
-
- - { - if (result.needsPasswordSet) { - const params = new URLSearchParams({ - email: result.user.email, - redirect: effectiveRedirectTo, - }); - router.push(`/auth/set-password?${params.toString()}`); - return; - } - router.push(effectiveRedirectTo); - }} - /> -
- ); - } - - // Full-page mode: redirect to migrate page - const migrateUrl = `/auth/migrate?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`; return ( -
-
-
- +
+
+
+
+ +
+
+ +
+

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

+

+ We found your existing billing account. Set up your new portal password to continue. +

-
-

Existing Account Found

-

- We found an existing billing account with this email. Please verify your password to - link it to your new portal account. -

+ {/* 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:

+
    +
  • + + Date of birth +
  • +
  • + + New portal password +
  • +
+
); diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts b/apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts index d0235f12..262df584 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts @@ -2,4 +2,5 @@ export { EmailStep } from "./EmailStep"; export { VerificationStep } from "./VerificationStep"; export { AccountStatusStep } from "./AccountStatusStep"; export { CompleteAccountStep } from "./CompleteAccountStep"; +export { MigrateAccountStep } from "./MigrateAccountStep"; export { SuccessStep } from "./SuccessStep"; diff --git a/apps/portal/src/features/get-started/stores/get-started.store.ts b/apps/portal/src/features/get-started/stores/get-started.store.ts index 12383896..4b8d3f33 100644 --- a/apps/portal/src/features/get-started/stores/get-started.store.ts +++ b/apps/portal/src/features/get-started/stores/get-started.store.ts @@ -17,6 +17,7 @@ export type GetStartedStep = | "verification" | "account-status" | "complete-account" + | "migrate-account" | "success"; /** @@ -97,6 +98,7 @@ export interface GetStartedState { sendVerificationCode: (email: string) => Promise; verifyCode: (code: string) => Promise; completeAccount: () => Promise; + migrateWhmcsAccount: () => Promise; // Navigation goToStep: (step: GetStartedStep) => void; @@ -240,7 +242,7 @@ export const useGetStartedStore = create()((set, get) => ({ }, completeAccount: async () => { - const { sessionToken, formData } = get(); + const { sessionToken, formData, accountStatus } = get(); if (!sessionToken) { set({ error: "Session expired. Please start over." }); @@ -250,6 +252,10 @@ export const useGetStartedStore = create()((set, get) => ({ set({ loading: true, error: null }); try { + // For new customers, include name and address in request + // For SF-only users, these come from session (via handoff token) + const isNewCustomer = accountStatus === "new_customer"; + const result = await api.completeAccount({ sessionToken, password: formData.password, @@ -258,6 +264,21 @@ export const useGetStartedStore = create()((set, get) => ({ gender: formData.gender as "male" | "female" | "other", acceptTerms: formData.acceptTerms, marketingConsent: formData.marketingConsent, + // Include name and address for new customers (or if provided) + ...(isNewCustomer || formData.firstName ? { firstName: formData.firstName } : {}), + ...(isNewCustomer || formData.lastName ? { lastName: formData.lastName } : {}), + ...(isNewCustomer || formData.address?.address1 + ? { + address: { + address1: formData.address.address1 || "", + address2: formData.address.address2, + city: formData.address.city || "", + state: formData.address.state || "", + postcode: formData.address.postcode || "", + country: formData.address.country || "Japan", + }, + } + : {}), }); set({ loading: false, step: "success" }); @@ -271,18 +292,55 @@ export const useGetStartedStore = create()((set, get) => ({ } }, + migrateWhmcsAccount: async () => { + const { sessionToken, formData } = get(); + + if (!sessionToken) { + set({ error: "Session expired. Please start over." }); + return null; + } + + set({ loading: true, error: null }); + + try { + const result = await api.migrateWhmcsAccount({ + sessionToken, + password: formData.password, + dateOfBirth: formData.dateOfBirth, + gender: formData.gender as "male" | "female" | "other", + acceptTerms: formData.acceptTerms, + marketingConsent: formData.marketingConsent, + }); + + set({ loading: false, step: "success" }); + + return result; + } catch (error) { + const message = getErrorMessage(error); + logger.error("Failed to migrate WHMCS account", { error: message }); + set({ loading: false, error: message }); + return null; + } + }, + goToStep: (step: GetStartedStep) => { set({ step, error: null }); }, goBack: () => { const { step } = get(); + // Both complete-account and migrate-account go back to account-status const stepOrder: GetStartedStep[] = [ "email", "verification", "account-status", "complete-account", ]; + // For migrate-account, go back to account-status directly + if (step === "migrate-account") { + set({ step: "account-status", error: null }); + return; + } const currentIndex = stepOrder.indexOf(step); const prevStep = stepOrder[currentIndex - 1]; if (currentIndex > 0 && prevStep) { diff --git a/packages/domain/customer/providers/whmcs/raw.types.ts b/packages/domain/customer/providers/whmcs/raw.types.ts index bec957c8..edb7c334 100644 --- a/packages/domain/customer/providers/whmcs/raw.types.ts +++ b/packages/domain/customer/providers/whmcs/raw.types.ts @@ -203,3 +203,38 @@ export const whmcsSsoResponseSchema = z.object({ export type WhmcsSsoResponse = z.infer; export type WhmcsClientStats = z.infer; + +// ============================================================================ +// WHMCS Get Users Response +// ============================================================================ + +/** + * WHMCS GetUsers API response schema + * Used to look up users by email during account discovery + * + * @see https://developers.whmcs.com/api-reference/getusers/ + */ +export const whmcsUserClientAssociationSchema = z.object({ + id: numberLike, + isOwner: z.boolean().optional(), +}); + +export const whmcsGetUsersUserSchema = z.object({ + id: numberLike, + firstname: z.string().optional(), + lastname: z.string().optional(), + email: z.string(), + datecreated: z.string().optional(), + validationdata: z.string().optional(), + clients: z.array(whmcsUserClientAssociationSchema).optional().default([]), +}); + +export const whmcsGetUsersResponseSchema = z.object({ + totalresults: numberLike, + startnumber: numberLike.optional(), + numreturned: numberLike.optional(), + users: z.array(whmcsGetUsersUserSchema).optional().default([]), +}); + +export type WhmcsGetUsersUser = z.infer; +export type WhmcsGetUsersResponse = z.infer; diff --git a/packages/domain/get-started/contract.ts b/packages/domain/get-started/contract.ts index 7a30a7f6..af220144 100644 --- a/packages/domain/get-started/contract.ts +++ b/packages/domain/get-started/contract.ts @@ -23,6 +23,7 @@ import type { completeAccountRequestSchema, signupWithEligibilityRequestSchema, signupWithEligibilityResponseSchema, + migrateWhmcsAccountRequestSchema, getStartedSessionSchema, } from "./schema.js"; @@ -90,6 +91,7 @@ export type BilingualEligibilityAddress = z.infer; export type CompleteAccountRequest = z.infer; export type SignupWithEligibilityRequest = z.infer; +export type MigrateWhmcsAccountRequest = z.infer; // ============================================================================ // Response Types diff --git a/packages/domain/get-started/index.ts b/packages/domain/get-started/index.ts index 7266c08d..6ea68ab7 100644 --- a/packages/domain/get-started/index.ts +++ b/packages/domain/get-started/index.ts @@ -31,6 +31,7 @@ export { type CompleteAccountRequest, type SignupWithEligibilityRequest, type SignupWithEligibilityResponse, + type MigrateWhmcsAccountRequest, type GetStartedSession, type GetStartedError, } from "./contract.js"; @@ -57,6 +58,8 @@ export { // Signup with eligibility schemas (full inline signup) signupWithEligibilityRequestSchema, signupWithEligibilityResponseSchema, + // WHMCS migration schema (passwordless migration) + migrateWhmcsAccountRequestSchema, // Session schema getStartedSessionSchema, } from "./schema.js"; diff --git a/packages/domain/get-started/schema.ts b/packages/domain/get-started/schema.ts index 5cd247a3..f6311166 100644 --- a/packages/domain/get-started/schema.ts +++ b/packages/domain/get-started/schema.ts @@ -199,12 +199,21 @@ export const guestHandoffTokenSchema = z.object({ // ============================================================================ /** - * Request to complete account for SF-only users - * Creates WHMCS client and Portal user, links to existing SF Account + * Request to complete account for SF-only users or new customers + * Creates WHMCS client and Portal user, links to existing SF Account (if any) + * + * For SF-only users: name/address comes from session (prefilled from handoff token) + * For new customers: name/address must be provided in the request */ export const completeAccountRequestSchema = z.object({ /** Session token from verified email */ sessionToken: z.string().min(1, "Session token is required"), + /** Customer first name (required for new customers, optional for SF-only) */ + firstName: nameSchema.optional(), + /** Customer last name (required for new customers, optional for SF-only) */ + lastName: nameSchema.optional(), + /** Address (required for new customers, optional for SF-only who have it in session) */ + address: addressFormSchema.optional(), /** Password for the new portal account */ password: passwordSchema, /** Phone number (may be pre-filled from SF) */ @@ -267,6 +276,32 @@ export const signupWithEligibilityResponseSchema = z.object({ eligibilityRequestId: z.string().optional(), }); +// ============================================================================ +// WHMCS Migration Schema (Passwordless Migration) +// ============================================================================ + +/** + * Request to migrate WHMCS account to portal without legacy password + * For whmcs_unmapped users after email verification + * Creates portal user and syncs password to WHMCS + */ +export const migrateWhmcsAccountRequestSchema = z.object({ + /** Session token from verified email */ + sessionToken: z.string().min(1, "Session token is required"), + /** Password for the new portal account (will also sync to WHMCS) */ + password: passwordSchema, + /** Date of birth */ + dateOfBirth: isoDateOnlySchema, + /** Gender */ + gender: genderEnum, + /** Accept terms of service */ + acceptTerms: z.boolean().refine(val => val === true, { + message: "You must accept the terms of service", + }), + /** Marketing consent */ + marketingConsent: z.boolean().optional(), +}); + // ============================================================================ // Session Schema // ============================================================================ diff --git a/packages/domain/support/providers/salesforce/raw.types.ts b/packages/domain/support/providers/salesforce/raw.types.ts index 4f6bb620..6ce35395 100644 --- a/packages/domain/support/providers/salesforce/raw.types.ts +++ b/packages/domain/support/providers/salesforce/raw.types.ts @@ -133,6 +133,9 @@ export const salesforceCaseRecordSchema = z.object({ Department__c: z.string().nullable().optional(), // Picklist Comment__c: z.string().nullable().optional(), // Long Text Area(32768) Notes__c: z.string().nullable().optional(), // Long Text Area(32768) + + // Eligibility address key for duplicate detection (postcode:streetAddress) + Eligibility_Address_Key__c: z.string().nullable().optional(), // Text(50) }); export type SalesforceCaseRecord = z.infer;