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:
barsa 2026-01-15 16:11:11 +09:00
parent a01361bb89
commit aa77f23d85
15 changed files with 248 additions and 597 deletions

View File

@ -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;
}
/**

View File

@ -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),

View File

@ -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;

View File

@ -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}`;

View File

@ -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,
});

View File

@ -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", {

View File

@ -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),
};
}
}

View File

@ -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;
}

View File

@ -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) => ({

View File

@ -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

View File

@ -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>;
// ============================================================================

View File

@ -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";

View File

@ -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)
// ============================================================================

View File

@ -260,9 +260,6 @@ export function buildCaseSelectFields(additionalFields: string[] = []): string[]
// Flags
"IsEscalated",
// Email-to-Case Threading
"Thread_Id",
];
return [...new Set([...baseFields, ...additionalFields])];

View File

@ -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