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:
parent
1294375205
commit
18360416a3
128
apps/bff/src/infra/cache/cache-coordinator.service.ts
vendored
Normal file
128
apps/bff/src/infra/cache/cache-coordinator.service.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
apps/bff/src/infra/cache/cache.module.ts
vendored
11
apps/bff/src/infra/cache/cache.module.ts
vendored
@ -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";
|
||||
|
||||
54
apps/bff/src/infra/cache/cache.types.ts
vendored
54
apps/bff/src/infra/cache/cache.types.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
16
apps/bff/src/modules/shared/workflow/index.ts
Normal file
16
apps/bff/src/modules/shared/workflow/index.ts
Normal 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";
|
||||
@ -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(", ");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
19
apps/bff/src/modules/shared/workflow/workflow.module.ts
Normal file
19
apps/bff/src/modules/shared/workflow/workflow.module.ts
Normal 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 {}
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -222,7 +222,7 @@ export function FormStep() {
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="secondary"
|
||||
onClick={handleSubmitOnly}
|
||||
disabled={loading}
|
||||
loading={loading && submitType === "check"}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user