Refactor conditional rendering and improve code readability across multiple components
- Updated conditional rendering syntax in CheckoutStatusBanners, IdentityVerificationSection, and other components for consistency. - Removed unused quick eligibility and maybe later API functions from get-started API. - Enhanced error handling messages in various subscription views. - Updated documentation to reflect changes in eligibility check flow. - Cleaned up unused fields in Salesforce mapping and raw types.
This commit is contained in:
parent
a01361bb89
commit
aa77f23d85
@ -192,8 +192,8 @@ export class SalesforceAccountService {
|
||||
// Record type for Person Accounts (required)
|
||||
RecordTypeId: personAccountRecordTypeId,
|
||||
// Portal tracking fields
|
||||
[this.portalStatusField]: "Active",
|
||||
[this.portalSourceField]: "Portal Checkout",
|
||||
[this.portalStatusField]: data.portalStatus ?? "Not Yet",
|
||||
[this.portalSourceField]: data.portalSource,
|
||||
};
|
||||
|
||||
this.logger.debug("Person Account creation payload", {
|
||||
@ -525,6 +525,10 @@ export interface CreateSalesforceAccountRequest {
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
/** Portal status - defaults to "Not Yet" if not provided */
|
||||
portalStatus?: string;
|
||||
/** Portal registration source - REQUIRED, must be explicitly set by caller */
|
||||
portalSource: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -166,9 +166,7 @@ export class SalesforceCaseService {
|
||||
* @param params - Case creation parameters
|
||||
* @returns Created case ID and case number
|
||||
*/
|
||||
async createCase(
|
||||
params: CreateCaseParams
|
||||
): Promise<{ id: string; caseNumber: string; threadId: string | null }> {
|
||||
async createCase(params: CreateCaseParams): Promise<{ id: string; caseNumber: string }> {
|
||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||
const safeContactId = params.contactId
|
||||
? assertSalesforceId(params.contactId, "contactId")
|
||||
@ -219,19 +217,17 @@ export class SalesforceCaseService {
|
||||
throw new Error("Salesforce did not return a case ID");
|
||||
}
|
||||
|
||||
// Fetch the created case to get the CaseNumber and Thread_Id
|
||||
// Fetch the created case to get the CaseNumber
|
||||
const createdCase = await this.getCaseByIdInternal(created.id);
|
||||
const caseNumber = createdCase?.CaseNumber ?? created.id;
|
||||
const threadId = createdCase?.Thread_Id ?? null;
|
||||
|
||||
this.logger.log("Salesforce case created successfully", {
|
||||
caseId: created.id,
|
||||
caseNumber,
|
||||
threadId,
|
||||
origin: params.origin,
|
||||
});
|
||||
|
||||
return { id: created.id, caseNumber, threadId };
|
||||
return { id: created.id, caseNumber };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to create Salesforce case", {
|
||||
error: extractErrorMessage(error),
|
||||
@ -248,9 +244,7 @@ export class SalesforceCaseService {
|
||||
* Does not require an Account - uses supplied contact info.
|
||||
* Separate from createCase() because it has a different payload structure.
|
||||
*/
|
||||
async createWebCase(
|
||||
params: CreateWebCaseParams
|
||||
): Promise<{ id: string; caseNumber: string; threadId: string | null }> {
|
||||
async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> {
|
||||
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
|
||||
|
||||
const casePayload: Record<string, unknown> = {
|
||||
@ -271,19 +265,17 @@ export class SalesforceCaseService {
|
||||
throw new Error("Salesforce did not return a case ID");
|
||||
}
|
||||
|
||||
// Fetch the created case to get the CaseNumber and Thread_Id
|
||||
// Fetch the created case to get the CaseNumber
|
||||
const createdCase = await this.getCaseByIdInternal(created.id);
|
||||
const caseNumber = createdCase?.CaseNumber ?? created.id;
|
||||
const threadId = createdCase?.Thread_Id ?? null;
|
||||
|
||||
this.logger.log("Web-to-Case created successfully", {
|
||||
caseId: created.id,
|
||||
caseNumber,
|
||||
threadId,
|
||||
email: params.suppliedEmail,
|
||||
});
|
||||
|
||||
return { id: created.id, caseNumber, threadId };
|
||||
return { id: created.id, caseNumber };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to create Web-to-Case", {
|
||||
error: extractErrorMessage(error),
|
||||
|
||||
@ -4,6 +4,8 @@ export type PortalStatus = typeof PORTAL_STATUS_ACTIVE | typeof PORTAL_STATUS_NO
|
||||
|
||||
export const PORTAL_SOURCE_NEW_SIGNUP = "New Signup" as const;
|
||||
export const PORTAL_SOURCE_MIGRATED = "Migrated" as const;
|
||||
export const PORTAL_SOURCE_INTERNET_ELIGIBILITY = "Internet Eligibility" as const;
|
||||
export type PortalRegistrationSource =
|
||||
| typeof PORTAL_SOURCE_NEW_SIGNUP
|
||||
| typeof PORTAL_SOURCE_MIGRATED;
|
||||
| typeof PORTAL_SOURCE_MIGRATED
|
||||
| typeof PORTAL_SOURCE_INTERNET_ELIGIBILITY;
|
||||
|
||||
@ -22,7 +22,32 @@ interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
|
||||
/** Timestamp when session was marked as used (for one-time operations) */
|
||||
usedAt?: string;
|
||||
/** The operation that used this session */
|
||||
usedFor?: "guest_eligibility" | "signup_with_eligibility" | "complete_account";
|
||||
usedFor?: "signup_with_eligibility" | "complete_account";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean SessionData object from partial data, filtering out undefined values.
|
||||
* This maintains exactOptionalPropertyTypes compliance.
|
||||
*/
|
||||
function buildSessionData(
|
||||
required: Pick<SessionData, "id" | "email" | "emailVerified" | "createdAt">,
|
||||
optional: Partial<Omit<SessionData, "id" | "email" | "emailVerified" | "createdAt">>
|
||||
): SessionData {
|
||||
const result: SessionData = { ...required };
|
||||
|
||||
if (optional.usedAt !== undefined) result.usedAt = optional.usedAt;
|
||||
if (optional.usedFor !== undefined) result.usedFor = optional.usedFor;
|
||||
if (optional.accountStatus !== undefined) result.accountStatus = optional.accountStatus;
|
||||
if (optional.firstName !== undefined) result.firstName = optional.firstName;
|
||||
if (optional.lastName !== undefined) result.lastName = optional.lastName;
|
||||
if (optional.phone !== undefined) result.phone = optional.phone;
|
||||
if (optional.address !== undefined) result.address = optional.address;
|
||||
if (optional.sfAccountId !== undefined) result.sfAccountId = optional.sfAccountId;
|
||||
if (optional.whmcsClientId !== undefined) result.whmcsClientId = optional.whmcsClientId;
|
||||
if (optional.eligibilityStatus !== undefined)
|
||||
result.eligibilityStatus = optional.eligibilityStatus;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,7 +68,10 @@ export class GetStartedSessionService {
|
||||
private readonly HANDOFF_PREFIX = "guest-handoff:";
|
||||
private readonly SESSION_LOCK_PREFIX = "session-lock:";
|
||||
private readonly ttlSeconds: number;
|
||||
private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens
|
||||
/** TTL for handoff tokens (30 minutes) */
|
||||
private readonly handoffTtlSeconds = 1800;
|
||||
/** Lock TTL must exceed workflow lock (60s) to prevent premature expiry */
|
||||
private readonly sessionLockTtlMs = 65_000;
|
||||
|
||||
constructor(
|
||||
private readonly cache: CacheService,
|
||||
@ -51,14 +79,11 @@ export class GetStartedSessionService {
|
||||
private readonly config: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
this.ttlSeconds = this.config.get<number>("GET_STARTED_SESSION_TTL", 3600); // 1 hour
|
||||
this.ttlSeconds = this.config.get<number>("GET_STARTED_SESSION_TTL", 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for email verification
|
||||
*
|
||||
* @param email - Email address (normalized)
|
||||
* @returns Session token (UUID)
|
||||
*/
|
||||
async create(email: string): Promise<string> {
|
||||
const sessionId = randomUUID();
|
||||
@ -72,7 +97,6 @@ export class GetStartedSessionService {
|
||||
};
|
||||
|
||||
await this.cache.set(this.buildKey(sessionId), sessionData, this.ttlSeconds);
|
||||
|
||||
this.logger.debug({ email: normalizedEmail, sessionId }, "Get-started session created");
|
||||
|
||||
return sessionId;
|
||||
@ -80,16 +104,10 @@ export class GetStartedSessionService {
|
||||
|
||||
/**
|
||||
* Get session by token
|
||||
*
|
||||
* @param sessionToken - Session token (UUID)
|
||||
* @returns Session data or null if not found/expired
|
||||
*/
|
||||
async get(sessionToken: string): Promise<GetStartedSession | null> {
|
||||
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||
|
||||
if (!sessionData) {
|
||||
return null;
|
||||
}
|
||||
if (!sessionData) return null;
|
||||
|
||||
return {
|
||||
...sessionData,
|
||||
@ -98,7 +116,7 @@ export class GetStartedSessionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session with email verification status
|
||||
* Update session with email verification status and optional prefill data
|
||||
*/
|
||||
async markEmailVerified(
|
||||
sessionToken: string,
|
||||
@ -114,10 +132,7 @@ export class GetStartedSessionService {
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||
|
||||
if (!sessionData) {
|
||||
return false;
|
||||
}
|
||||
if (!sessionData) return false;
|
||||
|
||||
const updatedData: SessionData = {
|
||||
...sessionData,
|
||||
@ -126,7 +141,6 @@ export class GetStartedSessionService {
|
||||
...prefillData,
|
||||
};
|
||||
|
||||
// Calculate remaining TTL
|
||||
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
|
||||
await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl);
|
||||
|
||||
@ -138,45 +152,6 @@ export class GetStartedSessionService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session with quick check data
|
||||
*/
|
||||
async updateWithQuickCheckData(
|
||||
sessionToken: string,
|
||||
data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
address: GetStartedSession["address"];
|
||||
phone?: string;
|
||||
sfAccountId?: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||
|
||||
if (!sessionData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedData: SessionData = {
|
||||
...sessionData,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
address: data.address,
|
||||
phone: data.phone,
|
||||
sfAccountId: data.sfAccountId,
|
||||
};
|
||||
|
||||
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
|
||||
await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl);
|
||||
|
||||
this.logger.debug(
|
||||
{ sessionId: sessionToken },
|
||||
"Get-started session updated with quick check data"
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session (after successful account creation)
|
||||
*/
|
||||
@ -187,8 +162,6 @@ export class GetStartedSessionService {
|
||||
|
||||
/**
|
||||
* Validate that a session exists and email is verified
|
||||
*
|
||||
* @returns Session data if valid, null otherwise
|
||||
*/
|
||||
async validateVerifiedSession(sessionToken: string): Promise<GetStartedSession | null> {
|
||||
const session = await this.get(sessionToken);
|
||||
@ -216,10 +189,6 @@ export class GetStartedSessionService {
|
||||
* This token allows passing data from guest eligibility check to account creation
|
||||
* without requiring email verification first. The email will be verified when
|
||||
* the user proceeds to account creation.
|
||||
*
|
||||
* @param email - Email address (NOT verified)
|
||||
* @param data - User data from eligibility check
|
||||
* @returns Handoff token ID
|
||||
*/
|
||||
async createGuestHandoffToken(
|
||||
email: string,
|
||||
@ -248,42 +217,11 @@ export class GetStartedSessionService {
|
||||
};
|
||||
|
||||
await this.cache.set(this.buildHandoffKey(tokenId), tokenData, this.handoffTtlSeconds);
|
||||
|
||||
this.logger.debug({ email: normalizedEmail, tokenId }, "Guest handoff token created");
|
||||
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and retrieve guest handoff token data
|
||||
*
|
||||
* @param token - Handoff token ID
|
||||
* @returns Token data if valid, null otherwise
|
||||
*/
|
||||
async validateGuestHandoffToken(token: string): Promise<GuestHandoffToken | null> {
|
||||
const data = await this.cache.get<GuestHandoffToken>(this.buildHandoffKey(token));
|
||||
|
||||
if (!data) {
|
||||
this.logger.debug({ tokenId: token }, "Guest handoff token not found or expired");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data.type !== "guest_handoff") {
|
||||
this.logger.warn({ tokenId: token }, "Invalid handoff token type");
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate guest handoff token (after it's been used)
|
||||
*/
|
||||
async invalidateHandoffToken(token: string): Promise<void> {
|
||||
await this.cache.del(this.buildHandoffKey(token));
|
||||
this.logger.debug({ tokenId: token }, "Guest handoff token invalidated");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Locking (Idempotency Protection)
|
||||
// ============================================================================
|
||||
@ -293,22 +231,16 @@ export class GetStartedSessionService {
|
||||
*
|
||||
* This prevents race conditions where the same session could be used
|
||||
* multiple times (e.g., double-clicking "Create Account").
|
||||
*
|
||||
* @param sessionToken - Session token
|
||||
* @param operation - The operation being performed
|
||||
* @returns Object with success flag and session data if acquired
|
||||
*/
|
||||
async acquireAndMarkAsUsed(
|
||||
sessionToken: string,
|
||||
operation: SessionData["usedFor"]
|
||||
operation: NonNullable<SessionData["usedFor"]>
|
||||
): Promise<{ success: true; session: GetStartedSession } | { success: false; reason: string }> {
|
||||
const lockKey = `${this.SESSION_LOCK_PREFIX}${sessionToken}`;
|
||||
|
||||
// Try to acquire lock with no retries (immediate fail if already locked)
|
||||
const lockResult = await this.lockService.tryWithLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Check session state within lock
|
||||
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||
|
||||
if (!sessionData) {
|
||||
@ -330,27 +262,27 @@ export class GetStartedSessionService {
|
||||
};
|
||||
}
|
||||
|
||||
// Mark as used - build object with required fields, then add optional fields
|
||||
const updatedData = Object.assign(
|
||||
// Mark session as used
|
||||
const updatedData = buildSessionData(
|
||||
{
|
||||
id: sessionData.id,
|
||||
email: sessionData.email,
|
||||
emailVerified: sessionData.emailVerified,
|
||||
createdAt: sessionData.createdAt,
|
||||
},
|
||||
{
|
||||
usedAt: new Date().toISOString(),
|
||||
usedFor: operation,
|
||||
},
|
||||
sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {},
|
||||
sessionData.firstName ? { firstName: sessionData.firstName } : {},
|
||||
sessionData.lastName ? { lastName: sessionData.lastName } : {},
|
||||
sessionData.phone ? { phone: sessionData.phone } : {},
|
||||
sessionData.address ? { address: sessionData.address } : {},
|
||||
sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {},
|
||||
sessionData.whmcsClientId === undefined
|
||||
? {}
|
||||
: { whmcsClientId: sessionData.whmcsClientId },
|
||||
sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {}
|
||||
) as SessionData;
|
||||
accountStatus: sessionData.accountStatus,
|
||||
firstName: sessionData.firstName,
|
||||
lastName: sessionData.lastName,
|
||||
phone: sessionData.phone,
|
||||
address: sessionData.address,
|
||||
sfAccountId: sessionData.sfAccountId,
|
||||
whmcsClientId: sessionData.whmcsClientId,
|
||||
eligibilityStatus: sessionData.eligibilityStatus,
|
||||
}
|
||||
);
|
||||
|
||||
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
|
||||
await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl);
|
||||
@ -365,7 +297,7 @@ export class GetStartedSessionService {
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 65_000, maxRetries: 0 } // TTL must exceed workflow lock (60s) - fail fast
|
||||
{ ttlMs: this.sessionLockTtlMs, maxRetries: 0 }
|
||||
);
|
||||
|
||||
if (!lockResult.success) {
|
||||
@ -379,52 +311,9 @@ export class GetStartedSessionService {
|
||||
return lockResult.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has already been used for an operation
|
||||
*/
|
||||
async isSessionUsed(sessionToken: string): Promise<boolean> {
|
||||
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||
return sessionData?.usedAt != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the "used" status from a session (for recovery after partial failure)
|
||||
*
|
||||
* This should only be called when rolling back a failed operation
|
||||
* to allow the user to retry.
|
||||
*/
|
||||
async clearUsedStatus(sessionToken: string): Promise<boolean> {
|
||||
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||
|
||||
if (!sessionData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build clean session data without usedAt and usedFor
|
||||
const cleanSessionData = Object.assign(
|
||||
{
|
||||
id: sessionData.id,
|
||||
email: sessionData.email,
|
||||
emailVerified: sessionData.emailVerified,
|
||||
createdAt: sessionData.createdAt,
|
||||
},
|
||||
sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {},
|
||||
sessionData.firstName ? { firstName: sessionData.firstName } : {},
|
||||
sessionData.lastName ? { lastName: sessionData.lastName } : {},
|
||||
sessionData.phone ? { phone: sessionData.phone } : {},
|
||||
sessionData.address ? { address: sessionData.address } : {},
|
||||
sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {},
|
||||
sessionData.whmcsClientId === undefined ? {} : { whmcsClientId: sessionData.whmcsClientId },
|
||||
sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {}
|
||||
) as SessionData;
|
||||
|
||||
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
|
||||
await this.cache.set(this.buildKey(sessionToken), cleanSessionData, remainingTtl);
|
||||
|
||||
this.logger.debug({ sessionId: sessionToken }, "Session used status cleared for retry");
|
||||
|
||||
return true;
|
||||
}
|
||||
// ============================================================================
|
||||
// Private Helpers
|
||||
// ============================================================================
|
||||
|
||||
private buildKey(sessionId: string): string {
|
||||
return `${this.SESSION_PREFIX}${sessionId}`;
|
||||
|
||||
@ -12,14 +12,10 @@ import {
|
||||
type SendVerificationCodeResponse,
|
||||
type VerifyCodeRequest,
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type BilingualEligibilityAddress,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type CompleteAccountRequest,
|
||||
type MaybeLaterRequest,
|
||||
type MaybeLaterResponse,
|
||||
type SignupWithEligibilityRequest,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
|
||||
@ -43,7 +39,9 @@ import { SignupWhmcsService } from "./signup/signup-whmcs.service.js";
|
||||
import { SignupUserCreationService } from "./signup/signup-user-creation.service.js";
|
||||
import {
|
||||
PORTAL_SOURCE_NEW_SIGNUP,
|
||||
PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
PORTAL_STATUS_ACTIVE,
|
||||
PORTAL_STATUS_NOT_YET,
|
||||
} from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||
|
||||
@ -62,8 +60,9 @@ function removeUndefined<T extends Record<string, unknown>>(obj: T): Partial<T>
|
||||
* Orchestrates the unified "Get Started" flow:
|
||||
* 1. Email verification via OTP
|
||||
* 2. Account status detection (Portal, WHMCS, SF)
|
||||
* 3. Quick eligibility check for guests
|
||||
* 3. Guest eligibility check (creates SF Account + Case without OTP)
|
||||
* 4. Account completion for SF-only users
|
||||
* 5. Full signup with eligibility (SF + Case + WHMCS + Portal)
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetStartedWorkflowService {
|
||||
@ -200,103 +199,6 @@ export class GetStartedWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quick Eligibility Check (Guest Flow)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Quick eligibility check for guests
|
||||
* Creates SF Account + eligibility case
|
||||
*/
|
||||
async quickEligibilityCheck(request: QuickEligibilityRequest): Promise<QuickEligibilityResponse> {
|
||||
const session = await this.sessionService.validateVerifiedSession(request.sessionToken);
|
||||
if (!session) {
|
||||
throw new BadRequestException("Invalid or expired session. Please verify your email again.");
|
||||
}
|
||||
|
||||
const { firstName, lastName, address, phone } = request;
|
||||
|
||||
try {
|
||||
// Check if SF account already exists for this email
|
||||
let sfAccountId: string;
|
||||
|
||||
const existingSf = await this.salesforceAccountService.findByEmail(session.email);
|
||||
|
||||
if (existingSf) {
|
||||
sfAccountId = existingSf.id;
|
||||
this.logger.log({ email: session.email }, "Using existing SF account for quick check");
|
||||
} else {
|
||||
// Create new SF Account
|
||||
const { accountId } = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email: session.email,
|
||||
phone: phone ?? "",
|
||||
});
|
||||
sfAccountId = accountId;
|
||||
this.logger.log(
|
||||
{ email: session.email, sfAccountId },
|
||||
"Created SF account for quick check"
|
||||
);
|
||||
}
|
||||
|
||||
// Create eligibility case
|
||||
const { caseId } = await this.createEligibilityCase(sfAccountId, address);
|
||||
|
||||
// Update session with SF account info (clean address to remove undefined values)
|
||||
await this.sessionService.updateWithQuickCheckData(request.sessionToken, {
|
||||
firstName,
|
||||
lastName,
|
||||
address: removeUndefined(address),
|
||||
...(phone && { phone }),
|
||||
...(sfAccountId && { sfAccountId }),
|
||||
});
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
requestId: caseId,
|
||||
sfAccountId,
|
||||
message: "Eligibility check submitted. We'll notify you of the results.",
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: session.email },
|
||||
"Quick eligibility check failed"
|
||||
);
|
||||
|
||||
return {
|
||||
submitted: false,
|
||||
message: "Failed to submit eligibility check. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Maybe Later" flow - create SF Account + case, customer returns later
|
||||
*/
|
||||
async maybeLater(request: MaybeLaterRequest): Promise<MaybeLaterResponse> {
|
||||
// This is essentially the same as quickEligibilityCheck
|
||||
// but with explicit intent to not create account now
|
||||
const result = await this.quickEligibilityCheck(request);
|
||||
|
||||
if (result.submitted) {
|
||||
// Send confirmation email
|
||||
await this.sendMaybeLaterConfirmationEmail(
|
||||
(await this.sessionService.get(request.sessionToken))!.email,
|
||||
request.firstName,
|
||||
result.requestId!
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.submitted,
|
||||
requestId: result.requestId,
|
||||
message: result.submitted
|
||||
? "Your eligibility check has been submitted. Check your email for updates."
|
||||
: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Guest Eligibility Check (No OTP Required)
|
||||
// ============================================================================
|
||||
@ -344,13 +246,21 @@ export class GetStartedWorkflowService {
|
||||
{ email: normalizedEmail, sfAccountId },
|
||||
"Using existing SF account for guest eligibility check"
|
||||
);
|
||||
|
||||
// Set portal source for existing accounts going through eligibility
|
||||
// This ensures we can track that they came through internet eligibility
|
||||
await this.salesforceService.updateAccountPortalFields(sfAccountId, {
|
||||
status: PORTAL_STATUS_NOT_YET,
|
||||
source: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
});
|
||||
} else {
|
||||
// Create new SF Account (email NOT verified)
|
||||
// Create new SF Account with portal fields set to "Not Yet" + "Internet Eligibility"
|
||||
const { accountId } = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone: phone ?? "",
|
||||
portalSource: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
});
|
||||
sfAccountId = accountId;
|
||||
this.logger.log(
|
||||
@ -639,13 +549,20 @@ export class GetStartedWorkflowService {
|
||||
{ email: normalizedEmail, sfAccountId },
|
||||
"Using existing SF account for signup"
|
||||
);
|
||||
|
||||
// Set portal source for existing accounts going through eligibility signup
|
||||
// Source will be preserved when we activate the account later
|
||||
await this.salesforceService.updateAccountPortalFields(sfAccountId, {
|
||||
source: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
});
|
||||
} else {
|
||||
// Create new SF Account
|
||||
// Create new SF Account with portal fields set to "Not Yet" + "Internet Eligibility"
|
||||
const { accountId, accountNumber } = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone: phone ?? "",
|
||||
portalSource: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
});
|
||||
sfAccountId = accountId;
|
||||
customerNumber = accountNumber;
|
||||
@ -787,41 +704,6 @@ export class GetStartedWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMaybeLaterConfirmationEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_ELIGIBILITY_SUBMITTED");
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "We're checking internet availability at your address",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
firstName,
|
||||
portalUrl: appBase,
|
||||
email,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "We're checking internet availability at your address",
|
||||
html: `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>We received your request to check internet availability.</p>
|
||||
<p>We'll review this and email you the results within 1-2 business days.</p>
|
||||
<p>When ready, create your account at: <a href="${appBase}/get-started">${appBase}/get-started</a></p>
|
||||
<p>Just enter your email (${email}) to continue.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendWelcomeWithEligibilityEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
@ -913,8 +795,8 @@ export class GetStartedWorkflowService {
|
||||
|
||||
private async createEligibilityCase(
|
||||
sfAccountId: string,
|
||||
address: BilingualEligibilityAddress | QuickEligibilityRequest["address"]
|
||||
): Promise<{ caseId: string; caseNumber: string; threadId: string | null }> {
|
||||
address: BilingualEligibilityAddress | SignupWithEligibilityRequest["address"]
|
||||
): Promise<{ caseId: string; caseNumber: string }> {
|
||||
// Find or create Opportunity for Internet eligibility
|
||||
const { opportunityId } =
|
||||
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
|
||||
@ -956,11 +838,7 @@ export class GetStartedWorkflowService {
|
||||
...(japaneseAddress ? ["", "【Japanese Address】", japaneseAddress] : []),
|
||||
].join("\n");
|
||||
|
||||
const {
|
||||
id: caseId,
|
||||
caseNumber,
|
||||
threadId,
|
||||
} = await this.caseService.createCase({
|
||||
const { id: caseId, caseNumber } = await this.caseService.createCase({
|
||||
accountId: sfAccountId,
|
||||
opportunityId,
|
||||
subject: "Internet availability check request (Portal)",
|
||||
@ -971,7 +849,7 @@ export class GetStartedWorkflowService {
|
||||
// Update Account eligibility status to Pending
|
||||
this.updateAccountEligibilityStatus(sfAccountId);
|
||||
|
||||
return { caseId, caseNumber, threadId };
|
||||
return { caseId, caseNumber };
|
||||
}
|
||||
|
||||
private updateAccountEligibilityStatus(sfAccountId: string): void {
|
||||
@ -982,12 +860,15 @@ export class GetStartedWorkflowService {
|
||||
|
||||
private async updateSalesforcePortalFlags(
|
||||
accountId: string,
|
||||
whmcsClientId: number
|
||||
whmcsClientId: number,
|
||||
/** Optional source override - if not provided, source is not updated (preserves existing) */
|
||||
source?: typeof PORTAL_SOURCE_NEW_SIGNUP | typeof PORTAL_SOURCE_INTERNET_ELIGIBILITY
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.salesforceService.updateAccountPortalFields(accountId, {
|
||||
status: PORTAL_STATUS_ACTIVE,
|
||||
source: PORTAL_SOURCE_NEW_SIGNUP,
|
||||
// Only update source if explicitly provided - preserves existing source for eligibility flows
|
||||
...(source && { source }),
|
||||
lastSignedInAt: new Date(),
|
||||
whmcsAccountId: whmcsClientId,
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import { SalesforceAccountService } from "@bff/integrations/salesforce/services/
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
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 {
|
||||
@ -90,6 +91,7 @@ export class SignupAccountResolverService {
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone,
|
||||
portalSource: PORTAL_SOURCE_NEW_SIGNUP,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Salesforce Account creation failed - blocking signup", {
|
||||
|
||||
@ -11,16 +11,13 @@ import {
|
||||
sendVerificationCodeResponseSchema,
|
||||
verifyCodeRequestSchema,
|
||||
verifyCodeResponseSchema,
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
guestEligibilityRequestSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
completeAccountRequestSchema,
|
||||
maybeLaterRequestSchema,
|
||||
maybeLaterResponseSchema,
|
||||
signupWithEligibilityRequestSchema,
|
||||
signupWithEligibilityResponseSchema,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
|
||||
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
||||
|
||||
@ -29,27 +26,77 @@ class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRe
|
||||
class SendVerificationCodeResponseDto extends createZodDto(sendVerificationCodeResponseSchema) {}
|
||||
class VerifyCodeRequestDto extends createZodDto(verifyCodeRequestSchema) {}
|
||||
class VerifyCodeResponseDto extends createZodDto(verifyCodeResponseSchema) {}
|
||||
class QuickEligibilityRequestDto extends createZodDto(quickEligibilityRequestSchema) {}
|
||||
class QuickEligibilityResponseDto extends createZodDto(quickEligibilityResponseSchema) {}
|
||||
class GuestEligibilityRequestDto extends createZodDto(guestEligibilityRequestSchema) {}
|
||||
class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseSchema) {}
|
||||
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
|
||||
class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
|
||||
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
|
||||
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
|
||||
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
|
||||
|
||||
// Cookie configuration
|
||||
const ACCESS_COOKIE_PATH = "/api";
|
||||
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||||
const TOKEN_TYPE = "Bearer" as const;
|
||||
|
||||
const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
||||
interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
expiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
tokenType: typeof TOKEN_TYPE;
|
||||
}
|
||||
|
||||
interface AuthSuccessResponse {
|
||||
user: User;
|
||||
session: SessionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cookie max age from ISO timestamp
|
||||
*/
|
||||
function calculateCookieMaxAge(isoTimestamp: string): number {
|
||||
const expiresAt = Date.parse(isoTimestamp);
|
||||
if (Number.isNaN(expiresAt)) {
|
||||
return 0;
|
||||
}
|
||||
if (Number.isNaN(expiresAt)) return 0;
|
||||
return Math.max(0, expiresAt - Date.now());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication cookies (httpOnly, secure in production)
|
||||
*/
|
||||
function setAuthCookies(res: Response, tokens: AuthTokens): void {
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
res.cookie("access_token", tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: ACCESS_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(tokens.expiresAt),
|
||||
});
|
||||
|
||||
res.cookie("refresh_token", tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(tokens.refreshExpiresAt),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build session info from tokens
|
||||
*/
|
||||
function buildSessionInfo(tokens: AuthTokens): SessionInfo {
|
||||
return {
|
||||
expiresAt: tokens.expiresAt,
|
||||
refreshExpiresAt: tokens.refreshExpiresAt,
|
||||
tokenType: TOKEN_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Started Controller
|
||||
@ -57,8 +104,9 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
||||
* Handles the unified "Get Started" flow:
|
||||
* - Email verification via OTP
|
||||
* - Account status detection
|
||||
* - Quick eligibility check (guest)
|
||||
* - Guest eligibility check (no OTP required)
|
||||
* - Account completion (SF-only users)
|
||||
* - Signup with eligibility (full flow)
|
||||
*
|
||||
* All endpoints are public (no authentication required)
|
||||
*/
|
||||
@ -68,8 +116,6 @@ export class GetStartedController {
|
||||
|
||||
/**
|
||||
* Send OTP verification code to email
|
||||
*
|
||||
* Rate limit: 5 codes per 5 minutes per IP
|
||||
*/
|
||||
@Public()
|
||||
@Post("send-code")
|
||||
@ -86,8 +132,6 @@ export class GetStartedController {
|
||||
|
||||
/**
|
||||
* Verify OTP code and determine account status
|
||||
*
|
||||
* Rate limit: 10 attempts per 5 minutes per IP
|
||||
*/
|
||||
@Public()
|
||||
@Post("verify-code")
|
||||
@ -102,31 +146,11 @@ export class GetStartedController {
|
||||
return this.workflow.verifyCode(body, fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick eligibility check for guests
|
||||
* Creates SF Account + eligibility case
|
||||
*
|
||||
* Rate limit: 5 per 15 minutes per IP
|
||||
*/
|
||||
@Public()
|
||||
@Post("quick-check")
|
||||
@HttpCode(200)
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 900 })
|
||||
async quickEligibilityCheck(
|
||||
@Body() body: QuickEligibilityRequestDto
|
||||
): Promise<QuickEligibilityResponseDto> {
|
||||
return this.workflow.quickEligibilityCheck(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest eligibility check - NO email verification required
|
||||
* Creates SF Account + eligibility case without OTP verification
|
||||
*
|
||||
* This allows users to check availability without verifying their email first.
|
||||
* Creates SF Account + eligibility case without OTP verification.
|
||||
* Email verification happens later when they create an account.
|
||||
*
|
||||
* Rate limit: 3 per 15 minutes per IP (stricter due to no OTP protection)
|
||||
*/
|
||||
@Public()
|
||||
@Post("guest-eligibility")
|
||||
@ -141,28 +165,11 @@ export class GetStartedController {
|
||||
return this.workflow.guestEligibilityCheck(body, fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Maybe Later" flow
|
||||
* Creates SF Account + eligibility case, sends confirmation email
|
||||
*
|
||||
* Rate limit: 3 per 10 minutes per IP
|
||||
*/
|
||||
@Public()
|
||||
@Post("maybe-later")
|
||||
@HttpCode(200)
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 3, ttl: 600 })
|
||||
async maybeLater(@Body() body: MaybeLaterRequestDto): Promise<MaybeLaterResponseDto> {
|
||||
return this.workflow.maybeLater(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete account for SF-only users
|
||||
* Creates WHMCS client and Portal user, links to existing SF account
|
||||
*
|
||||
* Returns auth tokens (sets httpOnly cookies)
|
||||
*
|
||||
* Rate limit: 5 per 15 minutes per IP
|
||||
* Creates WHMCS client and Portal user, links to existing SF account.
|
||||
* Returns auth tokens (sets httpOnly cookies).
|
||||
*/
|
||||
@Public()
|
||||
@Post("complete-account")
|
||||
@ -172,49 +179,23 @@ export class GetStartedController {
|
||||
async completeAccount(
|
||||
@Body() body: CompleteAccountRequestDto,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
): Promise<AuthSuccessResponse> {
|
||||
const result = await this.workflow.completeAccount(body);
|
||||
|
||||
// Set auth cookies (same pattern as signup)
|
||||
const accessExpires = result.tokens.expiresAt;
|
||||
const refreshExpires = result.tokens.refreshExpiresAt;
|
||||
|
||||
res.cookie("access_token", result.tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env["NODE_ENV"] === "production",
|
||||
sameSite: "lax",
|
||||
path: ACCESS_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(accessExpires),
|
||||
});
|
||||
|
||||
res.cookie("refresh_token", result.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env["NODE_ENV"] === "production",
|
||||
sameSite: "lax",
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(refreshExpires),
|
||||
});
|
||||
setAuthCookies(res, result.tokens);
|
||||
|
||||
return {
|
||||
user: result.user,
|
||||
session: {
|
||||
expiresAt: accessExpires,
|
||||
refreshExpiresAt: refreshExpires,
|
||||
tokenType: TOKEN_TYPE,
|
||||
},
|
||||
session: buildSessionInfo(result.tokens),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full signup with eligibility check (inline flow)
|
||||
* Creates SF Account + Case + WHMCS + Portal in one operation
|
||||
*
|
||||
* Used when user clicks "Create Account" on the eligibility check page.
|
||||
* This is the primary signup path - creates all accounts at once after OTP verification.
|
||||
*
|
||||
* Returns auth tokens (sets httpOnly cookies)
|
||||
*
|
||||
* Rate limit: 5 per 15 minutes per IP
|
||||
* Creates SF Account + Case + WHMCS + Portal in one operation.
|
||||
* This is the primary signup path from the eligibility check page.
|
||||
* Returns auth tokens (sets httpOnly cookies) on success.
|
||||
*/
|
||||
@Public()
|
||||
@Post("signup-with-eligibility")
|
||||
@ -224,7 +205,10 @@ export class GetStartedController {
|
||||
async signupWithEligibility(
|
||||
@Body() body: SignupWithEligibilityRequestDto,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<SignupWithEligibilityResponseDto | { user: unknown; session: unknown }> {
|
||||
): Promise<
|
||||
| SignupWithEligibilityResponseDto
|
||||
| (AuthSuccessResponse & { success: true; eligibilityRequestId?: string })
|
||||
> {
|
||||
const result = await this.workflow.signupWithEligibility(body);
|
||||
|
||||
if (!result.success || !result.authResult) {
|
||||
@ -234,35 +218,13 @@ export class GetStartedController {
|
||||
};
|
||||
}
|
||||
|
||||
// Set auth cookies (same pattern as complete-account)
|
||||
const accessExpires = result.authResult.tokens.expiresAt;
|
||||
const refreshExpires = result.authResult.tokens.refreshExpiresAt;
|
||||
|
||||
res.cookie("access_token", result.authResult.tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env["NODE_ENV"] === "production",
|
||||
sameSite: "lax",
|
||||
path: ACCESS_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(accessExpires),
|
||||
});
|
||||
|
||||
res.cookie("refresh_token", result.authResult.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env["NODE_ENV"] === "production",
|
||||
sameSite: "lax",
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(refreshExpires),
|
||||
});
|
||||
setAuthCookies(res, result.authResult.tokens);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eligibilityRequestId: result.eligibilityRequestId,
|
||||
user: result.authResult.user,
|
||||
session: {
|
||||
expiresAt: accessExpires,
|
||||
refreshExpiresAt: refreshExpires,
|
||||
tokenType: TOKEN_TYPE,
|
||||
},
|
||||
session: buildSessionInfo(result.authResult.tokens),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,28 +8,40 @@ import { apiClient, getDataOrThrow } from "@/core/api";
|
||||
import {
|
||||
sendVerificationCodeResponseSchema,
|
||||
verifyCodeResponseSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
maybeLaterResponseSchema,
|
||||
signupWithEligibilityResponseSchema,
|
||||
type SendVerificationCodeRequest,
|
||||
type SendVerificationCodeResponse,
|
||||
type VerifyCodeRequest,
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type CompleteAccountRequest,
|
||||
type MaybeLaterRequest,
|
||||
type MaybeLaterResponse,
|
||||
type SignupWithEligibilityRequest,
|
||||
type SignupWithEligibilityResponse,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
import { authResponseSchema, type AuthResponse } from "@customer-portal/domain/auth";
|
||||
import {
|
||||
authResponseSchema,
|
||||
type AuthResponse,
|
||||
type AuthSession,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
|
||||
const BASE_PATH = "/api/auth/get-started";
|
||||
|
||||
/**
|
||||
* Response type for signup with eligibility - success case includes auth data
|
||||
*/
|
||||
export type SignupWithEligibilitySuccessResponse = SignupWithEligibilityResponse & {
|
||||
success: true;
|
||||
user: User;
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
export type SignupWithEligibilityApiResponse =
|
||||
| SignupWithEligibilityResponse
|
||||
| SignupWithEligibilitySuccessResponse;
|
||||
|
||||
/**
|
||||
* Send OTP verification code to email
|
||||
*/
|
||||
@ -54,23 +66,11 @@ export async function verifyCode(request: VerifyCodeRequest): Promise<VerifyCode
|
||||
return verifyCodeResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick eligibility check (guest flow) - requires OTP verification
|
||||
*/
|
||||
export async function quickEligibilityCheck(
|
||||
request: QuickEligibilityRequest
|
||||
): Promise<QuickEligibilityResponse> {
|
||||
const response = await apiClient.POST<QuickEligibilityResponse>(`${BASE_PATH}/quick-check`, {
|
||||
body: request,
|
||||
});
|
||||
const data = getDataOrThrow(response, "Failed to submit eligibility check");
|
||||
return quickEligibilityResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest eligibility check - NO OTP verification required
|
||||
* Allows users to check availability without verifying email first
|
||||
* Email verification happens later when user creates an account
|
||||
*
|
||||
* Allows users to check availability without verifying email first.
|
||||
* Email verification happens later when user creates an account.
|
||||
*/
|
||||
export async function guestEligibilityCheck(
|
||||
request: GuestEligibilityRequest
|
||||
@ -83,20 +83,10 @@ export async function guestEligibilityCheck(
|
||||
return guestEligibilityResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe later flow - create SF account and eligibility case
|
||||
*/
|
||||
export async function maybeLater(request: MaybeLaterRequest): Promise<MaybeLaterResponse> {
|
||||
const response = await apiClient.POST<MaybeLaterResponse>(`${BASE_PATH}/maybe-later`, {
|
||||
body: request,
|
||||
});
|
||||
const data = getDataOrThrow(response, "Failed to submit request");
|
||||
return maybeLaterResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete account for SF-only users
|
||||
* Returns auth response with user and session
|
||||
*
|
||||
* Returns auth response with user and session.
|
||||
*/
|
||||
export async function completeAccount(request: CompleteAccountRequest): Promise<AuthResponse> {
|
||||
const response = await apiClient.POST<AuthResponse>(`${BASE_PATH}/complete-account`, {
|
||||
@ -108,23 +98,41 @@ export async function completeAccount(request: CompleteAccountRequest): Promise<
|
||||
|
||||
/**
|
||||
* Full signup with eligibility check (inline flow)
|
||||
* Creates SF Account + Case + WHMCS + Portal in one operation
|
||||
* Used when user clicks "Create Account" on eligibility check page
|
||||
*
|
||||
* Creates SF Account + Case + WHMCS + Portal in one operation.
|
||||
* Returns auth data (user + session) on success.
|
||||
*/
|
||||
export async function signupWithEligibility(
|
||||
request: SignupWithEligibilityRequest
|
||||
): Promise<SignupWithEligibilityResponse & { user?: unknown; session?: unknown }> {
|
||||
const response = await apiClient.POST<
|
||||
SignupWithEligibilityResponse & { user?: unknown; session?: unknown }
|
||||
>(`${BASE_PATH}/signup-with-eligibility`, {
|
||||
body: request,
|
||||
});
|
||||
): Promise<SignupWithEligibilityApiResponse> {
|
||||
const response = await apiClient.POST<SignupWithEligibilityApiResponse>(
|
||||
`${BASE_PATH}/signup-with-eligibility`,
|
||||
{ body: request }
|
||||
);
|
||||
const data = getDataOrThrow(response, "Failed to create account");
|
||||
// Parse the base response, but allow extra fields (user, session)
|
||||
|
||||
// Parse base response first
|
||||
const baseResponse = signupWithEligibilityResponseSchema.parse(data);
|
||||
return {
|
||||
...baseResponse,
|
||||
user: data.user,
|
||||
session: data.session,
|
||||
};
|
||||
|
||||
// If successful, parse the full auth data
|
||||
if (baseResponse.success && "user" in data && "session" in data) {
|
||||
const authData = authResponseSchema.parse({ user: data.user, session: data.session });
|
||||
return {
|
||||
...baseResponse,
|
||||
success: true as const,
|
||||
user: authData.user,
|
||||
session: authData.session,
|
||||
};
|
||||
}
|
||||
|
||||
return baseResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if signup response was successful
|
||||
*/
|
||||
export function isSignupSuccess(
|
||||
response: SignupWithEligibilityApiResponse
|
||||
): response is SignupWithEligibilitySuccessResponse {
|
||||
return response.success === true && "user" in response && "session" in response;
|
||||
}
|
||||
|
||||
@ -73,7 +73,6 @@ export interface GetStartedState {
|
||||
// Verification state
|
||||
codeSent: boolean;
|
||||
attemptsRemaining: number | null;
|
||||
resendDisabled: boolean;
|
||||
|
||||
// Actions
|
||||
sendVerificationCode: (email: string) => Promise<boolean>;
|
||||
@ -124,7 +123,6 @@ const initialState = {
|
||||
error: null,
|
||||
codeSent: false,
|
||||
attemptsRemaining: null,
|
||||
resendDisabled: false,
|
||||
};
|
||||
|
||||
export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
|
||||
|
||||
@ -102,17 +102,17 @@ A dedicated page for guests to check internet availability. This approach provid
|
||||
1. Collects name, email, and address (with Japan ZIP code lookup)
|
||||
2. Verifies email with 6-digit OTP
|
||||
3. Creates SF Account + Eligibility Case immediately on verification
|
||||
4. Shows success with options: "Create Account Now" or "Maybe Later"
|
||||
4. Shows success with options: "Create Account Now" or "View Internet Plans"
|
||||
|
||||
## Backend Endpoints
|
||||
|
||||
| Endpoint | Rate Limit | Purpose |
|
||||
| ----------------------------------------- | ---------- | --------------------------------------------- |
|
||||
| `POST /auth/get-started/send-code` | 5/5min | Send OTP to email |
|
||||
| `POST /auth/get-started/verify-code` | 10/5min | Verify OTP, return account status |
|
||||
| `POST /auth/get-started/quick-check` | 5/15min | Guest eligibility (creates SF Account + Case) |
|
||||
| `POST /auth/get-started/complete-account` | 5/15min | Complete SF-only account |
|
||||
| `POST /auth/get-started/maybe-later` | 3/10min | Create SF + Case (legacy) |
|
||||
| Endpoint | Rate Limit | Purpose |
|
||||
| ------------------------------------------------ | ---------- | --------------------------------------------- |
|
||||
| `POST /auth/get-started/send-code` | 5/5min | Send OTP to email |
|
||||
| `POST /auth/get-started/verify-code` | 10/5min | Verify OTP, return account status |
|
||||
| `POST /auth/get-started/guest-eligibility` | 3/15min | Guest eligibility (no OTP, creates SF + Case) |
|
||||
| `POST /auth/get-started/complete-account` | 5/15min | Complete SF-only account |
|
||||
| `POST /auth/get-started/signup-with-eligibility` | 5/15min | Full signup with eligibility (OTP verified) |
|
||||
|
||||
## Domain Schemas
|
||||
|
||||
@ -122,8 +122,9 @@ Key schemas:
|
||||
|
||||
- `sendVerificationCodeRequestSchema` - email only
|
||||
- `verifyCodeRequestSchema` - email + 6-digit code
|
||||
- `quickEligibilityRequestSchema` - sessionToken, name, address
|
||||
- `guestEligibilityRequestSchema` - email, name, address (no OTP required)
|
||||
- `completeAccountRequestSchema` - sessionToken + password + profile fields
|
||||
- `signupWithEligibilityRequestSchema` - sessionToken + full account data
|
||||
- `accountStatusSchema` - `portal_exists | whmcs_unmapped | sf_unmapped | new_customer`
|
||||
|
||||
## OTP Security
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
* Types and constants for the unified "Get Started" flow that handles:
|
||||
* - Email verification (OTP)
|
||||
* - Account status detection
|
||||
* - Quick eligibility check
|
||||
* - Guest eligibility check (no OTP required)
|
||||
* - Account completion for SF-only accounts
|
||||
* - Signup with eligibility (full flow)
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
@ -15,15 +16,11 @@ import type {
|
||||
sendVerificationCodeResponseSchema,
|
||||
verifyCodeRequestSchema,
|
||||
verifyCodeResponseSchema,
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
bilingualEligibilityAddressSchema,
|
||||
guestEligibilityRequestSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
guestHandoffTokenSchema,
|
||||
completeAccountRequestSchema,
|
||||
maybeLaterRequestSchema,
|
||||
maybeLaterResponseSchema,
|
||||
signupWithEligibilityRequestSchema,
|
||||
signupWithEligibilityResponseSchema,
|
||||
getStartedSessionSchema,
|
||||
@ -89,11 +86,9 @@ export type GetStartedErrorCode =
|
||||
|
||||
export type SendVerificationCodeRequest = z.infer<typeof sendVerificationCodeRequestSchema>;
|
||||
export type VerifyCodeRequest = z.infer<typeof verifyCodeRequestSchema>;
|
||||
export type QuickEligibilityRequest = z.infer<typeof quickEligibilityRequestSchema>;
|
||||
export type BilingualEligibilityAddress = z.infer<typeof bilingualEligibilityAddressSchema>;
|
||||
export type GuestEligibilityRequest = z.infer<typeof guestEligibilityRequestSchema>;
|
||||
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
|
||||
export type MaybeLaterRequest = z.infer<typeof maybeLaterRequestSchema>;
|
||||
export type SignupWithEligibilityRequest = z.infer<typeof signupWithEligibilityRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
@ -102,9 +97,7 @@ export type SignupWithEligibilityRequest = z.infer<typeof signupWithEligibilityR
|
||||
|
||||
export type SendVerificationCodeResponse = z.infer<typeof sendVerificationCodeResponseSchema>;
|
||||
export type VerifyCodeResponse = z.infer<typeof verifyCodeResponseSchema>;
|
||||
export type QuickEligibilityResponse = z.infer<typeof quickEligibilityResponseSchema>;
|
||||
export type GuestEligibilityResponse = z.infer<typeof guestEligibilityResponseSchema>;
|
||||
export type MaybeLaterResponse = z.infer<typeof maybeLaterResponseSchema>;
|
||||
export type SignupWithEligibilityResponse = z.infer<typeof signupWithEligibilityResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
* Unified "Get Started" flow for:
|
||||
* - Email verification (OTP)
|
||||
* - Account status detection
|
||||
* - Quick eligibility check (guest)
|
||||
* - Guest eligibility check (no OTP required)
|
||||
* - Account completion (SF-only → full account)
|
||||
* - "Maybe Later" flow
|
||||
* - Signup with eligibility (full flow)
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
@ -24,15 +24,11 @@ export {
|
||||
type SendVerificationCodeResponse,
|
||||
type VerifyCodeRequest,
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type BilingualEligibilityAddress,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type GuestHandoffToken,
|
||||
type CompleteAccountRequest,
|
||||
type MaybeLaterRequest,
|
||||
type MaybeLaterResponse,
|
||||
type SignupWithEligibilityRequest,
|
||||
type SignupWithEligibilityResponse,
|
||||
type GetStartedSession,
|
||||
@ -51,9 +47,6 @@ export {
|
||||
verifyCodeRequestSchema,
|
||||
verifyCodeResponseSchema,
|
||||
accountStatusSchema,
|
||||
// Quick eligibility schemas (OTP-verified)
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
// Guest eligibility schemas (no OTP required)
|
||||
bilingualEligibilityAddressSchema,
|
||||
guestEligibilityRequestSchema,
|
||||
@ -64,9 +57,6 @@ export {
|
||||
// Signup with eligibility schemas (full inline signup)
|
||||
signupWithEligibilityRequestSchema,
|
||||
signupWithEligibilityResponseSchema,
|
||||
// Maybe later schemas
|
||||
maybeLaterRequestSchema,
|
||||
maybeLaterResponseSchema,
|
||||
// Session schema
|
||||
getStartedSessionSchema,
|
||||
} from "./schema.js";
|
||||
|
||||
@ -95,7 +95,7 @@ export const verifyCodeResponseSchema = z.object({
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Quick Eligibility Check Schemas
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@ -106,37 +106,6 @@ const isoDateOnlySchema = z
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Enter a valid date (YYYY-MM-DD)")
|
||||
.refine(value => !Number.isNaN(Date.parse(value)), "Enter a valid date (YYYY-MM-DD)");
|
||||
|
||||
/**
|
||||
* Request for quick eligibility check (guest flow)
|
||||
* Minimal data required to create SF Account and check eligibility
|
||||
*/
|
||||
export const quickEligibilityRequestSchema = z.object({
|
||||
/** Session token from email verification */
|
||||
sessionToken: z.string().min(1, "Session token is required"),
|
||||
/** Customer first name */
|
||||
firstName: nameSchema,
|
||||
/** Customer last name */
|
||||
lastName: nameSchema,
|
||||
/** Full address for eligibility check */
|
||||
address: addressFormSchema,
|
||||
/** Optional phone number */
|
||||
phone: phoneSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response from quick eligibility check
|
||||
*/
|
||||
export const quickEligibilityResponseSchema = z.object({
|
||||
/** Whether the request was submitted successfully */
|
||||
submitted: z.boolean(),
|
||||
/** Case ID for the eligibility request */
|
||||
requestId: z.string().optional(),
|
||||
/** SF Account ID created */
|
||||
sfAccountId: z.string().optional(),
|
||||
/** Message to display */
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Guest Eligibility Check Schemas (No OTP Required)
|
||||
// ============================================================================
|
||||
@ -252,39 +221,6 @@ export const completeAccountRequestSchema = z.object({
|
||||
marketingConsent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// "Maybe Later" Flow Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request for "Maybe Later" flow
|
||||
* Creates SF Account and eligibility case, customer can return later
|
||||
*/
|
||||
export const maybeLaterRequestSchema = z.object({
|
||||
/** Session token from email verification */
|
||||
sessionToken: z.string().min(1, "Session token is required"),
|
||||
/** Customer first name */
|
||||
firstName: nameSchema,
|
||||
/** Customer last name */
|
||||
lastName: nameSchema,
|
||||
/** Full address for eligibility check */
|
||||
address: addressFormSchema,
|
||||
/** Optional phone number */
|
||||
phone: phoneSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response from "Maybe Later" flow
|
||||
*/
|
||||
export const maybeLaterResponseSchema = z.object({
|
||||
/** Whether the SF account and case were created */
|
||||
success: z.boolean(),
|
||||
/** Case ID for the eligibility request */
|
||||
requestId: z.string().optional(),
|
||||
/** Message to display */
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Signup With Eligibility Schema (Full Inline Signup)
|
||||
// ============================================================================
|
||||
|
||||
@ -260,9 +260,6 @@ export function buildCaseSelectFields(additionalFields: string[] = []): string[]
|
||||
|
||||
// Flags
|
||||
"IsEscalated",
|
||||
|
||||
// Email-to-Case Threading
|
||||
"Thread_Id",
|
||||
];
|
||||
|
||||
return [...new Set([...baseFields, ...additionalFields])];
|
||||
|
||||
@ -114,10 +114,6 @@ export const salesforceCaseRecordSchema = z.object({
|
||||
MilestoneStatus: z.string().nullable().optional(), // Text(30)
|
||||
Language: z.string().nullable().optional(), // Picklist
|
||||
|
||||
// Email-to-Case Threading
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Thread_Id: z.string().nullable().optional(), // Auto-generated thread ID for Email-to-Case
|
||||
|
||||
// Web-to-Case fields
|
||||
SuppliedName: z.string().nullable().optional(), // Web Name - Text(80)
|
||||
SuppliedEmail: z.string().nullable().optional(), // Web Email - Email
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user