refactor: simplify auth signup flow by merging duplicate workflows
Merge SfCompletionWorkflow and NewCustomerSignupWorkflow into a single AccountCreationWorkflowService, remove legacy /auth/migrate endpoint and WhmcsLinkWorkflow, extract shared OTP email pattern into OtpEmailService, and improve PORTAL_EXISTS redirect UX with email pre-fill. - Consolidate signup/ directory services into steps/ (PortalUserCreationService, WhmcsCleanupService) and new AccountCreationWorkflowService - Rename WhmcsMigrationWorkflowService to AccountMigrationWorkflowService - Remove dead code: WhmcsLinkWorkflowService, auth.controller /migrate endpoint - Extract OtpEmailService from duplicated login/verification OTP email logic - Pass email query param on PORTAL_EXISTS redirect for login pre-fill - Delete 1977 lines of legacy code, add ~350 lines of consolidated logic
This commit is contained in:
parent
9b7cbcf78f
commit
451d58d436
@ -48,7 +48,6 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
"/api/auth/request-password-reset",
|
||||
"/api/auth/reset-password", // Public auth endpoint for password reset
|
||||
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
|
||||
"/api/auth/migrate", // Public auth endpoint for account migration
|
||||
"/api/health",
|
||||
"/docs",
|
||||
"/api/webhooks", // Webhooks typically don't use CSRF
|
||||
|
||||
@ -9,7 +9,6 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util.js";
|
||||
import {
|
||||
type LinkWhmcsRequest,
|
||||
type SetPasswordRequest,
|
||||
type ChangePasswordRequest,
|
||||
type SsoLinkResponse,
|
||||
@ -21,7 +20,6 @@ import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js
|
||||
import { AuthTokenService } from "../infra/token/token.service.js";
|
||||
import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service.js";
|
||||
import { PasswordWorkflowService } from "../infra/workflows/password-workflow.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "../infra/workflows/whmcs-link-workflow.service.js";
|
||||
// mapPrismaUserToDomain removed - usersService.update now returns profile directly
|
||||
import { AuthHealthService } from "./auth-health.service.js";
|
||||
import { AuthLoginService } from "./auth-login.service.js";
|
||||
@ -34,7 +32,6 @@ import { AuthLoginService } from "./auth-login.service.js";
|
||||
* - AuthHealthService: Health checks
|
||||
* - AuthLoginService: Login validation
|
||||
* - PasswordWorkflowService: Password operations
|
||||
* - WhmcsLinkWorkflowService: WHMCS account linking
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthOrchestrator {
|
||||
@ -47,7 +44,6 @@ export class AuthOrchestrator {
|
||||
private readonly auditService: AuditService,
|
||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||
private readonly passwordWorkflow: PasswordWorkflowService,
|
||||
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
private readonly authRateLimitService: AuthRateLimitService,
|
||||
private readonly healthService: AuthHealthService,
|
||||
@ -129,10 +125,6 @@ export class AuthOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
async linkWhmcsUser(linkData: LinkWhmcsRequest) {
|
||||
return this.whmcsLinkWorkflow.linkWhmcsUser(linkData.email, linkData.password);
|
||||
}
|
||||
|
||||
async checkPasswordNeeded(email: string) {
|
||||
return this.passwordWorkflow.checkPasswordNeeded(email);
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
||||
// Orchestrator-level services (not owned by any feature module)
|
||||
import { AuthOrchestrator } from "./application/auth-orchestrator.service.js";
|
||||
import { AuthHealthService } from "./application/auth-health.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js";
|
||||
|
||||
// Controller
|
||||
import { AuthController } from "./presentation/http/auth.controller.js";
|
||||
@ -42,7 +41,7 @@ import { AuthController } from "./presentation/http/auth.controller.js";
|
||||
WorkflowModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthOrchestrator, AuthHealthService, WhmcsLinkWorkflowService],
|
||||
providers: [AuthOrchestrator, AuthHealthService],
|
||||
exports: [AuthOrchestrator, TokensModule, SharedAuthModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -11,14 +11,8 @@ import { GetStartedCoordinator } from "../infra/workflows/get-started-coordinato
|
||||
// Workflow services
|
||||
import { VerificationWorkflowService } from "../infra/workflows/verification-workflow.service.js";
|
||||
import { GuestEligibilityWorkflowService } from "../infra/workflows/guest-eligibility-workflow.service.js";
|
||||
import { NewCustomerSignupWorkflowService } from "../infra/workflows/new-customer-signup-workflow.service.js";
|
||||
import { SfCompletionWorkflowService } from "../infra/workflows/sf-completion-workflow.service.js";
|
||||
import { WhmcsMigrationWorkflowService } from "../infra/workflows/whmcs-migration-workflow.service.js";
|
||||
// Signup shared services
|
||||
import { SignupAccountResolverService } from "../infra/workflows/signup/signup-account-resolver.service.js";
|
||||
import { SignupValidationService } from "../infra/workflows/signup/signup-validation.service.js";
|
||||
import { SignupWhmcsService } from "../infra/workflows/signup/signup-whmcs.service.js";
|
||||
import { SignupUserCreationService } from "../infra/workflows/signup/signup-user-creation.service.js";
|
||||
import { AccountCreationWorkflowService } from "../infra/workflows/account-creation-workflow.service.js";
|
||||
import { AccountMigrationWorkflowService } from "../infra/workflows/account-migration-workflow.service.js";
|
||||
// Step services
|
||||
import {
|
||||
ResolveSalesforceAccountStep,
|
||||
@ -27,6 +21,8 @@ import {
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
CreateEligibilityCaseStep,
|
||||
PortalUserCreationService,
|
||||
WhmcsCleanupService,
|
||||
} from "../infra/workflows/steps/index.js";
|
||||
// Controller
|
||||
import { GetStartedController } from "../presentation/http/get-started.controller.js";
|
||||
@ -48,14 +44,8 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
|
||||
// Workflow services
|
||||
VerificationWorkflowService,
|
||||
GuestEligibilityWorkflowService,
|
||||
NewCustomerSignupWorkflowService,
|
||||
SfCompletionWorkflowService,
|
||||
WhmcsMigrationWorkflowService,
|
||||
// Signup shared services
|
||||
SignupAccountResolverService,
|
||||
SignupValidationService,
|
||||
SignupWhmcsService,
|
||||
SignupUserCreationService,
|
||||
AccountCreationWorkflowService,
|
||||
AccountMigrationWorkflowService,
|
||||
// Step services
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateWhmcsClientStep,
|
||||
@ -63,6 +53,8 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
CreateEligibilityCaseStep,
|
||||
PortalUserCreationService,
|
||||
WhmcsCleanupService,
|
||||
],
|
||||
exports: [GetStartedCoordinator],
|
||||
})
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { OtpService, type OtpVerifyResult } from "./otp.service.js";
|
||||
export { OtpEmailService } from "./otp-email.service.js";
|
||||
export { GetStartedSessionService } from "./get-started-session.service.js";
|
||||
|
||||
55
apps/bff/src/modules/auth/infra/otp/otp-email.service.ts
Normal file
55
apps/bff/src/modules/auth/infra/otp/otp-email.service.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
|
||||
type OtpEmailContext = "login" | "verification";
|
||||
|
||||
/** Template config keys and email subjects per context */
|
||||
const OTP_EMAIL_CONFIG: Record<
|
||||
OtpEmailContext,
|
||||
{ configKey: string; subject: string; fallbackWarning: string }
|
||||
> = {
|
||||
login: {
|
||||
configKey: "EMAIL_TEMPLATE_LOGIN_OTP",
|
||||
subject: "Your login verification code",
|
||||
fallbackWarning: "If you didn't attempt to log in, please secure your account immediately.",
|
||||
},
|
||||
verification: {
|
||||
configKey: "EMAIL_TEMPLATE_OTP_VERIFICATION",
|
||||
subject: "Your verification code",
|
||||
fallbackWarning: "If you didn't request this code, please ignore this email.",
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OtpEmailService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly emailService: EmailService
|
||||
) {}
|
||||
|
||||
async sendOtpCode(email: string, code: string, context: OtpEmailContext): Promise<void> {
|
||||
const { configKey, subject, fallbackWarning } = OTP_EMAIL_CONFIG[context];
|
||||
const templateId = this.config.get<string>(configKey);
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
templateId,
|
||||
dynamicTemplateData: { code, expiresMinutes: "10" },
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html: `
|
||||
<p>Your verification code is: <strong>${code}</strong></p>
|
||||
<p>This code expires in 10 minutes.</p>
|
||||
<p>${fallbackWarning}</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,536 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
import {
|
||||
type CompleteAccountRequest,
|
||||
type SignupWithEligibilityRequest,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
import type { BilingualAddress } from "@customer-portal/domain/address";
|
||||
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
|
||||
import {
|
||||
PORTAL_SOURCE_NEW_SIGNUP,
|
||||
PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
} from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import {
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateEligibilityCaseStep,
|
||||
CreateWhmcsClientStep,
|
||||
CreatePortalUserStep,
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
} from "./steps/index.js";
|
||||
import { classifyError } from "./workflow-error.util.js";
|
||||
|
||||
/** Result type for the signupWithEligibility path */
|
||||
export interface SignupWithEligibilityResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errorCategory?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account Creation Workflow Service
|
||||
*
|
||||
* Merged workflow that handles both:
|
||||
* - completeAccount (SF_UNMAPPED / NEW_CUSTOMER without eligibility)
|
||||
* - signupWithEligibility (NEW_CUSTOMER with eligibility case)
|
||||
*
|
||||
* Replaces SfCompletionWorkflowService and NewCustomerSignupWorkflowService.
|
||||
* Also fixes the Birthdate/Sex__c gap by calling createContact on the SF PersonContact.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountCreationWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly addressWriter: AddressWriterService,
|
||||
private readonly sfStep: ResolveSalesforceAccountStep,
|
||||
private readonly caseStep: CreateEligibilityCaseStep,
|
||||
private readonly whmcsStep: CreateWhmcsClientStep,
|
||||
private readonly portalUserStep: CreatePortalUserStep,
|
||||
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
|
||||
private readonly authResultStep: GenerateAuthResultStep,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute account creation.
|
||||
*
|
||||
* - Without options: completeAccount path (exceptions propagate)
|
||||
* - With { withEligibility: true }: signupWithEligibility path (errors caught and returned)
|
||||
*/
|
||||
async execute(
|
||||
request: CompleteAccountRequest | SignupWithEligibilityRequest,
|
||||
options?: { withEligibility?: boolean }
|
||||
): Promise<AuthResultInternal | SignupWithEligibilityResult> {
|
||||
if (options?.withEligibility) {
|
||||
return this.executeWithEligibility(request as SignupWithEligibilityRequest);
|
||||
}
|
||||
|
||||
return this.executeCompleteAccount(request as CompleteAccountRequest);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// completeAccount path — exceptions propagate directly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async executeCompleteAccount(
|
||||
request: CompleteAccountRequest
|
||||
): Promise<AuthResultInternal> {
|
||||
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||
request.sessionToken,
|
||||
"complete_account"
|
||||
);
|
||||
|
||||
if (!sessionResult.success) {
|
||||
throw new BadRequestException(sessionResult.reason);
|
||||
}
|
||||
|
||||
const session = sessionResult.session;
|
||||
this.validateRequest(request, session);
|
||||
|
||||
const lockKey = `complete-account:${session.email}`;
|
||||
|
||||
try {
|
||||
// Type assertion safe: executeCreation returns AuthResultInternal when withEligibility=false
|
||||
return (await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => this.executeCreation(request, session, false),
|
||||
{ ttlMs: 60_000 }
|
||||
)) as AuthResultInternal;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: session.email },
|
||||
"Account completion failed"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// signupWithEligibility path — errors caught and returned as result objects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async executeWithEligibility(
|
||||
request: SignupWithEligibilityRequest
|
||||
): Promise<SignupWithEligibilityResult> {
|
||||
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||
request.sessionToken,
|
||||
"signup_with_eligibility"
|
||||
);
|
||||
|
||||
if (!sessionResult.success) {
|
||||
return { success: false, message: sessionResult.reason };
|
||||
}
|
||||
|
||||
const normalizedEmail = sessionResult.session.email;
|
||||
this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility");
|
||||
|
||||
const lockKey = `signup-email:${normalizedEmail}`;
|
||||
|
||||
try {
|
||||
// Type assertion safe: executeCreation returns SignupWithEligibilityResult when withEligibility=true
|
||||
return (await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => this.executeCreation(request, sessionResult.session, true),
|
||||
{ ttlMs: 60_000 }
|
||||
)) as SignupWithEligibilityResult;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||
"Signup with eligibility failed"
|
||||
);
|
||||
|
||||
const classified = classifyError(error);
|
||||
return {
|
||||
success: false,
|
||||
errorCategory: classified.errorCategory,
|
||||
message: classified.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared creation logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async executeCreation(
|
||||
request: CompleteAccountRequest | SignupWithEligibilityRequest,
|
||||
session: {
|
||||
email: string;
|
||||
sfAccountId?: string | undefined;
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
address?: Record<string, unknown> | undefined;
|
||||
},
|
||||
withEligibility: boolean
|
||||
): Promise<AuthResultInternal | SignupWithEligibilityResult> {
|
||||
const { password, phone, dateOfBirth, gender } = request;
|
||||
const email = session.email;
|
||||
const isNewCustomer = !session.sfAccountId;
|
||||
|
||||
// Check for existing accounts
|
||||
if (withEligibility) {
|
||||
const existingCheck = await this.checkExistingAccountsSafe(email);
|
||||
if (existingCheck) {
|
||||
return existingCheck;
|
||||
}
|
||||
} else {
|
||||
await this.ensureNoExistingAccounts(email);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
// Resolve address and names based on path
|
||||
let finalFirstName: string;
|
||||
let finalLastName: string;
|
||||
let address: NonNullable<CompleteAccountRequest["address"]> | BilingualAddress;
|
||||
|
||||
if (withEligibility) {
|
||||
const eligibilityRequest = request as SignupWithEligibilityRequest;
|
||||
finalFirstName = eligibilityRequest.firstName;
|
||||
finalLastName = eligibilityRequest.lastName;
|
||||
address = eligibilityRequest.address;
|
||||
} else {
|
||||
const completeRequest = request as CompleteAccountRequest;
|
||||
address = this.resolveAddress(completeRequest.address, session.address);
|
||||
const names = this.resolveNames(completeRequest.firstName, completeRequest.lastName, session);
|
||||
finalFirstName = names.finalFirstName;
|
||||
finalLastName = names.finalLastName;
|
||||
}
|
||||
|
||||
// Step 1: Resolve SF Account (CRITICAL)
|
||||
const sfResult = await this.sfStep.execute({
|
||||
email,
|
||||
...(session.sfAccountId != null && { existingAccountId: session.sfAccountId }),
|
||||
firstName: finalFirstName,
|
||||
lastName: finalLastName,
|
||||
phone,
|
||||
source: withEligibility ? PORTAL_SOURCE_INTERNET_ELIGIBILITY : PORTAL_SOURCE_NEW_SIGNUP,
|
||||
...(withEligibility && { updateSourceIfExists: true }),
|
||||
});
|
||||
|
||||
// Step 1.5: Write SF Address (DEGRADABLE) + Step 1.6: Create SF Contact (DEGRADABLE)
|
||||
// + resolve WHMCS address (varies by path)
|
||||
let eligibilityRequestId: string | undefined;
|
||||
|
||||
if (withEligibility) {
|
||||
const bilingualAddress = address as BilingualAddress;
|
||||
|
||||
// SF address write + eligibility case creation (both DEGRADABLE, independent)
|
||||
const [, caseId] = await Promise.all([
|
||||
safeOperation(
|
||||
async () => this.addressWriter.writeToSalesforce(sfResult.sfAccountId, bilingualAddress),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF address write",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
),
|
||||
safeOperation(
|
||||
async () => {
|
||||
const caseResult = await this.caseStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
address: this.addressWriter.toCaseAddress(bilingualAddress),
|
||||
});
|
||||
return caseResult.caseId;
|
||||
},
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined as string | undefined,
|
||||
context: "Eligibility case creation",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
eligibilityRequestId = caseId;
|
||||
} else {
|
||||
// SF address write only for new customers with prefectureJa
|
||||
const completeAddress = address as NonNullable<CompleteAccountRequest["address"]>;
|
||||
if (isNewCustomer && completeAddress.prefectureJa) {
|
||||
await safeOperation(
|
||||
async () =>
|
||||
this.addressWriter.writeToSalesforce(
|
||||
sfResult.sfAccountId,
|
||||
completeAddress as BilingualAddress
|
||||
),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF address write",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1.6: Create SF Contact — Birthdate + Sex__c (DEGRADABLE, NEW)
|
||||
if (dateOfBirth && gender) {
|
||||
await safeOperation(
|
||||
async () =>
|
||||
this.salesforceAccountService.createContact({
|
||||
accountId: sfResult.sfAccountId,
|
||||
firstName: finalFirstName,
|
||||
lastName: finalLastName,
|
||||
email,
|
||||
phone: phone ?? "",
|
||||
gender: gender,
|
||||
dateOfBirth,
|
||||
}),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF contact creation (Birthdate/Sex__c)",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Create WHMCS Client (CRITICAL)
|
||||
let whmcsAddress;
|
||||
if (withEligibility) {
|
||||
whmcsAddress = this.addressWriter.resolveWhmcsAddressFromBilingual(
|
||||
address as BilingualAddress
|
||||
);
|
||||
} else {
|
||||
const completeAddress = address as NonNullable<CompleteAccountRequest["address"]>;
|
||||
whmcsAddress = await this.addressWriter.resolveWhmcsAddress({
|
||||
postcode: completeAddress.postcode,
|
||||
townJa: completeAddress.townJa,
|
||||
streetAddress: completeAddress.streetAddress || "",
|
||||
buildingName: completeAddress.buildingName,
|
||||
roomNumber: completeAddress.roomNumber,
|
||||
residenceType: completeAddress.residenceType || "house",
|
||||
});
|
||||
}
|
||||
|
||||
const whmcsResult = await this.whmcsStep.execute({
|
||||
firstName: finalFirstName,
|
||||
lastName: finalLastName,
|
||||
email,
|
||||
password,
|
||||
phone: phone ?? "",
|
||||
address: this.addressWriter.toWhmcsStepAddress(whmcsAddress),
|
||||
customerNumber: sfResult.customerNumber ?? null,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
});
|
||||
|
||||
// Step 4: Create Portal User (CRITICAL, rollback WHMCS on fail)
|
||||
let portalUserResult: { userId: string };
|
||||
try {
|
||||
portalUserResult = await this.portalUserStep.execute({
|
||||
email,
|
||||
passwordHash,
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
});
|
||||
} catch (portalError) {
|
||||
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, email);
|
||||
throw portalError;
|
||||
}
|
||||
|
||||
// Step 5: Update SF Flags (DEGRADABLE)
|
||||
await safeOperation(
|
||||
async () =>
|
||||
this.sfFlagsStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
}),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF flags update",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
);
|
||||
|
||||
// Step 6: Generate Auth Result
|
||||
const auditSource = withEligibility
|
||||
? "signup_with_eligibility"
|
||||
: isNewCustomer
|
||||
? "get_started_new_customer"
|
||||
: "get_started_complete_account";
|
||||
|
||||
const authResult = await this.authResultStep.execute({
|
||||
userId: portalUserResult.userId,
|
||||
email,
|
||||
auditSource,
|
||||
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
|
||||
});
|
||||
|
||||
// Invalidate session
|
||||
await this.sessionService.invalidate(request.sessionToken);
|
||||
|
||||
// Send welcome email (only for eligibility path, DEGRADABLE)
|
||||
if (withEligibility) {
|
||||
await this.sendWelcomeWithEligibilityEmail(email, finalFirstName, eligibilityRequestId);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
{
|
||||
email,
|
||||
userId: portalUserResult.userId,
|
||||
isNewCustomer,
|
||||
withEligibility,
|
||||
...(eligibilityRequestId != null && { eligibilityRequestId }),
|
||||
},
|
||||
withEligibility
|
||||
? "Signup with eligibility completed successfully"
|
||||
: "Account completed successfully"
|
||||
);
|
||||
|
||||
if (withEligibility) {
|
||||
return {
|
||||
success: true,
|
||||
...(eligibilityRequestId != null && { eligibilityRequestId }),
|
||||
authResult,
|
||||
};
|
||||
}
|
||||
|
||||
return authResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private validateRequest(
|
||||
request: CompleteAccountRequest,
|
||||
session: { sfAccountId?: string | undefined }
|
||||
): void {
|
||||
const isNewCustomer = !session.sfAccountId;
|
||||
|
||||
if (!isNewCustomer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.firstName || !request.lastName) {
|
||||
throw new BadRequestException("First name and last name are required for new accounts.");
|
||||
}
|
||||
if (!request.address) {
|
||||
throw new BadRequestException("Address is required for new accounts.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureNoExistingAccounts(email: string): Promise<void> {
|
||||
const portalUser = await this.usersService.findByEmailInternal(email);
|
||||
|
||||
if (portalUser) {
|
||||
throw new ConflictException("An account already exists. Please log in.");
|
||||
}
|
||||
}
|
||||
|
||||
private async checkExistingAccountsSafe(
|
||||
email: string
|
||||
): Promise<{ success: false; message: string } | null> {
|
||||
const portalUser = await this.usersService.findByEmailInternal(email);
|
||||
|
||||
if (portalUser) {
|
||||
return {
|
||||
success: false,
|
||||
message: "An account already exists with this email. Please log in.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveAddress(
|
||||
requestAddress: CompleteAccountRequest["address"] | undefined,
|
||||
sessionAddress: Record<string, unknown> | undefined
|
||||
): NonNullable<CompleteAccountRequest["address"]> {
|
||||
const address = requestAddress ?? sessionAddress;
|
||||
|
||||
if (!address || !address.postcode) {
|
||||
throw new BadRequestException(
|
||||
"Address information is incomplete. Please ensure postcode is provided."
|
||||
);
|
||||
}
|
||||
|
||||
return address as NonNullable<CompleteAccountRequest["address"]>;
|
||||
}
|
||||
|
||||
private resolveNames(
|
||||
firstName: string | undefined,
|
||||
lastName: string | undefined,
|
||||
session: { firstName?: string | undefined; lastName?: string | undefined }
|
||||
): { finalFirstName: string; finalLastName: string } {
|
||||
const finalFirstName = firstName ?? session.firstName;
|
||||
const finalLastName = lastName ?? session.lastName;
|
||||
|
||||
if (!finalFirstName || !finalLastName) {
|
||||
throw new BadRequestException("Name information is missing. Please provide your name.");
|
||||
}
|
||||
|
||||
return { finalFirstName, finalLastName };
|
||||
}
|
||||
|
||||
private async sendWelcomeWithEligibilityEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
eligibilityRequestId?: string
|
||||
): Promise<void> {
|
||||
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY");
|
||||
|
||||
try {
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Welcome! Your account is ready",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
firstName,
|
||||
portalUrl: appBase,
|
||||
dashboardUrl: `${appBase}/account`,
|
||||
eligibilityRequestId: eligibilityRequestId ?? "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Welcome! Your account is ready",
|
||||
html: `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>Welcome! Your account has been created successfully.</p>
|
||||
<p>We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.</p>
|
||||
${eligibilityRequestId ? `<p>Reference ID: ${eligibilityRequestId}</p>` : ""}
|
||||
<p>Log in to your dashboard: <a href="${appBase}/account">${appBase}/account</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
} catch (emailError) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(emailError), email },
|
||||
"Failed to send welcome email (non-critical)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,8 +21,11 @@ import { PORTAL_SOURCE_MIGRATED } from "@bff/modules/auth/constants/portal.const
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import { SignupUserCreationService } from "./signup/signup-user-creation.service.js";
|
||||
import { UpdateSalesforceFlagsStep, GenerateAuthResultStep } from "./steps/index.js";
|
||||
import {
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
CreatePortalUserStep,
|
||||
} from "./steps/index.js";
|
||||
|
||||
/** WHMCS client update payload for account migration (password + optional custom fields) */
|
||||
interface WhmcsMigrationClientUpdate {
|
||||
@ -31,14 +34,14 @@ interface WhmcsMigrationClientUpdate {
|
||||
}
|
||||
|
||||
/**
|
||||
* WHMCS Migration Workflow Service
|
||||
* Account Migration Workflow Service
|
||||
*
|
||||
* Handles WHMCS_UNMAPPED path — passwordless migration for users
|
||||
* who already have a WHMCS account. Email verification serves as
|
||||
* identity proof. Creates Portal user + SF mapping.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WhmcsMigrationWorkflowService {
|
||||
export class AccountMigrationWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
@ -48,7 +51,7 @@ export class WhmcsMigrationWorkflowService {
|
||||
private readonly salesforceFacade: SalesforceFacade,
|
||||
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
|
||||
private readonly whmcsClientService: WhmcsClientService,
|
||||
private readonly userCreation: SignupUserCreationService,
|
||||
private readonly portalUserStep: CreatePortalUserStep,
|
||||
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
|
||||
private readonly authResultStep: GenerateAuthResultStep,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
@ -141,7 +144,7 @@ export class WhmcsMigrationWorkflowService {
|
||||
await this.updateWhmcsClientForMigration(whmcsClientId, password, dateOfBirth, gender);
|
||||
|
||||
// Create portal user and ID mapping
|
||||
const { userId } = await this.userCreation.createUserWithMapping({
|
||||
const { userId } = await this.portalUserStep.execute({
|
||||
email,
|
||||
passwordHash,
|
||||
whmcsClientId,
|
||||
@ -16,9 +16,8 @@ import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
import { VerificationWorkflowService } from "./verification-workflow.service.js";
|
||||
import { GuestEligibilityWorkflowService } from "./guest-eligibility-workflow.service.js";
|
||||
import { NewCustomerSignupWorkflowService } from "./new-customer-signup-workflow.service.js";
|
||||
import { SfCompletionWorkflowService } from "./sf-completion-workflow.service.js";
|
||||
import { WhmcsMigrationWorkflowService } from "./whmcs-migration-workflow.service.js";
|
||||
import { AccountCreationWorkflowService } from "./account-creation-workflow.service.js";
|
||||
import { AccountMigrationWorkflowService } from "./account-migration-workflow.service.js";
|
||||
|
||||
/**
|
||||
* Get Started Coordinator
|
||||
@ -32,9 +31,8 @@ export class GetStartedCoordinator {
|
||||
constructor(
|
||||
private readonly verification: VerificationWorkflowService,
|
||||
private readonly guestEligibility: GuestEligibilityWorkflowService,
|
||||
private readonly newCustomerSignup: NewCustomerSignupWorkflowService,
|
||||
private readonly sfCompletion: SfCompletionWorkflowService,
|
||||
private readonly whmcsMigration: WhmcsMigrationWorkflowService
|
||||
private readonly accountCreation: AccountCreationWorkflowService,
|
||||
private readonly accountMigration: AccountMigrationWorkflowService
|
||||
) {}
|
||||
|
||||
async sendVerificationCode(
|
||||
@ -56,7 +54,7 @@ export class GetStartedCoordinator {
|
||||
}
|
||||
|
||||
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
|
||||
return this.sfCompletion.execute(request);
|
||||
return this.accountCreation.execute(request) as Promise<AuthResultInternal>;
|
||||
}
|
||||
|
||||
async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{
|
||||
@ -65,10 +63,15 @@ export class GetStartedCoordinator {
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
return this.newCustomerSignup.execute(request);
|
||||
return this.accountCreation.execute(request, { withEligibility: true }) as Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}>;
|
||||
}
|
||||
|
||||
async migrateWhmcsAccount(request: MigrateWhmcsAccountRequest): Promise<AuthResultInternal> {
|
||||
return this.whmcsMigration.execute(request);
|
||||
return this.accountMigration.execute(request);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
import { EmailService } from "@/infra/email/email.service.js";
|
||||
import { AuditService, AuditAction } from "@/infra/audit/audit.service.js";
|
||||
import { extractErrorMessage } from "@/core/utils/error.util.js";
|
||||
|
||||
import { OtpService, type OtpVerifyResult } from "../otp/otp.service.js";
|
||||
import { OtpEmailService } from "../otp/otp-email.service.js";
|
||||
import { LoginSessionService } from "../login/login-session.service.js";
|
||||
|
||||
/**
|
||||
@ -41,10 +40,9 @@ export interface LoginOtpVerifyError {
|
||||
@Injectable()
|
||||
export class LoginOtpWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly sessionService: LoginSessionService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly otpEmailService: OtpEmailService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -74,7 +72,7 @@ export class LoginOtpWorkflowService {
|
||||
const { sessionToken, expiresAt } = await this.sessionService.create(user, fingerprint);
|
||||
|
||||
// Send OTP email
|
||||
await this.sendLoginOtpEmail(normalizedEmail, code);
|
||||
await this.otpEmailService.sendOtpCode(normalizedEmail, code, "login");
|
||||
|
||||
// Audit log
|
||||
await this.auditService.logAuthEvent(AuditAction.LOGIN_OTP_SENT, user.id, {
|
||||
@ -218,36 +216,6 @@ export class LoginOtpWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send login OTP email
|
||||
*/
|
||||
private async sendLoginOtpEmail(email: string, code: string): Promise<void> {
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_LOGIN_OTP");
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Your login verification code",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
code,
|
||||
expiresMinutes: "10",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Fallback to plain HTML
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Your login verification code",
|
||||
html: `
|
||||
<p>Your login verification code is: <strong>${code}</strong></p>
|
||||
<p>This code expires in 10 minutes.</p>
|
||||
<p>If you didn't attempt to log in, please secure your account immediately.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask email for display (e.g., j***e@e***.com)
|
||||
*
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
import { type SignupWithEligibilityRequest } from "@customer-portal/domain/get-started";
|
||||
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
|
||||
import { PORTAL_SOURCE_INTERNET_ELIGIBILITY } from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import {
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateEligibilityCaseStep,
|
||||
CreateWhmcsClientStep,
|
||||
CreatePortalUserStep,
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
} from "./steps/index.js";
|
||||
import { classifyError } from "./workflow-error.util.js";
|
||||
|
||||
/**
|
||||
* New Customer Signup Workflow Service
|
||||
*
|
||||
* Handles NEW_CUSTOMER signup path (and signupWithEligibility).
|
||||
* Creates SF Account + Case + WHMCS client + Portal user in a
|
||||
* distributed transaction with compensating rollbacks.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NewCustomerSignupWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly sfStep: ResolveSalesforceAccountStep,
|
||||
private readonly caseStep: CreateEligibilityCaseStep,
|
||||
private readonly whmcsStep: CreateWhmcsClientStep,
|
||||
private readonly portalUserStep: CreatePortalUserStep,
|
||||
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
|
||||
private readonly authResultStep: GenerateAuthResultStep,
|
||||
private readonly addressWriter: AddressWriterService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Full signup with eligibility check — creates everything in one operation.
|
||||
*
|
||||
* Flow: acquire session → lock by email → distributed transaction
|
||||
*/
|
||||
async execute(request: SignupWithEligibilityRequest): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errorCategory?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||
request.sessionToken,
|
||||
"signup_with_eligibility"
|
||||
);
|
||||
|
||||
if (!sessionResult.success) {
|
||||
return { success: false, message: sessionResult.reason };
|
||||
}
|
||||
|
||||
const normalizedEmail = sessionResult.session.email;
|
||||
this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility");
|
||||
|
||||
const lockKey = `signup-email:${normalizedEmail}`;
|
||||
|
||||
try {
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => this.executeSignup(request, normalizedEmail),
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||
"Signup with eligibility failed"
|
||||
);
|
||||
|
||||
const classified = classifyError(error);
|
||||
return {
|
||||
success: false,
|
||||
errorCategory: classified.errorCategory,
|
||||
message: classified.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async executeSignup(
|
||||
request: SignupWithEligibilityRequest,
|
||||
email: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errorCategory?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
// Check for existing accounts
|
||||
const existingCheck = await this.checkExistingAccounts(email);
|
||||
if (existingCheck) {
|
||||
return existingCheck;
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(request.password);
|
||||
|
||||
return this.executeWithSteps(request, email, passwordHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the signup flow with steps and manual rollback coordination.
|
||||
*
|
||||
* Uses DistributedTransactionService for coordinated rollback while
|
||||
* passing data between steps.
|
||||
*/
|
||||
private async executeWithSteps(
|
||||
request: SignupWithEligibilityRequest,
|
||||
email: string,
|
||||
passwordHash: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errorCategory?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
const { firstName, lastName, address, phone, password, dateOfBirth, gender } = request;
|
||||
|
||||
// Step 1: Resolve SF account (CRITICAL)
|
||||
const sfResult = await this.sfStep.execute({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
source: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
updateSourceIfExists: true,
|
||||
});
|
||||
|
||||
// Steps 1.5 + 2: SF address write + eligibility case creation (both DEGRADABLE, independent)
|
||||
const [, eligibilityRequestId] = await Promise.all([
|
||||
safeOperation(
|
||||
async () => this.addressWriter.writeToSalesforce(sfResult.sfAccountId, address),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF address write",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
),
|
||||
safeOperation(
|
||||
async () => {
|
||||
const caseResult = await this.caseStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
address: this.addressWriter.toCaseAddress(address),
|
||||
});
|
||||
return caseResult.caseId;
|
||||
},
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined as string | undefined,
|
||||
context: "Eligibility case creation",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
// Step 3: Create WHMCS client (CRITICAL, has rollback)
|
||||
const whmcsAddress = this.addressWriter.resolveWhmcsAddressFromBilingual(address);
|
||||
|
||||
const whmcsResult = await this.whmcsStep.execute({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
phone: phone ?? "",
|
||||
address: this.addressWriter.toWhmcsStepAddress(whmcsAddress),
|
||||
customerNumber: sfResult.customerNumber ?? null,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
});
|
||||
|
||||
// Step 4: Create portal user (CRITICAL, has rollback)
|
||||
let portalUserResult: { userId: string };
|
||||
try {
|
||||
portalUserResult = await this.portalUserStep.execute({
|
||||
email,
|
||||
passwordHash,
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
});
|
||||
} catch (portalError) {
|
||||
// Rollback WHMCS client
|
||||
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, email);
|
||||
throw portalError;
|
||||
}
|
||||
|
||||
// Step 5: Update SF flags (DEGRADABLE)
|
||||
await safeOperation(
|
||||
async () =>
|
||||
this.sfFlagsStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
}),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF flags update",
|
||||
logger: this.logger,
|
||||
metadata: { email },
|
||||
}
|
||||
);
|
||||
|
||||
// Step 6: Generate auth result + finalize
|
||||
const authResult = await this.authResultStep.execute({
|
||||
userId: portalUserResult.userId,
|
||||
email,
|
||||
auditSource: "signup_with_eligibility",
|
||||
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
|
||||
});
|
||||
|
||||
await this.sessionService.invalidate(request.sessionToken);
|
||||
await this.sendWelcomeWithEligibilityEmail(email, firstName, eligibilityRequestId);
|
||||
|
||||
this.logger.log(
|
||||
{ email, userId: portalUserResult.userId, eligibilityRequestId },
|
||||
"Signup with eligibility completed successfully"
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...(eligibilityRequestId != null && { eligibilityRequestId }),
|
||||
authResult,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkExistingAccounts(
|
||||
email: string
|
||||
): Promise<{ success: false; message: string } | null> {
|
||||
// Only check Portal — the verification phase already checked WHMCS,
|
||||
// and the AddClient step will reject duplicates if one was created in the interim.
|
||||
const portalUser = await this.usersService.findByEmailInternal(email);
|
||||
|
||||
if (portalUser) {
|
||||
return {
|
||||
success: false,
|
||||
message: "An account already exists with this email. Please log in.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async sendWelcomeWithEligibilityEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
eligibilityRequestId?: string
|
||||
): Promise<void> {
|
||||
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY");
|
||||
|
||||
try {
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Welcome! Your account is ready",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
firstName,
|
||||
portalUrl: appBase,
|
||||
dashboardUrl: `${appBase}/account`,
|
||||
eligibilityRequestId: eligibilityRequestId ?? "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Welcome! Your account is ready",
|
||||
html: `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>Welcome! Your account has been created successfully.</p>
|
||||
<p>We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.</p>
|
||||
${eligibilityRequestId ? `<p>Reference ID: ${eligibilityRequestId}</p>` : ""}
|
||||
<p>Log in to your dashboard: <a href="${appBase}/account">${appBase}/account</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
} catch (emailError) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(emailError), email },
|
||||
"Failed to send welcome email (non-critical)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,265 +0,0 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
import { type CompleteAccountRequest } from "@customer-portal/domain/get-started";
|
||||
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
|
||||
import { PORTAL_SOURCE_NEW_SIGNUP } from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
|
||||
import type { BilingualAddress } from "@customer-portal/domain/address";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import {
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateWhmcsClientStep,
|
||||
CreatePortalUserStep,
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
} from "./steps/index.js";
|
||||
|
||||
/**
|
||||
* SF Completion Workflow Service
|
||||
*
|
||||
* Handles account completion for SF_UNMAPPED and NEW_CUSTOMER paths
|
||||
* (the completeAccount endpoint). Creates WHMCS client + Portal user
|
||||
* and links to an existing or new SF account.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SfCompletionWorkflowService {
|
||||
constructor(
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly sfStep: ResolveSalesforceAccountStep,
|
||||
private readonly whmcsStep: CreateWhmcsClientStep,
|
||||
private readonly portalUserStep: CreatePortalUserStep,
|
||||
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
|
||||
private readonly authResultStep: GenerateAuthResultStep,
|
||||
private readonly addressWriter: AddressWriterService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Complete account for users with SF account but no WHMCS/Portal.
|
||||
* Also handles NEW_CUSTOMER who need a new SF account created.
|
||||
*/
|
||||
async execute(request: CompleteAccountRequest): Promise<AuthResultInternal> {
|
||||
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||
request.sessionToken,
|
||||
"complete_account"
|
||||
);
|
||||
|
||||
if (!sessionResult.success) {
|
||||
throw new BadRequestException(sessionResult.reason);
|
||||
}
|
||||
|
||||
const session = sessionResult.session;
|
||||
this.validateRequest(request, session);
|
||||
|
||||
const lockKey = `complete-account:${session.email}`;
|
||||
|
||||
try {
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => this.executeCompletion(request, session),
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: session.email },
|
||||
"Account completion failed"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateRequest(
|
||||
request: CompleteAccountRequest,
|
||||
session: { sfAccountId?: string | undefined }
|
||||
): void {
|
||||
const isNewCustomer = !session.sfAccountId;
|
||||
|
||||
if (!isNewCustomer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.firstName || !request.lastName) {
|
||||
throw new BadRequestException("First name and last name are required for new accounts.");
|
||||
}
|
||||
if (!request.address) {
|
||||
throw new BadRequestException("Address is required for new accounts.");
|
||||
}
|
||||
}
|
||||
|
||||
private async executeCompletion(
|
||||
request: CompleteAccountRequest,
|
||||
session: {
|
||||
email: string;
|
||||
sfAccountId?: string | undefined;
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
address?: Record<string, unknown> | undefined;
|
||||
}
|
||||
): Promise<AuthResultInternal> {
|
||||
const {
|
||||
password,
|
||||
phone,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
firstName,
|
||||
lastName,
|
||||
address: requestAddress,
|
||||
} = request;
|
||||
const isNewCustomer = !session.sfAccountId;
|
||||
|
||||
// Check for existing accounts
|
||||
await this.ensureNoExistingAccounts(session.email);
|
||||
|
||||
// Resolve address and names
|
||||
const address = this.resolveAddress(requestAddress, session.address);
|
||||
const { finalFirstName, finalLastName } = this.resolveNames(firstName, lastName, session);
|
||||
|
||||
// Step 1: Resolve SF account (CRITICAL)
|
||||
const sfResult = await this.sfStep.execute({
|
||||
email: session.email,
|
||||
...(session.sfAccountId != null && { existingAccountId: session.sfAccountId }),
|
||||
firstName: finalFirstName,
|
||||
lastName: finalLastName,
|
||||
phone,
|
||||
source: PORTAL_SOURCE_NEW_SIGNUP,
|
||||
});
|
||||
|
||||
// Steps 1.5 + 2: SF address write (DEGRADABLE) + WHMCS address resolve (CRITICAL) — independent
|
||||
const [, whmcsAddress] = await Promise.all([
|
||||
isNewCustomer && address.prefectureJa
|
||||
? safeOperation(
|
||||
async () =>
|
||||
this.addressWriter.writeToSalesforce(
|
||||
sfResult.sfAccountId,
|
||||
address as BilingualAddress
|
||||
),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF address write",
|
||||
logger: this.logger,
|
||||
metadata: { email: session.email },
|
||||
}
|
||||
)
|
||||
: Promise.resolve(),
|
||||
this.addressWriter.resolveWhmcsAddress({
|
||||
postcode: address.postcode,
|
||||
townJa: address.townJa,
|
||||
streetAddress: address.streetAddress || "",
|
||||
buildingName: address.buildingName,
|
||||
roomNumber: address.roomNumber,
|
||||
residenceType: address.residenceType || "house",
|
||||
}),
|
||||
]);
|
||||
|
||||
const whmcsResult = await this.whmcsStep.execute({
|
||||
firstName: finalFirstName,
|
||||
lastName: finalLastName,
|
||||
email: session.email,
|
||||
password,
|
||||
phone: phone ?? "",
|
||||
address: this.addressWriter.toWhmcsStepAddress(whmcsAddress),
|
||||
customerNumber: sfResult.customerNumber ?? null,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
});
|
||||
|
||||
// Step 3: Create portal user (CRITICAL, has rollback)
|
||||
const passwordHash = await argon2.hash(password);
|
||||
let portalUserResult: { userId: string };
|
||||
try {
|
||||
portalUserResult = await this.portalUserStep.execute({
|
||||
email: session.email,
|
||||
passwordHash,
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
});
|
||||
} catch (portalError) {
|
||||
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, session.email);
|
||||
throw portalError;
|
||||
}
|
||||
|
||||
// Step 4: Update SF flags (DEGRADABLE)
|
||||
await safeOperation(
|
||||
async () =>
|
||||
this.sfFlagsStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
}),
|
||||
{
|
||||
criticality: OperationCriticality.OPTIONAL,
|
||||
fallback: undefined,
|
||||
context: "SF flags update",
|
||||
logger: this.logger,
|
||||
metadata: { email: session.email },
|
||||
}
|
||||
);
|
||||
|
||||
// Step 5: Generate auth result
|
||||
const authResult = await this.authResultStep.execute({
|
||||
userId: portalUserResult.userId,
|
||||
email: session.email,
|
||||
auditSource: isNewCustomer ? "get_started_new_customer" : "get_started_complete_account",
|
||||
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
|
||||
});
|
||||
|
||||
await this.sessionService.invalidate(request.sessionToken);
|
||||
|
||||
this.logger.log(
|
||||
{ email: session.email, userId: portalUserResult.userId, isNewCustomer },
|
||||
"Account completed successfully"
|
||||
);
|
||||
|
||||
return authResult;
|
||||
}
|
||||
|
||||
private async ensureNoExistingAccounts(email: string): Promise<void> {
|
||||
// Only check Portal — the verification phase already checked WHMCS,
|
||||
// and the AddClient step will reject duplicates if one was created in the interim.
|
||||
const portalUser = await this.usersService.findByEmailInternal(email);
|
||||
|
||||
if (portalUser) {
|
||||
throw new ConflictException("An account already exists. Please log in.");
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAddress(
|
||||
requestAddress: CompleteAccountRequest["address"] | undefined,
|
||||
sessionAddress: Record<string, unknown> | undefined
|
||||
): NonNullable<CompleteAccountRequest["address"]> {
|
||||
const address = requestAddress ?? sessionAddress;
|
||||
|
||||
if (!address || !address.postcode) {
|
||||
throw new BadRequestException(
|
||||
"Address information is incomplete. Please ensure postcode is provided."
|
||||
);
|
||||
}
|
||||
|
||||
return address as NonNullable<CompleteAccountRequest["address"]>;
|
||||
}
|
||||
|
||||
private resolveNames(
|
||||
firstName: string | undefined,
|
||||
lastName: string | undefined,
|
||||
session: { firstName?: string | undefined; lastName?: string | undefined }
|
||||
): { finalFirstName: string; finalLastName: string } {
|
||||
const finalFirstName = firstName ?? session.firstName;
|
||||
const finalLastName = lastName ?? session.lastName;
|
||||
|
||||
if (!finalFirstName || !finalLastName) {
|
||||
throw new BadRequestException("Name information is missing. Please provide your name.");
|
||||
}
|
||||
|
||||
return { finalFirstName, finalLastName };
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Signup workflow exports
|
||||
*/
|
||||
export { SignupAccountResolverService } from "./signup-account-resolver.service.js";
|
||||
export { SignupValidationService } from "./signup-validation.service.js";
|
||||
export { SignupWhmcsService } from "./signup-whmcs.service.js";
|
||||
export { SignupUserCreationService } from "./signup-user-creation.service.js";
|
||||
export type {
|
||||
SignupAccountSnapshot,
|
||||
SignupAccountCacheEntry,
|
||||
SignupPreflightResult,
|
||||
} from "./signup.types.js";
|
||||
@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Service responsible for resolving and caching Salesforce account information during signup
|
||||
*/
|
||||
import { Injectable, Inject, BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { ErrorCode } from "@customer-portal/domain/common";
|
||||
import type { SignupRequest } from "@customer-portal/domain/auth";
|
||||
import type { SignupAccountSnapshot, SignupAccountCacheEntry } from "./signup.types.js";
|
||||
import { PORTAL_SOURCE_NEW_SIGNUP } from "@bff/modules/auth/constants/portal.constants.js";
|
||||
|
||||
@Injectable()
|
||||
export class SignupAccountResolverService {
|
||||
private readonly cacheTtlSeconds = 30;
|
||||
private readonly cachePrefix = "auth:signup:account:";
|
||||
|
||||
constructor(
|
||||
private readonly salesforceService: SalesforceFacade,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly cache: CacheService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve a Salesforce account by customer number with caching,
|
||||
* or create a new one if no customer number is provided.
|
||||
*/
|
||||
async resolveOrCreate(
|
||||
signupData: SignupRequest
|
||||
): Promise<{ snapshot: SignupAccountSnapshot; customerNumber: string | null }> {
|
||||
const { sfNumber, email, firstName, lastName, phone, address, gender, dateOfBirth } =
|
||||
signupData;
|
||||
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
|
||||
|
||||
if (normalizedCustomerNumber) {
|
||||
const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
|
||||
if (!resolved) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.CUSTOMER_NOT_FOUND,
|
||||
message: `Salesforce account not found for Customer Number: ${normalizedCustomerNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolved.WH_Account__c && resolved.WH_Account__c.trim() !== "") {
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
}
|
||||
|
||||
return { snapshot: resolved, customerNumber: normalizedCustomerNumber };
|
||||
}
|
||||
|
||||
// No customer number - create new SF account
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const existingAccount = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||
if (existingAccount) {
|
||||
throw new ConflictException(
|
||||
"An account already exists for this email. Please sign in or transfer your account."
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!address?.address1 ||
|
||||
!address?.city ||
|
||||
!address?.state ||
|
||||
!address?.postcode ||
|
||||
!address?.country
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Complete address information is required for account creation"
|
||||
);
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
throw new BadRequestException("Phone number is required for account creation");
|
||||
}
|
||||
|
||||
if (!gender) {
|
||||
throw new BadRequestException("Gender is required for account creation");
|
||||
}
|
||||
|
||||
if (!dateOfBirth) {
|
||||
throw new BadRequestException("Date of birth is required for account creation");
|
||||
}
|
||||
|
||||
let created: { accountId: string; accountNumber: string };
|
||||
try {
|
||||
created = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone,
|
||||
portalSource: PORTAL_SOURCE_NEW_SIGNUP,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Salesforce Account creation failed - blocking signup", {
|
||||
email: normalizedEmail,
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
throw new BadRequestException(
|
||||
"Failed to create customer account. Please try again or contact support."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.salesforceAccountService.createContact({
|
||||
accountId: created.accountId,
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("PersonContact update failed after Account creation", {
|
||||
accountId: created.accountId,
|
||||
email: normalizedEmail,
|
||||
error: extractErrorMessage(error),
|
||||
note: "Salesforce Account was created but Contact update failed",
|
||||
});
|
||||
throw new BadRequestException("Failed to complete account setup. Please contact support.");
|
||||
}
|
||||
|
||||
const snapshot: SignupAccountSnapshot = {
|
||||
id: created.accountId,
|
||||
Name: `${firstName} ${lastName}`,
|
||||
WH_Account__c: null,
|
||||
};
|
||||
|
||||
return { snapshot, customerNumber: created.accountNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Salesforce account by customer number with caching
|
||||
*/
|
||||
async getAccountSnapshot(sfNumber?: string | null): Promise<SignupAccountSnapshot | null> {
|
||||
const normalized = this.normalizeCustomerNumber(sfNumber);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = this.buildCacheKey(normalized);
|
||||
const cached = await this.cache.get<SignupAccountCacheEntry | null>(cacheKey);
|
||||
const unwrapped = this.unwrapCacheEntry(cached);
|
||||
|
||||
if (unwrapped.hit) {
|
||||
this.logger.debug("Account snapshot cache hit", { sfNumber: normalized });
|
||||
return unwrapped.value;
|
||||
}
|
||||
|
||||
const resolved =
|
||||
await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized);
|
||||
|
||||
await this.cache.set(cacheKey, this.wrapCacheEntry(resolved ?? null), this.cacheTtlSeconds);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize customer number input
|
||||
*/
|
||||
normalizeCustomerNumber(sfNumber?: string | null): string | null {
|
||||
if (typeof sfNumber !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = sfNumber.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached account snapshot
|
||||
*/
|
||||
async invalidateCache(sfNumber: string): Promise<void> {
|
||||
const normalized = this.normalizeCustomerNumber(sfNumber);
|
||||
if (normalized) {
|
||||
const cacheKey = this.buildCacheKey(normalized);
|
||||
await this.cache.del(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
private buildCacheKey(customerNumber: string): string {
|
||||
return `${this.cachePrefix}${customerNumber}`;
|
||||
}
|
||||
|
||||
private unwrapCacheEntry(cached: SignupAccountCacheEntry | null): {
|
||||
hit: boolean;
|
||||
value: SignupAccountSnapshot | null;
|
||||
} {
|
||||
if (!cached) {
|
||||
return { hit: false, value: null };
|
||||
}
|
||||
|
||||
if (typeof cached === "object" && cached.__signupCache === true) {
|
||||
return { hit: true, value: cached.value ?? null };
|
||||
}
|
||||
|
||||
return { hit: true, value: (cached as unknown as SignupAccountSnapshot) ?? null };
|
||||
}
|
||||
|
||||
private wrapCacheEntry(snapshot: SignupAccountSnapshot | null): SignupAccountCacheEntry {
|
||||
return {
|
||||
value: snapshot ?? null,
|
||||
__signupCache: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Service responsible for validating signup data and checking for existing accounts
|
||||
*/
|
||||
import { Injectable, Inject, BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type { Request } from "express";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { SignupAccountResolverService } from "./signup-account-resolver.service.js";
|
||||
import type { ValidateSignupRequest } from "@customer-portal/domain/auth";
|
||||
import type { SignupPreflightResult } from "./signup.types.js";
|
||||
|
||||
/** Error message for duplicate account conflicts */
|
||||
const DUPLICATE_ACCOUNT_MESSAGE =
|
||||
"You already have an account. Please use the login page to access your existing account.";
|
||||
|
||||
/** Error message when customer number not found in Salesforce */
|
||||
const CUSTOMER_NOT_FOUND_MESSAGE = "CUSTOMER_NOT_FOUND_MESSAGE";
|
||||
|
||||
@Injectable()
|
||||
export class SignupValidationService {
|
||||
// eslint-disable-next-line max-params -- NestJS DI requires individual constructor injection
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly accountResolver: SignupAccountResolverService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate customer number for signup
|
||||
*/
|
||||
async validateCustomerNumber(
|
||||
validateData: ValidateSignupRequest,
|
||||
request?: Request
|
||||
): Promise<{ valid: boolean; sfAccountId?: string; message: string }> {
|
||||
const { sfNumber } = validateData;
|
||||
const normalizedCustomerNumber = this.accountResolver.normalizeCustomerNumber(sfNumber);
|
||||
|
||||
if (!normalizedCustomerNumber) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber: sfNumber ?? null, reason: "no_customer_number_provided" },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
message: "Customer number is not required for signup",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accountSnapshot =
|
||||
await this.accountResolver.getAccountSnapshot(normalizedCustomerNumber);
|
||||
if (!accountSnapshot) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
|
||||
request,
|
||||
false,
|
||||
CUSTOMER_NOT_FOUND_MESSAGE
|
||||
);
|
||||
throw new BadRequestException(CUSTOMER_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
|
||||
if (existingMapping) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, sfAccountId: accountSnapshot.id, reason: "Already has mapping" },
|
||||
request,
|
||||
false,
|
||||
"Customer number already registered"
|
||||
);
|
||||
throw new ConflictException(DUPLICATE_ACCOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{
|
||||
sfNumber,
|
||||
sfAccountId: accountSnapshot.id,
|
||||
whAccount: accountSnapshot.WH_Account__c,
|
||||
reason: "WH Account not empty",
|
||||
},
|
||||
request,
|
||||
false,
|
||||
"Account already has WHMCS integration"
|
||||
);
|
||||
throw new ConflictException(DUPLICATE_ACCOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
sfAccountId: accountSnapshot.id,
|
||||
message: "Customer number validated successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException || error instanceof ConflictException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber: normalizedCustomerNumber, error: extractErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
extractErrorMessage(error)
|
||||
);
|
||||
|
||||
this.logger.error("Signup validation error", { error: extractErrorMessage(error) });
|
||||
throw new BadRequestException("Validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email is already registered in portal
|
||||
*/
|
||||
async checkExistingPortalUser(email: string): Promise<{
|
||||
exists: boolean;
|
||||
userId?: string;
|
||||
hasMapping?: boolean;
|
||||
needsPasswordSet?: boolean;
|
||||
}> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const existingUser = await this.usersService.findByEmailInternal(normalizedEmail);
|
||||
|
||||
if (!existingUser) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const hasMapping = await this.mappingsService.hasMapping(existingUser.id);
|
||||
return {
|
||||
exists: true,
|
||||
userId: existingUser.id,
|
||||
hasMapping,
|
||||
needsPasswordSet: !existingUser.passwordHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive preflight check before signup
|
||||
*/
|
||||
async preflightCheck(email: string, sfNumber?: string | null): Promise<SignupPreflightResult> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const normalizedCustomerNumber = this.accountResolver.normalizeCustomerNumber(sfNumber);
|
||||
const result = this.createEmptyPreflightResult(normalizedEmail);
|
||||
|
||||
const portalCheckResult = await this.checkPortalUser(normalizedEmail, result);
|
||||
if (portalCheckResult) return portalCheckResult;
|
||||
|
||||
if (!normalizedCustomerNumber) {
|
||||
return this.checkWithoutCustomerNumber(normalizedEmail, result);
|
||||
}
|
||||
|
||||
return this.checkWithCustomerNumber(normalizedEmail, normalizedCustomerNumber, result);
|
||||
}
|
||||
|
||||
private createEmptyPreflightResult(normalizedEmail: string): SignupPreflightResult {
|
||||
return {
|
||||
canProceed: false,
|
||||
nextAction: null,
|
||||
messages: [],
|
||||
normalized: { email: normalizedEmail },
|
||||
portal: { userExists: false },
|
||||
salesforce: { alreadyMapped: false },
|
||||
whmcs: { clientExists: false },
|
||||
};
|
||||
}
|
||||
|
||||
private async checkPortalUser(
|
||||
email: string,
|
||||
result: SignupPreflightResult
|
||||
): Promise<SignupPreflightResult | null> {
|
||||
const portalUserAuth = await this.usersService.findByEmailInternal(email);
|
||||
if (!portalUserAuth) return null;
|
||||
|
||||
result.portal.userExists = true;
|
||||
const mapped = await this.mappingsService.hasMapping(portalUserAuth.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("An account already exists. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.portal.needsPasswordSet = !portalUserAuth.passwordHash;
|
||||
result.nextAction = portalUserAuth.passwordHash ? "login" : "fix_input";
|
||||
result.messages.push(
|
||||
portalUserAuth.passwordHash
|
||||
? "An account exists without billing link. Please sign in to continue setup."
|
||||
: "An account exists and needs password setup. Please set a password to continue."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async checkWhmcsClientMapping(
|
||||
clientId: number,
|
||||
result: SignupPreflightResult
|
||||
): Promise<SignupPreflightResult | null> {
|
||||
result.whmcs.clientExists = true;
|
||||
result.whmcs.clientId = clientId;
|
||||
|
||||
try {
|
||||
const mapped = await this.mappingsService.findByWhmcsClientId(clientId);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This billing account is already linked. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// ignore; treat as unmapped
|
||||
}
|
||||
|
||||
result.nextAction = "link_whmcs";
|
||||
result.messages.push(
|
||||
"We found an existing billing account for this email. Please transfer your account to continue."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async checkWithoutCustomerNumber(
|
||||
email: string,
|
||||
result: SignupPreflightResult
|
||||
): Promise<SignupPreflightResult> {
|
||||
const client = await this.discoveryService.findClientByEmail(email);
|
||||
if (client) {
|
||||
const whmcsResult = await this.checkWhmcsClientMapping(client.id, result);
|
||||
if (whmcsResult) return whmcsResult;
|
||||
}
|
||||
|
||||
const sfCheckResult = await this.checkSalesforceAccount(email, result);
|
||||
if (sfCheckResult) return sfCheckResult;
|
||||
|
||||
return this.markAsCanProceed(result);
|
||||
}
|
||||
|
||||
private async checkSalesforceAccount(
|
||||
email: string,
|
||||
result: SignupPreflightResult
|
||||
): Promise<SignupPreflightResult | null> {
|
||||
try {
|
||||
const existingSf = await this.salesforceAccountService.findByEmail(email);
|
||||
if (!existingSf) return null;
|
||||
|
||||
return {
|
||||
...result,
|
||||
salesforce: { ...result.salesforce, accountId: existingSf.id },
|
||||
nextAction: "complete_account",
|
||||
messages: [
|
||||
...result.messages,
|
||||
"We found your existing account. Please verify your email to complete setup.",
|
||||
],
|
||||
};
|
||||
} catch (sfErr) {
|
||||
this.logger.warn("Salesforce preflight check failed", {
|
||||
error: extractErrorMessage(sfErr),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkWithCustomerNumber(
|
||||
email: string,
|
||||
customerNumber: string,
|
||||
result: SignupPreflightResult
|
||||
): Promise<SignupPreflightResult> {
|
||||
const accountSnapshot = await this.accountResolver.getAccountSnapshot(customerNumber);
|
||||
if (!accountSnapshot) {
|
||||
result.nextAction = "fix_input";
|
||||
result.messages.push(CUSTOMER_NOT_FOUND_MESSAGE);
|
||||
return result;
|
||||
}
|
||||
result.salesforce.accountId = accountSnapshot.id;
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
|
||||
if (existingMapping) {
|
||||
result.salesforce.alreadyMapped = true;
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This customer number is already registered. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
const client = await this.discoveryService.findClientByEmail(email);
|
||||
if (client) {
|
||||
const whmcsResult = await this.checkWhmcsClientMapping(client.id, result);
|
||||
if (whmcsResult) return whmcsResult;
|
||||
}
|
||||
|
||||
return this.markAsCanProceed(result);
|
||||
}
|
||||
|
||||
private markAsCanProceed(result: SignupPreflightResult): SignupPreflightResult {
|
||||
result.canProceed = true;
|
||||
result.nextAction = "proceed_signup";
|
||||
result.messages.push("All checks passed. Ready to create your account.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Shared types for signup workflow services
|
||||
*/
|
||||
|
||||
/**
|
||||
* Snapshot of a Salesforce account for signup purposes
|
||||
*/
|
||||
export interface SignupAccountSnapshot {
|
||||
id: string;
|
||||
Name?: string | null | undefined;
|
||||
WH_Account__c?: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache entry structure for account snapshots
|
||||
*/
|
||||
export interface SignupAccountCacheEntry {
|
||||
value: SignupAccountSnapshot | null;
|
||||
__signupCache: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of signup preflight check
|
||||
*/
|
||||
export interface SignupPreflightResult {
|
||||
canProceed: boolean;
|
||||
nextAction:
|
||||
| "login"
|
||||
| "proceed_signup"
|
||||
| "link_whmcs"
|
||||
| "fix_input"
|
||||
| "blocked"
|
||||
| "complete_account"
|
||||
| null;
|
||||
messages: string[];
|
||||
normalized: {
|
||||
email: string;
|
||||
};
|
||||
portal: {
|
||||
userExists: boolean;
|
||||
needsPasswordSet?: boolean | undefined;
|
||||
};
|
||||
salesforce: {
|
||||
accountId?: string | undefined;
|
||||
alreadyMapped: boolean;
|
||||
};
|
||||
whmcs: {
|
||||
clientExists: boolean;
|
||||
clientId?: number | undefined;
|
||||
};
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SignupUserCreationService } from "../signup/signup-user-creation.service.js";
|
||||
import { PortalUserCreationService } from "./portal-user-creation.service.js";
|
||||
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
@ -19,7 +19,7 @@ export interface CreatePortalUserResult {
|
||||
/**
|
||||
* Step: Create a portal user with ID mapping.
|
||||
*
|
||||
* Delegates to SignupUserCreationService for the Prisma transaction
|
||||
* Delegates to PortalUserCreationService for the Prisma transaction
|
||||
* that creates both the User row and the IdMapping row atomically.
|
||||
*
|
||||
* Rollback deletes the user and associated ID mapping.
|
||||
@ -27,13 +27,13 @@ export interface CreatePortalUserResult {
|
||||
@Injectable()
|
||||
export class CreatePortalUserStep {
|
||||
constructor(
|
||||
private readonly signupUserCreation: SignupUserCreationService,
|
||||
private readonly portalUserCreation: PortalUserCreationService,
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(params: CreatePortalUserParams): Promise<CreatePortalUserResult> {
|
||||
const { userId } = await this.signupUserCreation.createUserWithMapping({
|
||||
const { userId } = await this.portalUserCreation.createUserWithMapping({
|
||||
email: params.email,
|
||||
passwordHash: params.passwordHash,
|
||||
whmcsClientId: params.whmcsClientId,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SignupWhmcsService } from "../signup/signup-whmcs.service.js";
|
||||
import type { WhmcsCreatedClient } from "../signup/signup-whmcs.service.js";
|
||||
import { WhmcsCleanupService, type WhmcsCreatedClient } from "./whmcs-cleanup.service.js";
|
||||
|
||||
export interface CreateWhmcsClientParams {
|
||||
email: string;
|
||||
@ -31,13 +30,13 @@ export interface CreateWhmcsClientResult {
|
||||
/**
|
||||
* Step: Create a WHMCS billing client.
|
||||
*
|
||||
* Delegates to SignupWhmcsService for the actual API call.
|
||||
* Delegates to WhmcsCleanupService for the actual API call.
|
||||
* Rollback marks the created client as inactive for manual cleanup.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreateWhmcsClientStep {
|
||||
constructor(
|
||||
private readonly signupWhmcs: SignupWhmcsService,
|
||||
private readonly signupWhmcs: WhmcsCleanupService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
|
||||
@ -25,3 +25,7 @@ export type {
|
||||
CreateEligibilityCaseAddress,
|
||||
CreateEligibilityCaseResult,
|
||||
} from "./create-eligibility-case.step.js";
|
||||
|
||||
export { PortalUserCreationService } from "./portal-user-creation.service.js";
|
||||
export type { UserCreationParams, CreatedUserResult } from "./portal-user-creation.service.js";
|
||||
export { WhmcsCleanupService } from "./whmcs-cleanup.service.js";
|
||||
|
||||
@ -5,7 +5,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { SignupWhmcsService } from "./signup-whmcs.service.js";
|
||||
import { WhmcsCleanupService } from "./whmcs-cleanup.service.js";
|
||||
|
||||
export interface UserCreationParams {
|
||||
email: string;
|
||||
@ -19,10 +19,10 @@ export interface CreatedUserResult {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SignupUserCreationService {
|
||||
export class PortalUserCreationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly whmcsService: SignupWhmcsService,
|
||||
private readonly whmcsService: WhmcsCleanupService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -1,23 +1,15 @@
|
||||
/**
|
||||
* Service responsible for WHMCS client creation and cleanup during signup
|
||||
* Service responsible for WHMCS client creation and cleanup during signup.
|
||||
*
|
||||
* Used by CreateWhmcsClientStep for client creation and by
|
||||
* PortalUserCreationService for compensation (marking orphaned clients).
|
||||
*/
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||
import { ErrorCode } from "@customer-portal/domain/common";
|
||||
import { serializeWhmcsKeyValueMap } from "@customer-portal/domain/customer/providers";
|
||||
import type { SignupRequest } from "@customer-portal/domain/auth";
|
||||
|
||||
export interface WhmcsClientCreationParams {
|
||||
firstName: string;
|
||||
@ -45,33 +37,13 @@ export interface WhmcsCreatedClient {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SignupWhmcsService {
|
||||
export class WhmcsCleanupService {
|
||||
constructor(
|
||||
private readonly whmcsClientService: WhmcsClientService,
|
||||
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if WHMCS client already exists for email
|
||||
* Throws ConflictException or DomainHttpException if client exists
|
||||
*/
|
||||
async checkExistingClient(email: string): Promise<void> {
|
||||
const existingWhmcs = await this.discoveryService.findClientByEmail(email);
|
||||
if (!existingWhmcs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("You already have an account. Please sign in.");
|
||||
}
|
||||
|
||||
throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new WHMCS client for signup
|
||||
*/
|
||||
@ -160,27 +132,4 @@ export class SignupWhmcsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate address data for WHMCS client creation
|
||||
*/
|
||||
validateAddressData(signupData: SignupRequest): void {
|
||||
const { phone, address } = signupData;
|
||||
|
||||
if (
|
||||
!address?.address1 ||
|
||||
!address?.city ||
|
||||
!address?.state ||
|
||||
!address?.postcode ||
|
||||
!address?.country
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Complete address information is required for billing account creation"
|
||||
);
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
throw new BadRequestException("Phone number is required for billing account creation");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
import {
|
||||
@ -11,7 +10,6 @@ import {
|
||||
type VerifyCodeResponse,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
@ -19,6 +17,7 @@ import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/w
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
|
||||
import { OtpService } from "../otp/otp.service.js";
|
||||
import { OtpEmailService } from "../otp/otp-email.service.js";
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
|
||||
/**
|
||||
@ -53,10 +52,9 @@ interface AccountStatusResult {
|
||||
@Injectable()
|
||||
export class VerificationWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly otpEmailService: OtpEmailService,
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
@ -82,7 +80,7 @@ export class VerificationWorkflowService {
|
||||
await this.sessionService.create(normalizedEmail);
|
||||
|
||||
// Send email with OTP code
|
||||
await this.sendOtpEmail(normalizedEmail, code);
|
||||
await this.otpEmailService.sendOtpCode(normalizedEmail, code, "verification");
|
||||
|
||||
this.logger.log({ email: normalizedEmail }, "OTP verification code sent");
|
||||
|
||||
@ -161,32 +159,6 @@ export class VerificationWorkflowService {
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async sendOtpEmail(email: string, code: string): Promise<void> {
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_OTP_VERIFICATION");
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Your verification code",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
code,
|
||||
expiresMinutes: "10",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Your verification code",
|
||||
html: `
|
||||
<p>Your verification code is: <strong>${code}</strong></p>
|
||||
<p>This code expires in 10 minutes.</p>
|
||||
<p>If you didn't request this code, please ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildOtpErrorResponse(otpResult: {
|
||||
reason?: "expired" | "invalid" | "max_attempts";
|
||||
attemptsRemaining?: number;
|
||||
|
||||
@ -1,156 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { ErrorCode } from "@customer-portal/domain/common";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import { getCustomFieldValue } from "@customer-portal/domain/customer/providers";
|
||||
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import {
|
||||
PORTAL_SOURCE_MIGRATED,
|
||||
PORTAL_STATUS_ACTIVE,
|
||||
} from "@bff/modules/auth/constants/portal.constants.js";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsLinkWorkflowService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly whmcsClientService: WhmcsClientService,
|
||||
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||
private readonly salesforceService: SalesforceFacade,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async linkWhmcsUser(email: string, password: string) {
|
||||
const existingUser = await this.usersService.findByEmailInternal(email);
|
||||
if (existingUser) {
|
||||
if (!existingUser.passwordHash) {
|
||||
this.logger.log("User exists but has no password - allowing password setup to continue", {
|
||||
userId: existingUser.id,
|
||||
});
|
||||
return {
|
||||
user: mapPrismaUserToDomain(existingUser),
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new ConflictException(
|
||||
"User already exists in portal and has completed setup. Please use the login page."
|
||||
);
|
||||
}
|
||||
|
||||
return safeOperation(
|
||||
async () => {
|
||||
const clientDetails = await this.discoveryService.findClientByEmail(email);
|
||||
if (!clientDetails) {
|
||||
throw new BadRequestException(
|
||||
"No billing account found with this email address. Please check your email or contact support."
|
||||
);
|
||||
}
|
||||
|
||||
const clientNumericId = clientDetails.id;
|
||||
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientNumericId);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("This billing account is already linked. Please sign in.");
|
||||
}
|
||||
|
||||
this.logger.debug("Validating WHMCS credentials");
|
||||
const validateResult = await this.whmcsClientService.validateLogin(email, password);
|
||||
this.logger.debug("WHMCS validation successful");
|
||||
if (!validateResult || !validateResult.userId) {
|
||||
throw new BadRequestException("Invalid email or password. Please try again.");
|
||||
}
|
||||
|
||||
const customerNumber =
|
||||
getCustomFieldValue(clientDetails.customfields, "198")?.trim() ??
|
||||
getCustomFieldValue(clientDetails.customfields, "Customer Number")?.trim();
|
||||
|
||||
if (!customerNumber) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.ACCOUNT_MAPPING_MISSING,
|
||||
message: `Customer Number not found in WHMCS custom field 198 for client ${clientNumericId}`,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log("Found Customer Number for WHMCS client", {
|
||||
whmcsClientId: clientNumericId,
|
||||
hasCustomerNumber: !!customerNumber,
|
||||
});
|
||||
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(customerNumber);
|
||||
if (!sfAccount) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.CUSTOMER_NOT_FOUND,
|
||||
message: `Salesforce account not found for Customer Number: ${customerNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
const createdUser = await this.usersService.create(
|
||||
{
|
||||
email,
|
||||
passwordHash: null,
|
||||
emailVerified: true,
|
||||
},
|
||||
{ includeProfile: false }
|
||||
);
|
||||
|
||||
await this.mappingsService.createMapping({
|
||||
userId: createdUser.id,
|
||||
whmcsClientId: clientNumericId,
|
||||
sfAccountId: sfAccount.id,
|
||||
});
|
||||
|
||||
const prismaUser = await this.usersService.findByIdInternal(createdUser.id);
|
||||
if (!prismaUser) {
|
||||
throw new InternalServerErrorException("Failed to load newly linked user");
|
||||
}
|
||||
|
||||
const userProfile: User = mapPrismaUserToDomain(prismaUser);
|
||||
|
||||
try {
|
||||
await this.salesforceService.updateAccountPortalFields(sfAccount.id, {
|
||||
status: PORTAL_STATUS_ACTIVE,
|
||||
source: PORTAL_SOURCE_MIGRATED,
|
||||
lastSignedInAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to update Salesforce portal flags after WHMCS link", {
|
||||
accountId: sfAccount.id,
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user: userProfile,
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
criticality: OperationCriticality.CRITICAL,
|
||||
context: "WHMCS account linking",
|
||||
logger: this.logger,
|
||||
rethrow: [
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
],
|
||||
fallbackMessage: "Failed to link WHMCS account",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OtpService } from "../infra/otp/otp.service.js";
|
||||
import { OtpEmailService } from "../infra/otp/otp-email.service.js";
|
||||
import { GetStartedSessionService } from "../infra/otp/get-started-session.service.js";
|
||||
|
||||
/**
|
||||
* OTP Module
|
||||
*
|
||||
* Owns OTP generation/verification and get-started session management.
|
||||
* Both services are exported for use by LoginModule and GetStartedModule.
|
||||
* Owns OTP generation/verification, OTP email sending,
|
||||
* and get-started session management.
|
||||
* Services are exported for use by LoginModule and GetStartedModule.
|
||||
*/
|
||||
@Module({
|
||||
providers: [OtpService, GetStartedSessionService],
|
||||
exports: [OtpService, GetStartedSessionService],
|
||||
providers: [OtpService, OtpEmailService, GetStartedSessionService],
|
||||
exports: [OtpService, OtpEmailService, GetStartedSessionService],
|
||||
})
|
||||
export class OtpModule {}
|
||||
|
||||
@ -22,7 +22,6 @@ import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.
|
||||
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
||||
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js";
|
||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||
@ -49,14 +48,12 @@ import {
|
||||
passwordResetRequestSchema,
|
||||
passwordResetSchema,
|
||||
setPasswordRequestSchema,
|
||||
linkWhmcsRequestSchema,
|
||||
changePasswordRequestSchema,
|
||||
accountStatusRequestSchema,
|
||||
ssoLinkRequestSchema,
|
||||
checkPasswordNeededRequestSchema,
|
||||
refreshTokenRequestSchema,
|
||||
checkPasswordNeededResponseSchema,
|
||||
linkWhmcsResponseSchema,
|
||||
loginVerifyOtpRequestSchema,
|
||||
} from "@customer-portal/domain/auth";
|
||||
|
||||
@ -70,14 +67,12 @@ export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
|
||||
|
||||
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
|
||||
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
|
||||
class LinkWhmcsRequestDto extends createZodDto(linkWhmcsRequestSchema) {}
|
||||
class SetPasswordRequestDto extends createZodDto(setPasswordRequestSchema) {}
|
||||
class CheckPasswordNeededRequestDto extends createZodDto(checkPasswordNeededRequestSchema) {}
|
||||
class PasswordResetRequestDto extends createZodDto(passwordResetRequestSchema) {}
|
||||
class ResetPasswordRequestDto extends createZodDto(passwordResetSchema) {}
|
||||
class ChangePasswordRequestDto extends createZodDto(changePasswordRequestSchema) {}
|
||||
class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {}
|
||||
class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {}
|
||||
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
|
||||
class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {}
|
||||
|
||||
@ -284,16 +279,6 @@ export class AuthController {
|
||||
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("migrate")
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
|
||||
@ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto })
|
||||
async migrateAccount(@Body() linkData: LinkWhmcsRequestDto) {
|
||||
const result = await this.authOrchestrator.linkWhmcsUser(linkData);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("set-password")
|
||||
@UseGuards(RateLimitGuard)
|
||||
|
||||
@ -20,6 +20,7 @@ function LoginContent() {
|
||||
const { loading, isAuthenticated } = useAuthStore();
|
||||
const searchParams = useSearchParams();
|
||||
const reasonParam = useMemo(() => searchParams?.get("reason"), [searchParams]);
|
||||
const prefillEmail = useMemo(() => searchParams?.get("email") ?? undefined, [searchParams]);
|
||||
const [logoutReason, setLogoutReason] = useState<LogoutReason | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
@ -71,7 +72,7 @@ function LoginContent() {
|
||||
{logoutMessage.body}
|
||||
</AlertBanner>
|
||||
)}
|
||||
<LoginForm />
|
||||
<LoginForm initialEmail={prefillEmail} />
|
||||
</AuthLayout>
|
||||
|
||||
{/* Full-page loading overlay during authentication */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user