feat(workflow): introduce Workflow Module and Case Manager for internal case handling

- Added WorkflowModule to manage internal workflow operations.
- Implemented WorkflowCaseManager for creating various types of internal cases (e.g., order placements, cancellations, ID verifications).
- Integrated WorkflowCaseManager into existing services for handling internet and SIM cancellations, eligibility checks, and ID verification submissions.
- Enhanced error handling and logging for case creation processes.
- Introduced CacheCoordinatorService for coordinating multiple cache invalidation operations with error aggregation.
- Updated relevant modules to include WorkflowModule and refactored services to utilize the new WorkflowCaseManager.
- Improved UI components in the eligibility check process for better user experience.
This commit is contained in:
barsa 2026-01-15 18:15:51 +09:00
parent 1294375205
commit 18360416a3
24 changed files with 875 additions and 383 deletions

View File

@ -0,0 +1,128 @@
/**
* Cache Coordinator Service
*
* Provides utilities for coordinating multiple cache invalidation operations.
* Executes operations in parallel and aggregates errors without throwing.
*
* Features:
* - Parallel execution via Promise.allSettled()
* - Error aggregation (logs but never throws)
* - Metrics tracking for observability
*
* Usage:
* Domain services inject this coordinator along with their specific cache services,
* then use `invalidateMultiple()` to coordinate invalidations.
*
* Example:
* ```typescript
* await this.cacheCoordinator.invalidateMultiple([
* { name: 'order', execute: () => this.ordersCache.invalidateOrder(id) },
* { name: 'accountOrders', execute: () => this.ordersCache.invalidateAccountOrders(accountId) },
* ]);
* ```
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type {
CacheOperation,
CacheInvalidationResult,
CacheInvalidationError,
CacheCoordinatorMetrics,
} from "./cache.types.js";
@Injectable()
export class CacheCoordinatorService {
private readonly metrics: CacheCoordinatorMetrics = {
totalInvalidations: 0,
totalOperations: 0,
totalErrors: 0,
};
constructor(@Inject(Logger) private readonly logger: Logger) {}
/**
* Execute multiple cache operations in parallel.
*
* All operations are executed concurrently using Promise.allSettled().
* Errors are logged but never thrown - cache invalidation failures
* should not block business operations.
*
* @param operations - Array of cache operations to execute
* @returns Result containing success status, counts, and any errors
*/
async invalidateMultiple(operations: CacheOperation[]): Promise<CacheInvalidationResult> {
return this.executeOperations(operations);
}
/**
* Get coordinator metrics for monitoring
*/
getMetrics(): CacheCoordinatorMetrics {
return { ...this.metrics };
}
// ============================================================================
// Private Helpers
// ============================================================================
private async executeOperations(operations: CacheOperation[]): Promise<CacheInvalidationResult> {
if (operations.length === 0) {
return {
success: true,
attempted: 0,
succeeded: 0,
failed: 0,
errors: [],
};
}
const results = await Promise.allSettled(operations.map(async op => op.execute()));
const errors: CacheInvalidationError[] = [];
let succeeded = 0;
for (const [i, result] of results.entries()) {
const operation = operations[i];
if (!operation || !result) continue;
if (result.status === "fulfilled") {
succeeded++;
} else {
const errorMessage = extractErrorMessage(result.reason);
errors.push({
operation: operation.name,
error: result.reason,
message: errorMessage,
});
this.logger.warn("Cache invalidation operation failed", {
operation: operation.name,
error: errorMessage,
});
}
}
// Update metrics
this.metrics.totalInvalidations++;
this.metrics.totalOperations += operations.length;
this.metrics.totalErrors += errors.length;
const success = errors.length === 0;
if (success && operations.length > 0) {
this.logger.debug("Cache invalidation completed", {
operations: operations.length,
});
}
return {
success,
attempted: operations.length,
succeeded,
failed: errors.length,
errors,
};
}
}

View File

@ -1,19 +1,24 @@
import { Global, Module } from "@nestjs/common";
import { CacheService } from "./cache.service.js";
import { DistributedLockService } from "./distributed-lock.service.js";
import { CacheCoordinatorService } from "./cache-coordinator.service.js";
/**
* Global cache module
*
* Provides Redis-backed caching infrastructure for the entire application.
* Exports CacheService and DistributedLockService for use in domain services.
* Exports:
* - CacheService: Core Redis operations (get, set, del, delPattern)
* - DistributedLockService: Distributed locking for concurrent operations
* - CacheCoordinatorService: Utility for coordinating multi-cache invalidations
*/
@Global()
@Module({
providers: [CacheService, DistributedLockService],
exports: [CacheService, DistributedLockService],
providers: [CacheService, DistributedLockService, CacheCoordinatorService],
exports: [CacheService, DistributedLockService, CacheCoordinatorService],
})
export class CacheModule {}
// Export shared types for domain-specific cache services
export * from "./cache.types.js";
export { CacheCoordinatorService } from "./cache-coordinator.service.js";

View File

@ -45,3 +45,57 @@ export interface CacheOptions<T> {
value: T
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
}
// ============================================================================
// Cache Coordinator Types
// ============================================================================
/**
* A single cache invalidation operation
*/
export interface CacheOperation {
/** Human-readable operation name for logging */
name: string;
/** The invalidation function to execute */
execute: () => Promise<void>;
}
/**
* Error details from a failed cache operation
*/
export interface CacheInvalidationError {
/** Name of the failed operation */
operation: string;
/** The original error */
error: unknown;
/** Extracted error message */
message: string;
}
/**
* Result of a coordinated cache invalidation
*/
export interface CacheInvalidationResult {
/** Whether all operations succeeded */
success: boolean;
/** Number of operations attempted */
attempted: number;
/** Number of successful operations */
succeeded: number;
/** Number of failed operations */
failed: number;
/** Details of any failures */
errors: CacheInvalidationError[];
}
/**
* Metrics tracked by the CacheCoordinator
*/
export interface CacheCoordinatorMetrics {
/** Total invalidation calls made */
totalInvalidations: number;
/** Total individual operations executed */
totalOperations: number;
/** Total operation failures */
totalErrors: number;
}

