feat(eligibility): add address key field for Salesforce Flow duplicate detection
Add Eligibility_Address_Key__c field (postcode:streetAddress format) to Case records for eligibility check requests. Salesforce Flow will use this field to detect duplicate requests for the same address within a time period. Changes: - Add eligibilityAddressKey to CreateCaseParams and CASE_FIELDS mapping - Add streetAddress param to EligibilityCheckCaseParams for passing from callers - Add createAddressKey() helper in WorkflowCaseManager to generate the key - Update raw.types.ts schema with new field - Note in internet-eligibility.service.ts that basic flow skips duplicate detection
This commit is contained in:
parent
04fd0ea233
commit
464f98284a
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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<WhmcsValidateLoginResponse>("ValidateLogin", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users by email address
|
||||
* Used to discover if a user account exists in WHMCS
|
||||
*/
|
||||
async getUsersByEmail(email: string): Promise<WhmcsGetUsersResponse> {
|
||||
return this.makeRequest<WhmcsGetUsersResponse>("GetUsers", {
|
||||
search: email,
|
||||
limitnum: 10,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
@ -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<WhmcsClient | null> {
|
||||
// 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
|
||||
|
||||
@ -22,7 +22,7 @@ interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
|
||||
/** 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<SessionData>(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<GuestHandoffToken | null> {
|
||||
const tokenData = await this.cache.get<GuestHandoffToken>(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<void> {
|
||||
await this.cache.del(this.buildHandoffKey(tokenId));
|
||||
this.logger.debug({ tokenId }, "Guest handoff token invalidated");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Locking (Idempotency Protection)
|
||||
// ============================================================================
|
||||
|
||||
@ -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<VerifyCodeResponse> {
|
||||
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<AuthResultInternal> {
|
||||
// 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<void> {
|
||||
const dobFieldId = this.config.get<string>("WHMCS_DOB_FIELD_ID");
|
||||
const genderFieldId = this.config.get<string>("WHMCS_GENDER_FIELD_ID");
|
||||
|
||||
const customfieldsMap: Record<string, string> = {};
|
||||
if (dobFieldId) customfieldsMap[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId) customfieldsMap[genderFieldId] = gender;
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
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<void> {
|
||||
try {
|
||||
await this.salesforceService.updateAccountPortalFields(accountId, {
|
||||
|
||||
@ -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<AuthSuccessResponse> {
|
||||
const result = await this.workflow.migrateWhmcsAccount(body);
|
||||
|
||||
setAuthCookies(res, result.tokens);
|
||||
|
||||
return {
|
||||
user: result.user,
|
||||
session: buildSessionInfo(result.tokens),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<void> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<AuthResponse> {
|
||||
const response = await apiClient.POST<AuthResponse>(`${BASE_PATH}/migrate-whmcs-account`, {
|
||||
body: request,
|
||||
});
|
||||
const data = getDataOrThrow(response, "Failed to migrate account");
|
||||
return authResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
VerificationStep,
|
||||
AccountStatusStep,
|
||||
CompleteAccountStep,
|
||||
MigrateAccountStep,
|
||||
SuccessStep,
|
||||
} from "./steps";
|
||||
|
||||
@ -21,6 +22,7 @@ const stepComponents: Record<GetStartedStep, React.ComponentType> = {
|
||||
verification: VerificationStep,
|
||||
"account-status": AccountStatusStep,
|
||||
"complete-account": CompleteAccountStep,
|
||||
"migrate-account": MigrateAccountStep,
|
||||
success: SuccessStep,
|
||||
};
|
||||
|
||||
@ -41,6 +43,10 @@ const stepTitles: Record<GetStartedStep, { title: string; subtitle: string }> =
|
||||
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",
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<UserCircleIcon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">Existing Account Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We found an existing billing account with this email. Please verify your password to
|
||||
link it to your new portal account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkWhmcsForm
|
||||
initialEmail={formData.email}
|
||||
onTransferred={result => {
|
||||
if (result.needsPasswordSet) {
|
||||
const params = new URLSearchParams({
|
||||
email: result.user.email,
|
||||
redirect: effectiveRedirectTo,
|
||||
});
|
||||
router.push(`/auth/set-password?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
router.push(effectiveRedirectTo);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full-page mode: redirect to migrate page
|
||||
const migrateUrl = `/auth/migrate?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<UserCircleIcon className="h-8 w-8 text-primary" />
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<UserCircleIcon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We found your existing billing account. Set up your new portal password to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">Existing Account Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We found an existing billing account with this email. Please verify your password to
|
||||
link it to your new portal account.
|
||||
</p>
|
||||
{/* Show what's pre-filled vs what's needed */}
|
||||
<div className="p-4 rounded-xl bg-muted/50 border border-border text-left space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Your account info:</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Email verified</span>
|
||||
</li>
|
||||
{(prefill?.firstName || prefill?.lastName) && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Name on file</span>
|
||||
</li>
|
||||
)}
|
||||
{prefill?.phone && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Phone number on file</span>
|
||||
</li>
|
||||
)}
|
||||
{prefill?.address && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Address on file</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">What you'll add:</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>Date of birth</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>New portal password</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
as="a"
|
||||
href={migrateUrl}
|
||||
onClick={() => goToStep("migrate-account")}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Link My Account
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<boolean>;
|
||||
verifyCode: (code: string) => Promise<AccountStatus | null>;
|
||||
completeAccount: () => Promise<AuthResponse | null>;
|
||||
migrateWhmcsAccount: () => Promise<AuthResponse | null>;
|
||||
|
||||
// Navigation
|
||||
goToStep: (step: GetStartedStep) => void;
|
||||
@ -240,7 +242,7 @@ export const useGetStartedStore = create<GetStartedState>()((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<GetStartedState>()((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<GetStartedState>()((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<GetStartedState>()((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) {
|
||||
|
||||
@ -203,3 +203,38 @@ export const whmcsSsoResponseSchema = z.object({
|
||||
|
||||
export type WhmcsSsoResponse = z.infer<typeof whmcsSsoResponseSchema>;
|
||||
export type WhmcsClientStats = z.infer<typeof whmcsClientStatsSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// 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<typeof whmcsGetUsersUserSchema>;
|
||||
export type WhmcsGetUsersResponse = z.infer<typeof whmcsGetUsersResponseSchema>;
|
||||
|
||||
@ -23,6 +23,7 @@ import type {
|
||||
completeAccountRequestSchema,
|
||||
signupWithEligibilityRequestSchema,
|
||||
signupWithEligibilityResponseSchema,
|
||||
migrateWhmcsAccountRequestSchema,
|
||||
getStartedSessionSchema,
|
||||
} from "./schema.js";
|
||||
|
||||
@ -90,6 +91,7 @@ export type BilingualEligibilityAddress = z.infer<typeof bilingualEligibilityAdd
|
||||
export type GuestEligibilityRequest = z.infer<typeof guestEligibilityRequestSchema>;
|
||||
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
|
||||
export type SignupWithEligibilityRequest = z.infer<typeof signupWithEligibilityRequestSchema>;
|
||||
export type MigrateWhmcsAccountRequest = z.infer<typeof migrateWhmcsAccountRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Response Types
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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<typeof salesforceCaseRecordSchema>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user