From 18360416a310b2784c2be8c0bcd81a02d98854df Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 15 Jan 2026 18:15:51 +0900 Subject: [PATCH] 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. --- .../infra/cache/cache-coordinator.service.ts | 128 +++++++ apps/bff/src/infra/cache/cache.module.ts | 11 +- apps/bff/src/infra/cache/cache.types.ts | 54 +++ apps/bff/src/modules/auth/auth.module.ts | 3 +- .../workflows/get-started-workflow.service.ts | 64 +--- apps/bff/src/modules/orders/orders.module.ts | 2 + .../services/order-orchestrator.service.ts | 93 ++--- .../internet-eligibility.service.ts | 69 ++-- .../src/modules/services/services.module.ts | 2 + apps/bff/src/modules/shared/workflow/index.ts | 16 + .../workflow/workflow-case-manager.service.ts | 331 ++++++++++++++++++ .../workflow/workflow-case-manager.types.ts | 113 ++++++ .../shared/workflow/workflow.module.ts | 19 + .../internet-management.module.ts | 3 +- .../services/internet-cancellation.service.ts | 45 +-- .../services/sim-cancellation.service.ts | 63 +--- .../sim-management/sim-management.module.ts | 2 + .../verification/residence-card.service.ts | 24 +- .../verification/verification.module.ts | 9 +- .../steps/CompleteAccountStep.tsx | 160 ++++----- .../eligibility-check/steps/FormStep.tsx | 2 +- .../eligibility-check/steps/OtpStep.tsx | 21 +- .../eligibility-check/steps/SuccessStep.tsx | 22 +- .../services/views/PublicEligibilityCheck.tsx | 2 +- 24 files changed, 875 insertions(+), 383 deletions(-) create mode 100644 apps/bff/src/infra/cache/cache-coordinator.service.ts create mode 100644 apps/bff/src/modules/shared/workflow/index.ts create mode 100644 apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts create mode 100644 apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts create mode 100644 apps/bff/src/modules/shared/workflow/workflow.module.ts diff --git a/apps/bff/src/infra/cache/cache-coordinator.service.ts b/apps/bff/src/infra/cache/cache-coordinator.service.ts new file mode 100644 index 00000000..cbbea862 --- /dev/null +++ b/apps/bff/src/infra/cache/cache-coordinator.service.ts @@ -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 { + return this.executeOperations(operations); + } + + /** + * Get coordinator metrics for monitoring + */ + getMetrics(): CacheCoordinatorMetrics { + return { ...this.metrics }; + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + private async executeOperations(operations: CacheOperation[]): Promise { + 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, + }; + } +} diff --git a/apps/bff/src/infra/cache/cache.module.ts b/apps/bff/src/infra/cache/cache.module.ts index dcca5c70..865e1df3 100644 --- a/apps/bff/src/infra/cache/cache.module.ts +++ b/apps/bff/src/infra/cache/cache.module.ts @@ -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"; diff --git a/apps/bff/src/infra/cache/cache.types.ts b/apps/bff/src/infra/cache/cache.types.ts index 83d284e1..803ab333 100644 --- a/apps/bff/src/infra/cache/cache.types.ts +++ b/apps/bff/src/infra/cache/cache.types.ts @@ -45,3 +45,57 @@ export interface CacheOptions { value: T ) => CacheDependencies | Promise | 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; +} + +/** + * 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; +} diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index a646cd46..2bcc74ce 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -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 diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts index ebcf6a42..a54814c5 100644 --- a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts @@ -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 }; } diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index c02a7a9d..7812b2a5 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -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: [ diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 803a76f1..032dd1c4 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -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 { - 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 * diff --git a/apps/bff/src/modules/services/application/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts index 802a1438..9e9b0a78 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.service.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.service.ts @@ -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 { - 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 { @@ -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 = {}; + 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, diff --git a/apps/bff/src/modules/services/services.module.ts b/apps/bff/src/modules/services/services.module.ts index 454f2963..ce95b97b 100644 --- a/apps/bff/src/modules/services/services.module.ts +++ b/apps/bff/src/modules/services/services.module.ts @@ -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, diff --git a/apps/bff/src/modules/shared/workflow/index.ts b/apps/bff/src/modules/shared/workflow/index.ts new file mode 100644 index 00000000..d700b04d --- /dev/null +++ b/apps/bff/src/modules/shared/workflow/index.ts @@ -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"; diff --git a/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts new file mode 100644 index 00000000..e8c8ba3b --- /dev/null +++ b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(", "); + } +} diff --git a/apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts b/apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts new file mode 100644 index 00000000..54c131c4 --- /dev/null +++ b/apps/bff/src/modules/shared/workflow/workflow-case-manager.types.ts @@ -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; +} diff --git a/apps/bff/src/modules/shared/workflow/workflow.module.ts b/apps/bff/src/modules/shared/workflow/workflow.module.ts new file mode 100644 index 00000000..c905c00f --- /dev/null +++ b/apps/bff/src/modules/shared/workflow/workflow.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts index cbb5b54f..249fe46d 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts @@ -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], diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index 52d6249f..61019d9e 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -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, diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index e727940f..08c95953 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -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) { diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index e55e6d3e..0af765a7 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -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) diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 5465ae9b..9f471d56 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -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 diff --git a/apps/bff/src/modules/verification/verification.module.ts b/apps/bff/src/modules/verification/verification.module.ts index ad12a656..f2da431f 100644 --- a/apps/bff/src/modules/verification/verification.module.ts +++ b/apps/bff/src/modules/verification/verification.module.ts @@ -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], diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx index ec2e1536..f314ca7f 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx @@ -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 (
- {/* Header */} -
-
-
- -
-
-
-

Complete Your Account

-

- Creating account for{" "} - {formData.email} -

-
-
- {/* Pre-filled info display */}

Account details:

{formData.firstName} {formData.lastName}

+

{formData.email}

{formData.address && (

- {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}`}

)}
+ {/* Phone */} +
+ + { + updateAccountData({ phone: e.target.value }); + handleClearError("phone"); + }} + placeholder="090-1234-5678" + disabled={loading} + error={accountErrors.phone} + /> + {accountErrors.phone} +
+ + {/* Date of Birth */} +
+ + { + updateAccountData({ dateOfBirth: e.target.value }); + handleClearError("dateOfBirth"); + }} + disabled={loading} + error={accountErrors.dateOfBirth} + max={new Date().toISOString().split("T")[0]} + /> + {accountErrors.dateOfBirth} +
+ + {/* Gender */} +
+ +
+ {(["male", "female", "other"] as const).map(option => ( + + ))} +
+ {accountErrors.gender} +
+ {/* Password */}
- {/* Phone */} -
- - { - updateAccountData({ phone: e.target.value }); - handleClearError("phone"); - }} - placeholder="090-1234-5678" - disabled={loading} - error={accountErrors.phone} - /> - {accountErrors.phone} -
- - {/* Date of Birth */} -
- - { - updateAccountData({ dateOfBirth: e.target.value }); - handleClearError("dateOfBirth"); - }} - disabled={loading} - error={accountErrors.dateOfBirth} - max={new Date().toISOString().split("T")[0]} - /> - {accountErrors.dateOfBirth} -
- - {/* Gender */} -
- -
- {(["male", "female", "other"] as const).map(option => ( - - ))} -
- {accountErrors.gender} -
- {/* Terms & Marketing */}
diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx index abef65f2..388b214e 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx @@ -222,7 +222,7 @@ export function FormStep() {