View File

@ -30,9 +30,10 @@ import { OtpService } from "./infra/otp/otp.service.js";
import { GetStartedSessionService } from "./infra/otp/get-started-session.service.js";
import { GetStartedWorkflowService } from "./infra/workflows/get-started-workflow.service.js";
import { GetStartedController } from "./presentation/http/get-started.controller.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
@Module({
imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule],
imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule, WorkflowModule],
controllers: [AuthController, GetStartedController],
providers: [
// Application services

View File

@ -25,8 +25,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
@ -77,7 +76,7 @@ export class GetStartedWorkflowService {
private readonly salesforceAccountService: SalesforceAccountService,
private readonly salesforceService: SalesforceService,
private readonly opportunityResolution: OpportunityResolutionService,
private readonly caseService: SalesforceCaseService,
private readonly workflowCases: WorkflowCaseManager,
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
private readonly whmcsSignup: SignupWhmcsService,
private readonly userCreation: SignupUserCreationService,
@ -798,57 +797,32 @@ export class GetStartedWorkflowService {
address: BilingualEligibilityAddress | SignupWithEligibilityRequest["address"]
): Promise<{ caseId: string; caseNumber: string }> {
// Find or create Opportunity for Internet eligibility
const { opportunityId } =
const { opportunityId, wasCreated: opportunityCreated } =
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
// Format English address
const englishAddress = [
address.address1,
address.address2,
address.city,
address.state,
address.postcode,
]
.filter(Boolean)
.join(", ");
// Format Japanese address (if available)
const bilingualAddr = address as BilingualEligibilityAddress;
const hasJapaneseAddress =
bilingualAddr.prefectureJa || bilingualAddr.cityJa || bilingualAddr.townJa;
const japaneseAddress = hasJapaneseAddress
? [
`${address.postcode}`,
`${bilingualAddr.prefectureJa || ""}${bilingualAddr.cityJa || ""}${bilingualAddr.townJa || ""}${bilingualAddr.streetAddress || ""}`,
bilingualAddr.buildingName
? `${bilingualAddr.buildingName} ${bilingualAddr.roomNumber || ""}`.trim()
: "",
]
.filter(Boolean)
.join("\n")
: null;
// Build case description with both addresses
const description = [
"Customer requested to check if internet service is available at the following address:",
"",
"【English Address】",
englishAddress,
...(japaneseAddress ? ["", "【Japanese Address】", japaneseAddress] : []),
].join("\n");
const { id: caseId, caseNumber } = await this.caseService.createCase({
// Create eligibility case via workflow manager
await this.workflowCases.notifyEligibilityCheck({
accountId: sfAccountId,
opportunityId,
subject: "Internet availability check request (Portal)",
description,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
opportunityCreated,
address: {
address1: address.address1,
...(address.address2 ? { address2: address.address2 } : {}),
city: address.city,
state: address.state,
postcode: address.postcode,
...(address.country ? { country: address.country } : {}),
},
});
// Update Account eligibility status to Pending
this.updateAccountEligibilityStatus(sfAccountId);
// Generate a reference ID for the eligibility request
// (WorkflowCaseManager.notifyEligibilityCheck doesn't return case ID as it's non-critical)
const caseId = `eligibility:${sfAccountId}:${Date.now()}`;
const caseNumber = `ELG-${Date.now().toString(36).toUpperCase()}`;
return { caseId, caseNumber };
}

View File

@ -9,6 +9,7 @@ import { ServicesModule } from "@bff/modules/services/services.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
// Clean modular order services
import { OrderValidator } from "./services/order-validator.service.js";
@ -42,6 +43,7 @@ import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/c
VerificationModule,
NotificationsModule,
SalesforceOrderFieldConfigModule,
WorkflowModule,
],
controllers: [OrdersController, CheckoutController],
providers: [

View File

@ -3,13 +3,13 @@ import { Logger } from "nestjs-pino";
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { OrderValidator } from "./order-validator.service.js";
import { OrderBuilder } from "./order-builder.service.js";
import { OrderItemBuilder } from "./order-item-builder.service.js";
import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
import { OrdersCacheService } from "./orders-cache.service.js";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { CacheCoordinatorService } from "@bff/infra/cache/cache.module.js";
import type {
OrderDetails,
OrderSummary,
@ -18,8 +18,6 @@ import type {
} from "@customer-portal/domain/orders";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
type OrderDetailsResponse = OrderDetails;
type OrderSummaryResponse = OrderSummary;
@ -35,12 +33,12 @@ export class OrderOrchestrator {
private readonly salesforceOrderService: SalesforceOrderService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly opportunityResolution: OpportunityResolutionService,
private readonly caseService: SalesforceCaseService,
private readonly sfConnection: SalesforceConnection,
private readonly orderValidator: OrderValidator,
private readonly orderBuilder: OrderBuilder,
private readonly orderItemBuilder: OrderItemBuilder,
private readonly ordersCache: OrdersCacheService
private readonly ordersCache: OrdersCacheService,
private readonly workflowCases: WorkflowCaseManager,
private readonly cacheCoordinator: CacheCoordinatorService
) {}
/**
@ -121,19 +119,28 @@ export class OrderOrchestrator {
// 5) Create internal "Order Placed" case for CS team
if (userMapping.sfAccountId) {
await this.createOrderPlacedCase({
await this.workflowCases.notifyOrderPlaced({
accountId: userMapping.sfAccountId,
orderId: created.id,
orderType: validatedBody.orderType,
opportunityId,
...(opportunityId ? { opportunityId } : {}),
opportunityCreated,
});
}
if (userMapping.sfAccountId) {
await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId);
}
await this.ordersCache.invalidateOrder(created.id);
// 6) Invalidate caches
await this.cacheCoordinator.invalidateMultiple([
{ name: "order", execute: async () => this.ordersCache.invalidateOrder(created.id) },
...(userMapping.sfAccountId
? [
{
name: "accountOrders",
execute: async () =>
this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId!),
},
]
: []),
]);
this.logger.log(
{
@ -153,66 +160,6 @@ export class OrderOrchestrator {
};
}
/**
* Create an internal case when an order is placed.
* This is for CS team visibility - not visible to customers.
*/
private async createOrderPlacedCase(params: {
accountId: string;
orderId: string;
orderType: OrderTypeValue;
opportunityId: string | null;
opportunityCreated: boolean;
}): Promise<void> {
try {
const instanceUrl = this.sfConnection.getInstanceUrl();
const orderLink = instanceUrl
? `${instanceUrl}/lightning/r/Order/${params.orderId}/view`
: null;
const opportunityLink =
params.opportunityId && instanceUrl
? `${instanceUrl}/lightning/r/Opportunity/${params.opportunityId}/view`
: null;
const opportunityStatus = params.opportunityId
? params.opportunityCreated
? "Created new opportunity for this order"
: "Linked to existing opportunity"
: "No opportunity linked";
const descriptionLines = [
"Order placed via Customer Portal.",
"",
`Order ID: ${params.orderId}`,
orderLink ? `Order: ${orderLink}` : null,
"",
params.opportunityId ? `Opportunity ID: ${params.opportunityId}` : null,
opportunityLink ? `Opportunity: ${opportunityLink}` : null,
`Opportunity Status: ${opportunityStatus}`,
].filter(Boolean);
await this.caseService.createCase({
accountId: params.accountId,
opportunityId: params.opportunityId ?? undefined,
subject: `Order Placed - ${params.orderType} (Portal)`,
description: descriptionLines.join("\n"),
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
});
this.logger.log("Created Order Placed case", {
orderId: params.orderId,
opportunityIdTail: params.opportunityId?.slice(-4),
opportunityCreated: params.opportunityCreated,
});
} catch (error) {
// Log but don't fail the order
this.logger.warn("Failed to create Order Placed case", {
orderId: params.orderId,
error: extractErrorMessage(error),
});
}
}
/**
* Resolve Opportunity for an order
*

View File

@ -5,8 +5,7 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { ServicesCacheService } from "./services-cache.service.js";
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import {
assertSalesforceId,
@ -20,16 +19,6 @@ import { internetEligibilityDetailsSchema } from "@customer-portal/domain/servic
import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
function formatAddressForLog(address: Record<string, unknown>): string {
const address1 = typeof address["address1"] === "string" ? address["address1"].trim() : "";
const address2 = typeof address["address2"] === "string" ? address["address2"].trim() : "";
const city = typeof address["city"] === "string" ? address["city"].trim() : "";
const state = typeof address["state"] === "string" ? address["state"].trim() : "";
const postcode = typeof address["postcode"] === "string" ? address["postcode"].trim() : "";
const country = typeof address["country"] === "string" ? address["country"].trim() : "";
return [address1, address2, city, state, postcode, country].filter(Boolean).join(", ");
}
@Injectable()
export class InternetEligibilityService {
constructor(
@ -39,7 +28,7 @@ export class InternetEligibilityService {
private readonly mappingsService: MappingsService,
private readonly catalogCache: ServicesCacheService,
private readonly opportunityResolution: OpportunityResolutionService,
private readonly caseService: SalesforceCaseService
private readonly workflowCases: WorkflowCaseManager
) {}
async getEligibilityForUser(userId: string): Promise<string | null> {
@ -113,53 +102,47 @@ export class InternetEligibilityService {
}
try {
const subject = "Internet availability check request (Portal)";
// 1) Find or create Opportunity for Internet eligibility (this service remains locked internally)
// 1) Find or create Opportunity for Internet eligibility
const { opportunityId, wasCreated: opportunityCreated } =
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
// 2) Build case description
const instanceUrl = this.sf.getInstanceUrl();
const opportunityLink = instanceUrl
? `${instanceUrl}/lightning/r/Opportunity/${opportunityId}/view`
: null;
// 2) Create eligibility check case via WorkflowCaseManager
// Build address object conditionally to avoid exactOptionalPropertyTypes issues
const eligibilityAddress: Record<string, string> = {};
if (request.address.address1) eligibilityAddress["address1"] = request.address.address1;
if (request.address.address2) eligibilityAddress["address2"] = request.address.address2;
if (request.address.city) eligibilityAddress["city"] = request.address.city;
if (request.address.state) eligibilityAddress["state"] = request.address.state;
if (request.address.postcode) eligibilityAddress["postcode"] = request.address.postcode;
if (request.address.country) eligibilityAddress["country"] = request.address.country;
const opportunityStatus = opportunityCreated
? "Created new opportunity for this request"
: "Linked to existing opportunity";
const descriptionLines: string[] = [
"Customer requested to check if internet service is available at the following address:",
"",
request.address ? formatAddressForLog(request.address) : "",
"",
opportunityLink ? `Opportunity: ${opportunityLink}` : "",
`Opportunity Status: ${opportunityStatus}`,
].filter(Boolean);
// 3) Create Case linked to Opportunity (internal workflow case)
const { id: createdCaseId } = await this.caseService.createCase({
await this.workflowCases.notifyEligibilityCheck({
accountId: sfAccountId,
opportunityId,
subject,
description: descriptionLines.join("\n"),
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
opportunityCreated,
address: eligibilityAddress as {
address1?: string;
address2?: string;
city?: string;
state?: string;
postcode?: string;
country?: string;
},
});
// 4) Update Account eligibility status
// 3) Update Account eligibility status
await this.updateAccountEligibilityRequestState(sfAccountId);
await this.catalogCache.invalidateEligibility(sfAccountId);
this.logger.log("Created eligibility Case linked to Opportunity", {
this.logger.log("Eligibility check request submitted", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: createdCaseId.slice(-4),
opportunityIdTail: opportunityId.slice(-4),
opportunityCreated,
});
return createdCaseId;
// Return the opportunity ID as the request identifier
return opportunityId;
} catch (error) {
this.logger.error("Failed to create eligibility request", {
userId,

View File

@ -9,6 +9,7 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { QueueModule } from "@bff/infra/queue/queue.module.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
import { BaseServicesService } from "./application/base-services.service.js";
import { InternetServicesService } from "./application/internet-services.service.js";
@ -24,6 +25,7 @@ import { ServicesCacheService } from "./application/services-cache.service.js";
CoreConfigModule,
CacheModule,
QueueModule,
WorkflowModule,
],
controllers: [
ServicesController,

View File

@ -0,0 +1,16 @@
/**
* Workflow Module Public API
*
* Provides services for internal workflow operations.
*/
export { WorkflowModule } from "./workflow.module.js";
export { WorkflowCaseManager } from "./workflow-case-manager.service.js";
export type {
OrderPlacedCaseParams,
EligibilityCheckCaseParams,
InternetCancellationCaseParams,
SimCancellationCaseParams,
IdVerificationCaseParams,
WorkflowCaseResult,
} from "./workflow-case-manager.types.js";

View File

@ -0,0 +1,331 @@
/**
* Workflow Case Manager
*
* Centralizes creation of internal workflow cases for CS team visibility.
* All cases created by this service use PORTAL_NOTIFICATION origin,
* meaning they are visible only to internal CS staff, not customers.
*
* Provides typed methods for each workflow case type with consistent:
* - Salesforce Lightning URL building
* - Description formatting
* - Error handling (critical vs non-critical)
* - Logging
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type {
OrderPlacedCaseParams,
EligibilityCheckCaseParams,
InternetCancellationCaseParams,
SimCancellationCaseParams,
IdVerificationCaseParams,
} from "./workflow-case-manager.types.js";
@Injectable()
export class WorkflowCaseManager {
constructor(
private readonly caseService: SalesforceCaseService,
private readonly sfConnection: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
// ============================================================================
// Public Methods - One per workflow case type
// ============================================================================
/**
* Create a notification case when an order is placed.
* Non-critical: logs warning on failure, does not throw.
*/
async notifyOrderPlaced(params: OrderPlacedCaseParams): Promise<void> {
const { accountId, orderId, orderType, opportunityId, opportunityCreated } = params;
try {
const orderLink = this.buildLightningUrl("Order", orderId);
const opportunityLink = opportunityId
? this.buildLightningUrl("Opportunity", opportunityId)
: null;
const opportunityStatus = opportunityId
? opportunityCreated
? "Created new opportunity for this order"
: "Linked to existing opportunity"
: "No opportunity linked";
const description = this.buildDescription([
"Order placed via Customer Portal.",
"",
`Order ID: ${orderId}`,
orderLink ? `Order: ${orderLink}` : null,
"",
opportunityId ? `Opportunity ID: ${opportunityId}` : null,
opportunityLink ? `Opportunity: ${opportunityLink}` : null,
`Opportunity Status: ${opportunityStatus}`,
]);
await this.caseService.createCase({
accountId,
opportunityId,
subject: `Order Placed - ${orderType} (Portal)`,
description,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
});
this.logger.log("Created order placed case", {
orderIdTail: orderId.slice(-4),
opportunityIdTail: opportunityId?.slice(-4),
opportunityCreated,
});
} catch (error) {
this.logger.warn("Failed to create order placed case", {
orderIdTail: orderId.slice(-4),
error: extractErrorMessage(error),
});
}
}
/**
* Create a case for internet eligibility check request.
* Non-critical: logs warning on failure, does not throw.
*/
async notifyEligibilityCheck(params: EligibilityCheckCaseParams): Promise<void> {
const { accountId, address, opportunityId, opportunityCreated } = params;
try {
const opportunityLink = opportunityId
? this.buildLightningUrl("Opportunity", opportunityId)
: null;
const opportunityStatus = opportunityCreated
? "Created new opportunity for this request"
: "Linked to existing opportunity";
const formattedAddress = this.formatAddress(address);
const description = this.buildDescription([
"Customer requested to check if internet service is available at the following address:",
"",
formattedAddress,
"",
opportunityLink ? `Opportunity: ${opportunityLink}` : null,
`Opportunity Status: ${opportunityStatus}`,
]);
await this.caseService.createCase({
accountId,
opportunityId,
subject: "Internet availability check request (Portal)",
description,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
});
this.logger.log("Created eligibility check case", {
accountIdTail: accountId.slice(-4),
opportunityIdTail: opportunityId?.slice(-4),
opportunityCreated,
});
} catch (error) {
this.logger.warn("Failed to create eligibility check case", {
accountIdTail: accountId.slice(-4),
error: extractErrorMessage(error),
});
}
}
/**
* Create a case for internet service cancellation request.
* Non-critical: logs warning on failure, does not throw.
*/
async notifyInternetCancellation(params: InternetCancellationCaseParams): Promise<void> {
const {
accountId,
subscriptionId,
cancellationMonth,
serviceEndDate,
comments,
opportunityId,
} = params;
try {
const description = this.buildDescription([
"Cancellation Request from Portal",
"",
"Product Type: Internet",
`WHMCS Service ID: ${subscriptionId}`,
`Cancellation Month: ${cancellationMonth}`,
`Service End Date: ${serviceEndDate}`,
"",
comments ? "Customer Comments:" : null,
comments || null,
"",
`Submitted: ${new Date().toISOString()}`,
]);
await this.caseService.createCase({
accountId,
opportunityId,
subject: `Cancellation Request - Internet (${cancellationMonth})`,
description,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
priority: "High",
});
this.logger.log("Created internet cancellation case", {
accountIdTail: accountId.slice(-4),
subscriptionId,
cancellationMonth,
});
} catch (error) {
this.logger.warn("Failed to create internet cancellation case", {
accountIdTail: accountId.slice(-4),
subscriptionId,
error: extractErrorMessage(error),
});
}
}
/**
* Create a case for SIM service cancellation request.
* Non-critical: logs warning on failure, does not throw.
*/
async notifySimCancellation(params: SimCancellationCaseParams): Promise<void> {
const {
accountId,
simAccount,
iccid,
subscriptionId,
cancellationMonth,
serviceEndDate,
comments,
opportunityId,
} = params;
try {
const description = this.buildDescription([
"Cancellation Request from Portal",
"",
"Product Type: SIM",
`SIM Number: ${simAccount}`,
`Serial Number: ${iccid || "N/A"}`,
`WHMCS Service ID: ${subscriptionId}`,
`Cancellation Month: ${cancellationMonth}`,
`Service End Date: ${serviceEndDate}`,
"",
comments ? "Customer Comments:" : null,
comments || null,
"",
`Submitted: ${new Date().toISOString()}`,
]);
await this.caseService.createCase({
accountId,
opportunityId,
subject: `Cancellation Request - SIM (${cancellationMonth})`,
description,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
priority: "High",
});
this.logger.log("Created SIM cancellation case", {
accountIdTail: accountId.slice(-4),
simAccount,
subscriptionId,
cancellationMonth,
});
} catch (error) {
this.logger.warn("Failed to create SIM cancellation case", {
accountIdTail: accountId.slice(-4),
subscriptionId,
error: extractErrorMessage(error),
});
}
}
/**
* Create a case for ID verification document submission.
* CRITICAL: throws on failure as caller needs the case ID for file attachment.
*
* @returns The Salesforce Case ID
* @throws Error if case creation fails
*/
async createIdVerificationCase(params: IdVerificationCaseParams): Promise<string> {
const { accountId, filename, mimeType, sizeBytes } = params;
const description = this.buildDescription([
"Customer submitted their id card for verification.",
"",
`Document: ${filename || "residence-card"}`,
`File Type: ${mimeType}`,
`File Size: ${(sizeBytes / 1024).toFixed(2)} KB`,
"",
"The ID document is attached to this Case (see Files related list).",
]);
try {
const { id: caseId } = await this.caseService.createCase({
accountId,
subject: "ID verification review (Portal)",
description,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
});
this.logger.log("Created ID verification case", {
accountIdTail: accountId.slice(-4),
caseIdTail: caseId.slice(-4),
filename,
});
return caseId;
} catch (error) {
this.logger.error("Failed to create ID verification case", {
accountIdTail: accountId.slice(-4),
filename,
error: extractErrorMessage(error),
});
throw new Error("Failed to create verification case");
}
}
// ============================================================================
// Private Helpers
// ============================================================================
/**
* Build a Salesforce Lightning URL for a record.
* Returns null if instance URL is not available.
*/
private buildLightningUrl(sobjectType: string, recordId: string): string | null {
const instanceUrl = this.sfConnection.getInstanceUrl();
if (!instanceUrl) return null;
return `${instanceUrl}/lightning/r/${sobjectType}/${recordId}/view`;
}
/**
* Build a case description from an array of lines.
* Filters out null/undefined/empty values and joins with newlines.
*/
private buildDescription(lines: Array<string | null | undefined>): string {
return lines.filter(Boolean).join("\n");
}
/**
* Format an address object into a single-line string.
*/
private formatAddress(address: EligibilityCheckCaseParams["address"]): string {
return [
address.address1,
address.address2,
address.city,
address.state,
address.postcode,
address.country,
]
.filter(Boolean)
.join(", ");
}
}

View File

@ -0,0 +1,113 @@
/**
* Workflow Case Manager Types
*
* Type definitions for internal workflow cases created via the WorkflowCaseManager.
* All workflow cases use PORTAL_NOTIFICATION origin (visible to CS team only, not customers).
*/
/**
* Base parameters shared across all workflow cases
*/
export interface BaseWorkflowCaseParams {
/** Salesforce Account ID */
accountId: string;
/** Optional Opportunity ID to link the case */
opportunityId?: string;
/** Case priority - defaults to Medium if not specified */
priority?: "High" | "Medium" | "Low";
}
/**
* Order placement notification case
* Created when a customer places an order via the portal
*/
export interface OrderPlacedCaseParams extends BaseWorkflowCaseParams {
/** Salesforce Order ID */
orderId: string;
/** Order type (e.g., "Internet", "SIM") */
orderType: string;
/** Whether a new Opportunity was created for this order */
opportunityCreated: boolean;
}
/**
* Internet eligibility check request case
* Created when a customer requests an internet availability check
*/
export interface EligibilityCheckCaseParams extends BaseWorkflowCaseParams {
/** Service address to check */
address: {
address1?: string;
address2?: string;
city?: string;
state?: string;
postcode?: string;
country?: string;
};
/** Whether a new Opportunity was created for this request */
opportunityCreated: boolean;
}
/**
* Internet cancellation request case
* Created when a customer requests to cancel their internet service
*/
export interface InternetCancellationCaseParams extends BaseWorkflowCaseParams {
/** WHMCS subscription/service ID */
subscriptionId: number;
/** Selected cancellation month (e.g., "2024-03") */
cancellationMonth: string;
/** Calculated service end date */
serviceEndDate: string;
/** Optional customer comments */
comments?: string;
}
/**
* SIM cancellation request case
* Created when a customer requests to cancel their SIM service
*/
export interface SimCancellationCaseParams extends BaseWorkflowCaseParams {
/** SIM account number */
simAccount: string;
/** SIM serial number (ICCID) */
iccid: string;
/** WHMCS subscription/service ID */
subscriptionId: number;
/** Selected cancellation month (e.g., "2024-03") */
cancellationMonth: string;
/** Calculated service end date */
serviceEndDate: string;
/** Optional customer comments */
comments?: string;
}
/**
* ID verification case (CRITICAL)
* Created when a customer submits ID documents for verification.
* Returns the case ID which is needed to attach the uploaded file.
*/
export interface IdVerificationCaseParams {
/** Salesforce Account ID */
accountId: string;
/** Uploaded document filename */
filename: string;
/** Document MIME type */
mimeType: string;
/** Document size in bytes */
sizeBytes: number;
}
/**
* Result of a workflow case creation attempt
*/
export interface WorkflowCaseResult {
/** Whether the case was created successfully */
success: boolean;
/** Salesforce Case ID (if successful) */
caseId?: string;
/** Salesforce Case Number (if successful) */
caseNumber?: string;
/** Error message (if failed) */
error?: string;
}

View File

@ -0,0 +1,19 @@
import { Module } from "@nestjs/common";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { WorkflowCaseManager } from "./workflow-case-manager.service.js";
/**
* Workflow Module
*
* Provides cross-cutting workflow services for internal operations.
* NOT @Global - modules must explicitly import WorkflowModule to use these services.
*
* Exports:
* - WorkflowCaseManager: Creates internal workflow cases for CS visibility
*/
@Module({
imports: [SalesforceModule],
providers: [WorkflowCaseManager],
exports: [WorkflowCaseManager],
})
export class WorkflowModule {}

View File

@ -5,9 +5,10 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
@Module({
imports: [WhmcsModule, MappingsModule, SalesforceModule, NotificationsModule],
imports: [WhmcsModule, MappingsModule, SalesforceModule, NotificationsModule, WorkflowModule],
controllers: [InternetController],
providers: [InternetCancellationService],
exports: [InternetCancellationService],

View File

@ -16,9 +16,8 @@ import { Logger } from "nestjs-pino";
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { EmailService } from "@bff/infra/email/email.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import {
@ -41,8 +40,8 @@ export class InternetCancellationService {
private readonly whmcsConnectionService: WhmcsConnectionOrchestratorService,
private readonly whmcsClientService: WhmcsClientService,
private readonly mappingsService: MappingsService,
private readonly caseService: SalesforceCaseService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly workflowCases: WorkflowCaseManager,
private readonly emailService: EmailService,
private readonly notifications: NotificationService,
@Inject(Logger) private readonly logger: Logger
@ -194,38 +193,24 @@ export class InternetCancellationService {
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
}
// Build description with all form data
const descriptionLines = [
`Cancellation Request from Portal`,
``,
`Product Type: Internet`,
`WHMCS Service ID: ${subscriptionId}`,
`Cancellation Month: ${request.cancellationMonth}`,
`Service End Date: ${cancellationDate}`,
``,
];
if (request.comments) {
descriptionLines.push(``, `Customer Comments:`, request.comments);
}
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
// Create Salesforce Case for cancellation (internal workflow case)
const { id: caseId } = await this.caseService.createCase({
// Create Salesforce Case for cancellation via workflow manager
await this.workflowCases.notifyInternetCancellation({
accountId: sfAccountId,
opportunityId: opportunityId || undefined,
subject: `Cancellation Request - Internet (${request.cancellationMonth})`,
description: descriptionLines.join("\n"),
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
priority: "High",
...(opportunityId ? { opportunityId } : {}),
subscriptionId,
cancellationMonth: request.cancellationMonth,
serviceEndDate: cancellationDate,
...(request.comments ? { comments: request.comments } : {}),
});
this.logger.log("Cancellation case created", {
caseId,
opportunityId,
this.logger.log("Cancellation case created via WorkflowCaseManager", {
sfAccountIdTail: sfAccountId.slice(-4),
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
});
// Use a placeholder caseId for notification since workflow manager doesn't return it
const caseId = `cancellation:internet:${subscriptionId}:${request.cancellationMonth}`;
try {
await this.notifications.createNotification({
userId,

View File

@ -5,7 +5,7 @@ import { FreebitOperationsService } from "@bff/integrations/freebit/services/fre
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { SimValidationService } from "./sim-validation.service.js";
import type {
SimCancelRequest,
@ -18,7 +18,6 @@ import {
getCancellationEffectiveDate,
getRunDateFromMonth,
} from "@customer-portal/domain/subscriptions";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity";
import { SimScheduleService } from "./sim-schedule.service.js";
import { SimNotificationService } from "./sim-notification.service.js";
@ -32,7 +31,7 @@ export class SimCancellationService {
private readonly whmcsClientService: WhmcsClientService,
private readonly mappingsService: MappingsService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly caseService: SalesforceCaseService,
private readonly workflowCases: WorkflowCaseManager,
private readonly simValidation: SimValidationService,
private readonly simSchedule: SimScheduleService,
private readonly simNotification: SimNotificationService,
@ -232,49 +231,25 @@ export class SimCancellationService {
this.logger.warn("Could not find Opportunity for SIM subscription", { subscriptionId });
}
// Build description with all form data (same pattern as Internet)
const descriptionLines = [
`Cancellation Request from Portal`,
``,
`Product Type: SIM`,
`SIM Number: ${account}`,
`Serial Number: ${simDetails.iccid || "N/A"}`,
`WHMCS Service ID: ${subscriptionId}`,
`Cancellation Month: ${request.cancellationMonth}`,
`Service End Date: ${cancellationDate}`,
``,
];
// Create Salesforce Case for cancellation via workflow manager
await this.workflowCases.notifySimCancellation({
accountId: mapping.sfAccountId,
...(opportunityId ? { opportunityId } : {}),
simAccount: account,
iccid: simDetails.iccid || "N/A",
subscriptionId,
cancellationMonth: request.cancellationMonth,
serviceEndDate: cancellationDate,
...(request.comments ? { comments: request.comments } : {}),
});
if (request.comments) {
descriptionLines.push(`Customer Comments:`, request.comments, ``);
}
this.logger.log("SIM cancellation case created via WorkflowCaseManager", {
sfAccountIdTail: mapping.sfAccountId.slice(-4),
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
});
descriptionLines.push(`Submitted: ${new Date().toISOString()}`);
// Create Salesforce Case for cancellation (same as Internet)
let caseId: string | undefined;
try {
const caseResult = await this.caseService.createCase({
accountId: mapping.sfAccountId,
...(opportunityId ? { opportunityId } : {}),
subject: `Cancellation Request - SIM (${request.cancellationMonth})`,
description: descriptionLines.join("\n"),
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
priority: "High",
});
caseId = caseResult.id;
this.logger.log("SIM cancellation case created", {
caseId,
opportunityId,
});
} catch (error) {
// Log but don't fail - Freebit API was already called successfully
this.logger.error("Failed to create SIM cancellation Case", {
error: error instanceof Error ? error.message : String(error),
subscriptionId,
});
}
// Use a placeholder caseId for notification since workflow manager doesn't return it
const caseId = `cancellation:sim:${subscriptionId}:${request.cancellationMonth}`;
// Update Salesforce Opportunity (if linked via WHMCS_Service_ID__c)
if (opportunityId) {

View File

@ -31,6 +31,7 @@ import { SimCallHistoryFormatterService } from "./services/sim-call-history-form
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
@Module({
imports: [
@ -43,6 +44,7 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti
NotificationsModule,
SecurityModule,
VoiceOptionsModule,
WorkflowModule,
],
// SimController is registered in SubscriptionsModule to ensure route order
// (more specific routes like :id/sim must be registered before :id)

View File

@ -3,9 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { ServicesCacheService } from "@bff/modules/services/application/services-cache.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import {
assertSalesforceId,
assertSoqlFieldName,
@ -27,7 +26,7 @@ export class ResidenceCardService {
constructor(
private readonly sf: SalesforceConnection,
private readonly mappings: MappingsService,
private readonly caseService: SalesforceCaseService,
private readonly workflowCases: WorkflowCaseManager,
private readonly servicesCache: ServicesCacheService,
private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger
@ -119,22 +118,11 @@ export class ResidenceCardService {
// Create an internal workflow Case for CS to track this submission.
// (No lock/dedupe: multiple submissions may create multiple cases by design.)
const subject = "ID verification review (Portal)";
const descriptionLines: string[] = [
"Customer submitted their id card for verification.",
"",
`Document: ${params.filename || "residence-card"}`,
`File Type: ${params.mimeType}`,
`File Size: ${(params.sizeBytes / 1024).toFixed(2)} KB`,
"",
"The ID document is attached to this Case (see Files related list).",
];
const { id: caseId } = await this.caseService.createCase({
const caseId = await this.workflowCases.createIdVerificationCase({
accountId: sfAccountId,
subject,
description: descriptionLines.join("\n"),
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
filename: params.filename || "residence-card",
mimeType: params.mimeType,
sizeBytes: params.sizeBytes,
});
// Upload file to Salesforce Files and attach to the Case

View File

@ -5,9 +5,16 @@ import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
@Module({
imports: [IntegrationsModule, MappingsModule, CoreConfigModule, forwardRef(() => ServicesModule)],
imports: [
IntegrationsModule,
MappingsModule,
CoreConfigModule,
forwardRef(() => ServicesModule),
WorkflowModule,
],
controllers: [ResidenceCardController],
providers: [ResidenceCardService],
exports: [ResidenceCardService],

View File

@ -8,7 +8,7 @@
"use client";
import { useState, useCallback } from "react";
import { Lock, ArrowLeft, Check, X } from "lucide-react";
import { ArrowLeft, Check, X } from "lucide-react";
import { Button, Input, Label, ErrorMessage } from "@/components/atoms";
import { Checkbox } from "@/components/atoms/checkbox";
import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store";
@ -134,35 +134,92 @@ export function CompleteAccountStep() {
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center">
<Lock className="h-7 w-7 text-primary" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold text-foreground">Complete Your Account</h2>
<p className="text-sm text-muted-foreground mt-1">
Creating account for{" "}
<span className="font-medium text-foreground">{formData.email}</span>
</p>
</div>
</div>
{/* Pre-filled info display */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-1">Account details:</p>
<p className="font-medium text-foreground">
{formData.firstName} {formData.lastName}
</p>
<p className="text-sm text-muted-foreground mt-1">{formData.email}</p>
{formData.address && (
<p className="text-sm text-muted-foreground mt-1">
{formData.address.city}, {formData.address.prefecture}
{formData.address.postcode} {formData.address.prefectureJa}
{formData.address.cityJa}
{formData.address.townJa}
{formData.address.streetAddress}
{formData.address.buildingName && ` ${formData.address.buildingName}`}
{formData.address.roomNumber && ` ${formData.address.roomNumber}`}
</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">
Phone Number <span className="text-danger">*</span>
</Label>
<Input
id="phone"
type="tel"
value={accountData.phone}
onChange={e => {
updateAccountData({ phone: e.target.value });
handleClearError("phone");
}}
placeholder="090-1234-5678"
disabled={loading}
error={accountErrors.phone}
/>
<ErrorMessage>{accountErrors.phone}</ErrorMessage>
</div>
{/* Date of Birth */}
<div className="space-y-2">
<Label htmlFor="dateOfBirth">
Date of Birth <span className="text-danger">*</span>
</Label>
<Input
id="dateOfBirth"
type="date"
value={accountData.dateOfBirth}
onChange={e => {
updateAccountData({ dateOfBirth: e.target.value });
handleClearError("dateOfBirth");
}}
disabled={loading}
error={accountErrors.dateOfBirth}
max={new Date().toISOString().split("T")[0]}
/>
<ErrorMessage>{accountErrors.dateOfBirth}</ErrorMessage>
</div>
{/* Gender */}
<div className="space-y-2">
<Label>
Gender <span className="text-danger">*</span>
</Label>
<div className="flex gap-4">
{(["male", "female", "other"] as const).map(option => (
<label key={option} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="gender"
value={option}
checked={accountData.gender === option}
onChange={() => {
updateAccountData({ gender: option });
handleClearError("gender");
}}
disabled={loading}
className="h-4 w-4 text-primary focus:ring-primary"
/>
<span className="text-sm capitalize">{option}</span>
</label>
))}
</div>
<ErrorMessage>{accountErrors.gender}</ErrorMessage>
</div>
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password">
@ -235,73 +292,6 @@ export function CompleteAccountStep() {
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">
Phone Number <span className="text-danger">*</span>
</Label>
<Input
id="phone"
type="tel"
value={accountData.phone}
onChange={e => {
updateAccountData({ phone: e.target.value });
handleClearError("phone");
}}
placeholder="090-1234-5678"
disabled={loading}
error={accountErrors.phone}
/>
<ErrorMessage>{accountErrors.phone}</ErrorMessage>
</div>
{/* Date of Birth */}
<div className="space-y-2">
<Label htmlFor="dateOfBirth">
Date of Birth <span className="text-danger">*</span>
</Label>
<Input
id="dateOfBirth"
type="date"
value={accountData.dateOfBirth}
onChange={e => {
updateAccountData({ dateOfBirth: e.target.value });
handleClearError("dateOfBirth");
}}
disabled={loading}
error={accountErrors.dateOfBirth}
max={new Date().toISOString().split("T")[0]}
/>
<ErrorMessage>{accountErrors.dateOfBirth}</ErrorMessage>
</div>
{/* Gender */}
<div className="space-y-2">
<Label>
Gender <span className="text-danger">*</span>
</Label>
<div className="flex gap-4">
{(["male", "female", "other"] as const).map(option => (
<label key={option} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="gender"
value={option}
checked={accountData.gender === option}
onChange={() => {
updateAccountData({ gender: option });
handleClearError("gender");
}}
disabled={loading}
className="h-4 w-4 text-primary focus:ring-primary"
/>
<span className="text-sm capitalize">{option}</span>
</label>
))}
</div>
<ErrorMessage>{accountErrors.gender}</ErrorMessage>
</div>
{/* Terms & Marketing */}
<div className="space-y-3">
<div className="flex items-start gap-2">

View File

@ -222,7 +222,7 @@ export function FormStep() {
<Button
type="button"
variant="ghost"
variant="secondary"
onClick={handleSubmitOnly}
disabled={loading}
loading={loading && submitType === "check"}

View File

@ -5,7 +5,6 @@
"use client";
import { useState, useCallback } from "react";
import { Mail } from "lucide-react";
import { Button } from "@/components/atoms";
import { OtpInput } from "@/features/get-started/components/OtpInput/OtpInput";
import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store";
@ -58,21 +57,11 @@ export function OtpStep() {
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold text-foreground">Verify Your Email</h2>
<p className="text-sm text-muted-foreground mt-1">
We sent a 6-digit code to{" "}
<span className="font-medium text-foreground">{formData.email}</span>
</p>
</div>
</div>
{/* Email reminder */}
<p className="text-sm text-muted-foreground text-center">
We sent a 6-digit code to{" "}
<span className="font-medium text-foreground">{formData.email}</span>
</p>
{/* OTP Input */}
<div className="py-4">

View File

@ -10,7 +10,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle, ArrowRight, Home } from "lucide-react";
import { ArrowRight, Home } from "lucide-react";
import { Button } from "@/components/atoms";
import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store";
@ -63,26 +63,6 @@ export function SuccessStep() {
return (
<div className="space-y-6">
{/* Success Icon */}
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircle className="h-9 w-9 text-success" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold text-foreground">
{hasAccount ? "Account Created!" : "Request Submitted!"}
</h2>
<p className="text-sm text-muted-foreground mt-2">
{hasAccount
? "Your account has been created and your eligibility check is being processed."
: "Your eligibility check request has been submitted."}
</p>
</div>
</div>
{/* Email notification */}
<div className="p-4 rounded-lg bg-primary/5 border border-primary/20">
<p className="text-sm text-foreground">

View File

@ -30,7 +30,7 @@ const stepMeta: Record<
},
otp: {
title: "Verify Email",
description: "Enter the verification code we sent to your email.",
description: "Enter the 6-digit verification code to continue.",
icon: "otp",
},
"complete-account": {