diff --git a/README.md b/README.md index 6a06d0bc..b3f2aacd 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ When running `pnpm dev:tools`, you get access to: - `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert - `POST /api/auth/login` - Portal authentication -- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin +- `POST /api/auth/migrate` - Account migration from legacy portal - `POST /api/auth/set-password` - Required after WHMCS link ### User Management diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index 55689ef4..18cbfecd 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware { "/api/auth/request-password-reset", "/api/auth/reset-password", // Public auth endpoint for password reset "/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link - "/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking + "/api/auth/migrate", // Public auth endpoint for account migration "/api/health", "/docs", "/api/webhooks", // Webhooks typically don't use CSRF diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts index 34736957..20255bd0 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -304,23 +304,28 @@ export class SalesforceOpportunityService { */ async findOpenOpportunityForAccount( accountId: string, - productType: OpportunityProductTypeValue + productType: OpportunityProductTypeValue, + options?: { stages?: OpportunityStageValue[] } ): Promise { const safeAccountId = assertSalesforceId(accountId, "accountId"); // Get the CommodityType value(s) that match this product type const commodityTypeValues = this.getCommodityTypesForProductType(productType); + const stages = + Array.isArray(options?.stages) && options?.stages.length > 0 + ? options.stages + : OPEN_OPPORTUNITY_STAGES; + this.logger.debug("Looking for open Opportunity", { accountId: safeAccountId, productType, commodityTypes: commodityTypeValues, + stages, }); // Build stage filter for open stages - const stageList = OPEN_OPPORTUNITY_STAGES.map((s: OpportunityStageValue) => `'${s}'`).join( - ", " - ); + const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", "); const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", "); const soql = ` diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 6e3805e8..c765a78e 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -230,11 +230,11 @@ export class AuthController { } @Public() - @Post("link-whmcs") + @Post("migrate") @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) - async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { + async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { const result = await this.authFacade.linkWhmcsUser(linkData); return linkWhmcsResponseSchema.parse(result); } diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 05a491e9..781641d7 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.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 { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; @@ -28,6 +29,7 @@ import { OPPORTUNITY_STAGE, OPPORTUNITY_SOURCE, OPPORTUNITY_PRODUCT_TYPE, + OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY, } from "@customer-portal/domain/opportunity"; export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible"; @@ -49,6 +51,7 @@ export class InternetCatalogService extends BaseCatalogService { @Inject(Logger) logger: Logger, private mappingsService: MappingsService, private catalogCache: CatalogCacheService, + private lockService: DistributedLockService, private opportunityService: SalesforceOpportunityService, private caseService: SalesforceCaseService ) { @@ -278,67 +281,88 @@ export class InternetCatalogService extends BaseCatalogService { } try { - // 1. Find or create Opportunity for Internet eligibility - // Only match Introduction stage (not Ready/Post Processing - those have progressed) - let opportunityId = await this.opportunityService.findOpenOpportunityForAccount( - sfAccountId, - OPPORTUNITY_PRODUCT_TYPE.INTERNET + const lockKey = `internet:eligibility:${sfAccountId}`; + + const caseId = await this.lockService.withLock( + lockKey, + async () => { + // Idempotency: if we already have a pending request, return the existing request id. + const existing = await this.queryEligibilityDetails(sfAccountId); + if (existing.status === "pending" && existing.requestId) { + this.logger.log("Eligibility request already pending; returning existing request id", { + userId, + sfAccountIdTail: sfAccountId.slice(-4), + caseIdTail: existing.requestId.slice(-4), + }); + return existing.requestId; + } + + // 1) Find or create Opportunity for Internet eligibility + // Only match Introduction stage. "Ready"/"Post Processing"/"Active" indicate the journey progressed. + let opportunityId = await this.opportunityService.findOpenOpportunityForAccount( + sfAccountId, + OPPORTUNITY_PRODUCT_TYPE.INTERNET, + { stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY } + ); + + let opportunityCreated = false; + if (!opportunityId) { + // Create Opportunity - Salesforce workflow auto-generates the name + opportunityId = await this.opportunityService.createOpportunity({ + accountId: sfAccountId, + productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, + stage: OPPORTUNITY_STAGE.INTRODUCTION, + source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, + }); + opportunityCreated = true; + + this.logger.log("Created Opportunity for eligibility request", { + opportunityIdTail: opportunityId.slice(-4), + sfAccountIdTail: sfAccountId.slice(-4), + }); + } + + // 2) Build case description + const subject = "Internet availability check request (Portal)"; + const descriptionLines: string[] = [ + "Portal internet availability check requested.", + "", + `UserId: ${userId}`, + `Email: ${request.email}`, + `SalesforceAccountId: ${sfAccountId}`, + `OpportunityId: ${opportunityId}`, + "", + request.notes ? `Notes: ${request.notes}` : "", + request.address ? `Address: ${formatAddressForLog(request.address)}` : "", + "", + `RequestedAt: ${new Date().toISOString()}`, + ].filter(Boolean); + + // 3) Create Case linked to Opportunity + const createdCaseId = await this.caseService.createEligibilityCase({ + accountId: sfAccountId, + opportunityId, + subject, + description: descriptionLines.join("\n"), + }); + + // 4) Update Account eligibility status + await this.updateAccountEligibilityRequestState(sfAccountId, createdCaseId); + await this.catalogCache.invalidateEligibility(sfAccountId); + + this.logger.log("Created eligibility Case linked to Opportunity", { + userId, + sfAccountIdTail: sfAccountId.slice(-4), + caseIdTail: createdCaseId.slice(-4), + opportunityIdTail: opportunityId.slice(-4), + opportunityCreated, + }); + + return createdCaseId; + }, + { ttlMs: 10_000 } ); - let opportunityCreated = false; - if (!opportunityId) { - // Create Opportunity - Salesforce workflow auto-generates the name - opportunityId = await this.opportunityService.createOpportunity({ - accountId: sfAccountId, - productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, - stage: OPPORTUNITY_STAGE.INTRODUCTION, - source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, - }); - opportunityCreated = true; - - this.logger.log("Created Opportunity for eligibility request", { - opportunityIdTail: opportunityId.slice(-4), - sfAccountIdTail: sfAccountId.slice(-4), - }); - } - - // 2. Build case description - const subject = "Internet availability check request (Portal)"; - const descriptionLines: string[] = [ - "Portal internet availability check requested.", - "", - `UserId: ${userId}`, - `Email: ${request.email}`, - `SalesforceAccountId: ${sfAccountId}`, - `OpportunityId: ${opportunityId}`, - "", - request.notes ? `Notes: ${request.notes}` : "", - request.address ? `Address: ${formatAddressForLog(request.address)}` : "", - "", - `RequestedAt: ${new Date().toISOString()}`, - ].filter(Boolean); - - // 3. Create Case linked to Opportunity - const caseId = await this.caseService.createEligibilityCase({ - accountId: sfAccountId, - opportunityId, - subject, - description: descriptionLines.join("\n"), - }); - - // 4. Update Account eligibility status - await this.updateAccountEligibilityRequestState(sfAccountId, caseId); - - await this.catalogCache.invalidateEligibility(sfAccountId); - - this.logger.log("Created eligibility Case linked to Opportunity", { - userId, - sfAccountIdTail: sfAccountId.slice(-4), - caseIdTail: caseId.slice(-4), - opportunityIdTail: opportunityId.slice(-4), - opportunityCreated, - }); - return caseId; } catch (error) { this.logger.error("Failed to create eligibility request", { diff --git a/apps/bff/src/modules/notifications/notifications.service.ts b/apps/bff/src/modules/notifications/notifications.service.ts index 815acf59..a1b42076 100644 --- a/apps/bff/src/modules/notifications/notifications.service.ts +++ b/apps/bff/src/modules/notifications/notifications.service.ts @@ -129,7 +129,8 @@ export class NotificationService { userId, expiresAt: { gt: now }, ...(options?.includeDismissed ? {} : { dismissed: false }), - ...(options?.includeRead ? {} : {}), // Include all by default for the list + // By default we include read notifications. If includeRead=false, filter them out. + ...(options?.includeRead === false ? { read: false } : {}), }; try { diff --git a/apps/bff/src/modules/orders/services/opportunity-matching.service.ts b/apps/bff/src/modules/orders/services/opportunity-matching.service.ts deleted file mode 100644 index 13e5925e..00000000 --- a/apps/bff/src/modules/orders/services/opportunity-matching.service.ts +++ /dev/null @@ -1,561 +0,0 @@ -/** - * Opportunity Matching Service - * - * Resolves which Opportunity to use for orders based on matching rules. - * Handles finding existing Opportunities or creating new ones as needed. - * - * Uses existing Salesforce stages: - * - Introduction → Ready → Post Processing → Active → △Cancelling → 〇Cancelled - * - * Matching Rules: - * 1. If order already has opportunityId → use it directly - * 2. For Internet orders → find Introduction/Ready stage Opportunity or create new - * 3. For SIM orders → find open Opportunity for account or create new - * 4. For VPN orders → create new Opportunity (no matching) - * - * @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation - */ - -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; -import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; -import { - type OpportunityProductTypeValue, - type OpportunityStageValue, - type CancellationFormData, - type CancellationOpportunityData, - type CancellationEligibility, - OPPORTUNITY_STAGE, - OPPORTUNITY_SOURCE, - OPPORTUNITY_PRODUCT_TYPE, - APPLICATION_STAGE, - getCancellationEligibility, - validateCancellationMonth, - transformCancellationFormToOpportunityData, -} from "@customer-portal/domain/opportunity"; -import type { OrderTypeValue } from "@customer-portal/domain/orders"; - -// ============================================================================ -// Types -// ============================================================================ - -/** - * Context for Opportunity resolution - */ -export interface OpportunityResolutionContext { - /** Salesforce Account ID */ - accountId: string; - - /** Order type (Internet, SIM, VPN) */ - orderType: OrderTypeValue; - - /** Existing Opportunity ID if provided with order */ - existingOpportunityId?: string; - - /** Source of the order (for new Opportunity creation) */ - source?: "checkout" | "eligibility" | "order"; -} - -/** - * Result of Opportunity resolution - */ -export interface ResolvedOpportunity { - /** The Opportunity ID to use */ - opportunityId: string; - - /** Whether a new Opportunity was created */ - wasCreated: boolean; - - /** The stage of the Opportunity */ - stage: OpportunityStageValue; -} - -// ============================================================================ -// Service -// ============================================================================ - -@Injectable() -export class OpportunityMatchingService { - constructor( - private readonly opportunityService: SalesforceOpportunityService, - private readonly lockService: DistributedLockService, - @Inject(Logger) private readonly logger: Logger - ) {} - - // ========================================================================== - // Opportunity Resolution for Orders - // ========================================================================== - - /** - * Resolve the Opportunity to use for an order - * - * This is the main entry point for Opportunity matching. - * It handles finding existing Opportunities or creating new ones. - * - * Uses a distributed lock to prevent race conditions when multiple - * concurrent requests try to create Opportunities for the same account. - * - * @param context - Resolution context with account and order details - * @returns Resolved Opportunity with ID and metadata - */ - async resolveOpportunityForOrder( - context: OpportunityResolutionContext - ): Promise { - this.logger.log("Resolving Opportunity for order", { - accountId: context.accountId, - orderType: context.orderType, - hasExistingOpportunityId: !!context.existingOpportunityId, - }); - - // Rule 1: If order already has opportunityId, validate and use it - if (context.existingOpportunityId) { - return this.useExistingOpportunity(context.existingOpportunityId); - } - - // Rule 2-4: Find or create based on order type - const productType = this.mapOrderTypeToProductType(context.orderType); - - if (!productType) { - this.logger.warn("Unknown order type, creating new Opportunity", { - orderType: context.orderType, - }); - return this.createNewOpportunity(context, "Internet"); - } - - // Use distributed lock to prevent race conditions - // Lock key is specific to account + product type - const lockKey = `opportunity:${context.accountId}:${productType}`; - - return this.lockService.withLock( - lockKey, - async () => { - // Re-check for existing Opportunity after acquiring lock - // Another request may have created one while we were waiting - const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( - context.accountId, - productType - ); - - if (existingOppId) { - this.logger.debug("Found existing Opportunity after acquiring lock", { - opportunityId: existingOppId, - }); - return this.useExistingOpportunity(existingOppId); - } - - // No existing Opportunity found - create new one - return this.createNewOpportunity(context, productType); - }, - { ttlMs: 10_000 } // 10 second lock TTL - ); - } - - // ========================================================================== - // Opportunity Creation Triggers - // ========================================================================== - - /** - * Create Opportunity at eligibility request (Internet only) - * - * Called when customer requests Internet eligibility check. - * Uses distributed lock to prevent duplicate Opportunities. - * First checks for existing open Opportunity before creating. - * - * NOTE: The Case is linked TO the Opportunity via Case.OpportunityId, - * not the other way around. So we don't need to store Case ID on Opportunity. - * NOTE: Opportunity Name is auto-generated by Salesforce workflow. - * - * @param accountId - Salesforce Account ID - * @returns Opportunity ID (existing or newly created) - */ - async createOpportunityForEligibility(accountId: string): Promise { - this.logger.log("Creating Opportunity for Internet eligibility request", { - accountId, - }); - - const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.INTERNET}`; - - return this.lockService.withLock( - lockKey, - async () => { - // Check for existing open Opportunity first - const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( - accountId, - OPPORTUNITY_PRODUCT_TYPE.INTERNET - ); - - if (existingOppId) { - this.logger.debug("Found existing Internet Opportunity, reusing", { - opportunityId: existingOppId, - }); - return existingOppId; - } - - // Create new Opportunity - const opportunityId = await this.opportunityService.createOpportunity({ - accountId, - productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, - stage: OPPORTUNITY_STAGE.INTRODUCTION, - source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, - applicationStage: APPLICATION_STAGE.INTRO_1, - }); - - return opportunityId; - }, - { ttlMs: 10_000 } - ); - } - - /** - * Create Opportunity at checkout registration (SIM only) - * - * Called when customer creates account during SIM checkout. - * Uses distributed lock to prevent duplicate Opportunities. - * First checks for existing open Opportunity before creating. - * - * NOTE: Opportunity Name is auto-generated by Salesforce workflow. - * - * @param accountId - Salesforce Account ID - * @returns Opportunity ID (existing or newly created) - */ - async createOpportunityForCheckoutRegistration(accountId: string): Promise { - this.logger.log("Creating Opportunity for SIM checkout registration", { - accountId, - }); - - const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.SIM}`; - - return this.lockService.withLock( - lockKey, - async () => { - // Check for existing open Opportunity first - const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( - accountId, - OPPORTUNITY_PRODUCT_TYPE.SIM - ); - - if (existingOppId) { - this.logger.debug("Found existing SIM Opportunity, reusing", { - opportunityId: existingOppId, - }); - return existingOppId; - } - - // Create new Opportunity - const opportunityId = await this.opportunityService.createOpportunity({ - accountId, - productType: OPPORTUNITY_PRODUCT_TYPE.SIM, - stage: OPPORTUNITY_STAGE.INTRODUCTION, - source: OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION, - applicationStage: APPLICATION_STAGE.INTRO_1, - }); - - return opportunityId; - }, - { ttlMs: 10_000 } - ); - } - - // ========================================================================== - // Lifecycle Stage Updates - // ========================================================================== - - /** - * Update Opportunity stage to Ready (eligible) - * - * Called when Internet eligibility is confirmed. - * - * @param opportunityId - Opportunity ID to update - */ - async markEligible(opportunityId: string): Promise { - this.logger.log("Marking Opportunity as eligible (Ready)", { opportunityId }); - await this.opportunityService.updateStage( - opportunityId, - OPPORTUNITY_STAGE.READY, - "Eligibility confirmed" - ); - } - - /** - * Update Opportunity stage to Void (not eligible) - * - * Called when Internet eligibility check fails. - * - * @param opportunityId - Opportunity ID to update - */ - async markNotEligible(opportunityId: string): Promise { - this.logger.log("Marking Opportunity as not eligible (Void)", { opportunityId }); - await this.opportunityService.updateStage( - opportunityId, - OPPORTUNITY_STAGE.VOID, - "Not eligible for service" - ); - } - - /** - * Update Opportunity stage after order placement - * - * Called after order is successfully created in Salesforce. - * - * @param opportunityId - Opportunity ID to update - */ - async markOrderPlaced(opportunityId: string): Promise { - this.logger.log("Marking Opportunity as order placed (Post Processing)", { opportunityId }); - await this.opportunityService.updateStage( - opportunityId, - OPPORTUNITY_STAGE.POST_PROCESSING, - "Order placed via portal" - ); - } - - /** - * Update Opportunity stage after provisioning - * - * Called after order is successfully provisioned. - * - * @param opportunityId - Opportunity ID to update - * @param whmcsServiceId - WHMCS Service ID to link - */ - async markProvisioned(opportunityId: string, whmcsServiceId: number): Promise { - this.logger.log("Marking Opportunity as active", { - opportunityId, - whmcsServiceId, - }); - - // Update stage to Active - await this.opportunityService.updateStage( - opportunityId, - OPPORTUNITY_STAGE.ACTIVE, - "Service provisioned successfully" - ); - - // Link WHMCS Service ID for cancellation workflows - await this.opportunityService.linkWhmcsServiceToOpportunity(opportunityId, whmcsServiceId); - } - - /** - * Update Opportunity for eligibility result - * - * Called when eligibility check is completed (eligible or not). - * - * @param opportunityId - Opportunity ID to update - * @param isEligible - Whether the customer is eligible - */ - async updateEligibilityResult(opportunityId: string, isEligible: boolean): Promise { - if (isEligible) { - await this.markEligible(opportunityId); - } else { - await this.markNotEligible(opportunityId); - } - } - - // ========================================================================== - // Cancellation Flow - // ========================================================================== - - /** - * Get cancellation eligibility for a customer - * - * Calculates available cancellation months based on the 25th rule. - * - * @returns Cancellation eligibility details - */ - getCancellationEligibility(): CancellationEligibility { - return getCancellationEligibility(); - } - - /** - * Validate a cancellation request - * - * Checks: - * - Month format is valid - * - Month is not before earliest allowed - * - Both confirmations are checked - * - * @param formData - Form data from customer - * @returns Validation result - */ - validateCancellationRequest(formData: CancellationFormData): { valid: boolean; error?: string } { - // Validate month - const monthValidation = validateCancellationMonth(formData.cancellationMonth); - if (!monthValidation.valid) { - return monthValidation; - } - - // Validate confirmations - if (!formData.confirmTermsRead) { - return { - valid: false, - error: "You must confirm you have read the cancellation terms", - }; - } - - if (!formData.confirmMonthEndCancellation) { - return { - valid: false, - error: "You must confirm you understand cancellation is at month end", - }; - } - - return { valid: true }; - } - - /** - * Process a cancellation request - * - * Finds the Opportunity for the service and updates it with cancellation data. - * - * @param whmcsServiceId - WHMCS Service ID - * @param formData - Cancellation form data - * @returns Result of the cancellation update - */ - async processCancellationRequest( - whmcsServiceId: number, - formData: CancellationFormData - ): Promise<{ success: boolean; opportunityId?: string; error?: string }> { - this.logger.log("Processing cancellation request", { - whmcsServiceId, - cancellationMonth: formData.cancellationMonth, - }); - - // Validate the request - const validation = this.validateCancellationRequest(formData); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - - // Find the Opportunity - const opportunityId = - await this.opportunityService.findOpportunityByWhmcsServiceId(whmcsServiceId); - - if (!opportunityId) { - this.logger.warn("No Opportunity found for WHMCS Service", { whmcsServiceId }); - // This is not necessarily an error - older services may not have Opportunities - // Return success but note no Opportunity was found - return { success: true }; - } - - // Transform form data to Opportunity data - const cancellationData: CancellationOpportunityData = - transformCancellationFormToOpportunityData(formData); - - // Update the Opportunity - await this.opportunityService.updateCancellationData(opportunityId, cancellationData); - - this.logger.log("Cancellation request processed successfully", { - opportunityId, - scheduledDate: cancellationData.scheduledCancellationDate, - }); - - return { success: true, opportunityId }; - } - - // ========================================================================== - // Private Helpers - // ========================================================================== - - /** - * Use an existing Opportunity (validate it exists) - */ - private async useExistingOpportunity(opportunityId: string): Promise { - this.logger.debug("Using existing Opportunity", { opportunityId }); - - const opportunity = await this.opportunityService.getOpportunityById(opportunityId); - - if (!opportunity) { - this.logger.warn("Existing Opportunity not found, will need to create new", { - opportunityId, - }); - throw new Error(`Opportunity ${opportunityId} not found`); - } - - return { - opportunityId: opportunity.id, - wasCreated: false, - stage: opportunity.stage, - }; - } - - /** - * Create a new Opportunity for an order - */ - private async createNewOpportunity( - context: OpportunityResolutionContext, - productType: OpportunityProductTypeValue - ): Promise { - this.logger.debug("Creating new Opportunity for order", { - accountId: context.accountId, - productType, - }); - - const stage = this.determineInitialStage(context); - const source = this.determineSource(context); - - // Salesforce workflow auto-generates Opportunity Name - const opportunityId = await this.opportunityService.createOpportunity({ - accountId: context.accountId, - productType, - stage, - source, - applicationStage: APPLICATION_STAGE.INTRO_1, - }); - - return { - opportunityId, - wasCreated: true, - stage, - }; - } - - /** - * Map OrderType to OpportunityProductType - */ - private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue | null { - switch (orderType) { - case "Internet": - return OPPORTUNITY_PRODUCT_TYPE.INTERNET; - case "SIM": - return OPPORTUNITY_PRODUCT_TYPE.SIM; - case "VPN": - return OPPORTUNITY_PRODUCT_TYPE.VPN; - default: - return null; - } - } - - /** - * Determine initial stage for new Opportunity - */ - private determineInitialStage(context: OpportunityResolutionContext): OpportunityStageValue { - // If coming from eligibility request - if (context.source === "eligibility") { - return OPPORTUNITY_STAGE.INTRODUCTION; - } - - // If coming from checkout registration - if (context.source === "checkout") { - return OPPORTUNITY_STAGE.INTRODUCTION; - } - - // Default: order placement - go to Post Processing - return OPPORTUNITY_STAGE.POST_PROCESSING; - } - - /** - * Determine source for new Opportunity - */ - private determineSource( - context: OpportunityResolutionContext - ): (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE] { - switch (context.source) { - case "eligibility": - return OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY; - case "checkout": - return OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION; - case "order": - default: - return OPPORTUNITY_SOURCE.ORDER_PLACEMENT; - } - } -} 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 1be9a0d6..80c72a0e 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common"; 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 { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; import { OrderValidator } from "./order-validator.service.js"; import { OrderBuilder } from "./order-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js"; @@ -14,6 +15,7 @@ import { OPPORTUNITY_SOURCE, OPPORTUNITY_PRODUCT_TYPE, type OpportunityProductTypeValue, + OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT, } from "@customer-portal/domain/opportunity"; type OrderDetailsResponse = OrderDetails; @@ -29,6 +31,7 @@ export class OrderOrchestrator { @Inject(Logger) private readonly logger: Logger, private readonly salesforceOrderService: SalesforceOrderService, private readonly opportunityService: SalesforceOpportunityService, + private readonly lockService: DistributedLockService, private readonly orderValidator: OrderValidator, private readonly orderBuilder: OrderBuilder, private readonly orderItemBuilder: OrderItemBuilder, @@ -163,34 +166,43 @@ export class OrderOrchestrator { } try { - // Try to find existing open Opportunity (Introduction or Ready stage) - const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( - safeAccountId, - productType + const lockKey = `opportunity:order:${safeAccountId}:${productType}`; + + return await this.lockService.withLock( + lockKey, + async () => { + // Try to find existing matchable Opportunity (Introduction or Ready stage) + const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( + safeAccountId, + productType, + { stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT } + ); + + if (existingOppId) { + this.logger.log("Found existing Opportunity for order", { + opportunityIdTail: existingOppId.slice(-4), + productType, + }); + return existingOppId; + } + + // Create new Opportunity - Salesforce workflow auto-generates the name + const newOppId = await this.opportunityService.createOpportunity({ + accountId: safeAccountId, + productType, + stage: OPPORTUNITY_STAGE.POST_PROCESSING, + source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT, + }); + + this.logger.log("Created new Opportunity for order", { + opportunityIdTail: newOppId.slice(-4), + productType, + }); + + return newOppId; + }, + { ttlMs: 10_000 } ); - - if (existingOppId) { - this.logger.log("Found existing Opportunity for order", { - opportunityIdTail: existingOppId.slice(-4), - productType, - }); - return existingOppId; - } - - // Create new Opportunity - Salesforce workflow auto-generates the name - const newOppId = await this.opportunityService.createOpportunity({ - accountId: safeAccountId, - productType, - stage: OPPORTUNITY_STAGE.POST_PROCESSING, - source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT, - }); - - this.logger.log("Created new Opportunity for order", { - opportunityIdTail: newOppId.slice(-4), - productType, - }); - - return newOppId; } catch { this.logger.warn("Failed to resolve Opportunity for order", { orderType, diff --git a/apps/portal/src/app/(public)/(site)/auth/link-whmcs/page.tsx b/apps/portal/src/app/(public)/(site)/auth/link-whmcs/page.tsx deleted file mode 100644 index ab9d883d..00000000 --- a/apps/portal/src/app/(public)/(site)/auth/link-whmcs/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LinkWhmcsView from "@/features/auth/views/LinkWhmcsView"; - -export default function LinkWhmcsPage() { - return ; -} diff --git a/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx b/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx new file mode 100644 index 00000000..33cf7d4c --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx @@ -0,0 +1,5 @@ +import MigrateAccountView from "@/features/auth/views/MigrateAccountView"; + +export default function MigrateAccountPage() { + return ; +} diff --git a/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx b/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx index bf6965d2..b045cbfa 100644 --- a/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx +++ b/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx @@ -11,6 +11,8 @@ export interface AuthLayoutProps { showBackButton?: boolean; backHref?: string; backLabel?: string; + /** Use wider layout for forms with more content like signup */ + wide?: boolean; } export function AuthLayout({ @@ -20,10 +22,13 @@ export function AuthLayout({ showBackButton = false, backHref = "/", backLabel = "Back to Home", + wide = false, }: AuthLayoutProps) { + const maxWidth = wide ? "max-w-xl" : "max-w-md"; + return (
-
+
{showBackButton && (
-
+
{/* Subtle gradient glow behind card */}
diff --git a/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx index 3d5ebce8..5b64b706 100644 --- a/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx +++ b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx @@ -76,33 +76,45 @@ export function InlineAuthSection({
- {mode === "signup" && ( - - )} - {mode === "login" && ( - - )} - {mode === "migrate" && ( -
-

Migrate your account

-

- Use your legacy portal credentials to transfer your account. -

- { - if (result.needsPasswordSet) { - const params = new URLSearchParams({ - email: result.user.email, - redirect: safeRedirect, - }); - router.push(`/auth/set-password?${params.toString()}`); - return; - } - router.push(safeRedirect); - }} - /> -
- )} +
+ {mode === "signup" && ( + <> +

Create your account

+

+ Set up your portal access in a few simple steps. +

+ + + )} + {mode === "login" && ( + <> +

Sign in

+

Access your account to continue.

+ + + )} + {mode === "migrate" && ( + <> +

Migrate your account

+

+ Use your legacy portal credentials to transfer your account. +

+ { + if (result.needsPasswordSet) { + const params = new URLSearchParams({ + email: result.user.email, + redirect: safeRedirect, + }); + router.push(`/auth/set-password?${params.toString()}`); + return; + } + router.push(safeRedirect); + }} + /> + + )} +
{highlights.length > 0 && ( diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index f0ae978b..1f6cf88a 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -83,7 +83,7 @@ export function LoginForm({ }); return ( -
+
void handleSubmit(event)} className="space-y-6"> - {/* Step Indicators */} + {/* Simple Step Indicators */}
{steps.map((s, idx) => { const isCompleted = idx < currentStep; @@ -61,20 +60,20 @@ export function MultiStepForm({
{isCompleted ? : idx + 1}
- {idx < totalSteps - 1 && ( + {idx < steps.length - 1 && (
-
- +
+ - {error && ( - - {error} - - )} + {error && ( + + {error} + + )} - {showFooterLinks && ( -
-

- Already have an account?{" "} - - Sign in - -

-

- Existing customer?{" "} - - Migrate your account - -

-
- )} -
+ {showFooterLinks && ( +
+

+ Already have an account?{" "} + + Sign in + +

+

+ Existing customer?{" "} + + Migrate your account + +

+
+ )}
); } diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx index a453a556..4cb589bc 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx @@ -4,7 +4,6 @@ "use client"; -import { useState } from "react"; import { Input } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; @@ -30,7 +29,6 @@ interface AccountStepProps { export function AccountStep({ form }: AccountStepProps) { const { values, errors, touched, setValue, setTouchedField } = form; const getError = (field: string) => (touched[field] ? errors[field] : undefined); - const [showOptional, setShowOptional] = useState(false); return (
@@ -112,69 +110,55 @@ export function AccountStep({ form }: AccountStepProps) {
-
- + {/* DOB + Gender (Required) */} +
+ + setValue("dateOfBirth", e.target.value || undefined)} + onBlur={() => setTouchedField("dateOfBirth")} + autoComplete="bday" + /> + + + + +
- {showOptional && ( -
- {/* DOB + Gender (Optional WHMCS custom fields) */} -
- - setValue("dateOfBirth", e.target.value || undefined)} - onBlur={() => setTouchedField("dateOfBirth")} - autoComplete="bday" - /> - - - - - -
- - {/* Company (Optional) */} - - setValue("company", e.target.value)} - onBlur={() => setTouchedField("company")} - placeholder="Company name" - autoComplete="organization" - /> - -
- )} + {/* Company (Optional) */} + + setValue("company", e.target.value)} + onBlur={() => setTouchedField("company")} + placeholder="Company name" + autoComplete="organization" + /> +
); } diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 5e701a51..11808afd 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -280,7 +280,7 @@ export const useAuthStore = create()((set, get) => { linkWhmcs: async (linkRequest: LinkWhmcsRequest) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST("/api/auth/link-whmcs", { + const response = await apiClient.POST("/api/auth/migrate", { body: linkRequest, disableCsrf: true, // Public auth endpoint, exempt from CSRF }); diff --git a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx b/apps/portal/src/features/auth/views/MigrateAccountView.tsx similarity index 91% rename from apps/portal/src/features/auth/views/LinkWhmcsView.tsx rename to apps/portal/src/features/auth/views/MigrateAccountView.tsx index c92620c4..41536ca9 100644 --- a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx +++ b/apps/portal/src/features/auth/views/MigrateAccountView.tsx @@ -1,5 +1,5 @@ /** - * Link WHMCS View - Account migration page + * Migrate Account View - Account migration page */ "use client"; @@ -10,7 +10,7 @@ import { AuthLayout } from "../components"; import { LinkWhmcsForm } from "@/features/auth/components"; import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth"; -export function LinkWhmcsView() { +export function MigrateAccountView() { const router = useRouter(); return ( @@ -32,7 +32,7 @@ export function LinkWhmcsView() {
{/* Form */} -
+

Enter Legacy Portal Credentials

@@ -51,7 +51,7 @@ export function LinkWhmcsView() {
{/* Links */} -
+
New customer?{" "} @@ -92,4 +92,4 @@ export function LinkWhmcsView() { ); } -export default LinkWhmcsView; +export default MigrateAccountView; diff --git a/apps/portal/src/features/auth/views/SetPasswordView.tsx b/apps/portal/src/features/auth/views/SetPasswordView.tsx index 17a90a8d..fda8439c 100644 --- a/apps/portal/src/features/auth/views/SetPasswordView.tsx +++ b/apps/portal/src/features/auth/views/SetPasswordView.tsx @@ -15,7 +15,7 @@ function SetPasswordContent() { useEffect(() => { if (!email) { - router.replace("/auth/link-whmcs"); + router.replace("/auth/migrate"); } }, [email, router]); @@ -36,7 +36,7 @@ function SetPasswordContent() { again so we can verify your account.

Go to account transfer diff --git a/apps/portal/src/features/auth/views/SignupView.tsx b/apps/portal/src/features/auth/views/SignupView.tsx index d206f69d..27ca9807 100644 --- a/apps/portal/src/features/auth/views/SignupView.tsx +++ b/apps/portal/src/features/auth/views/SignupView.tsx @@ -13,6 +13,7 @@ export function SignupView() { diff --git a/apps/portal/src/features/auth/views/index.ts b/apps/portal/src/features/auth/views/index.ts index 50a35c81..7e3ecdc2 100644 --- a/apps/portal/src/features/auth/views/index.ts +++ b/apps/portal/src/features/auth/views/index.ts @@ -3,4 +3,4 @@ export { SignupView } from "./SignupView"; export { ForgotPasswordView } from "./ForgotPasswordView"; export { ResetPasswordView } from "./ResetPasswordView"; export { SetPasswordView } from "./SetPasswordView"; -export { LinkWhmcsView } from "./LinkWhmcsView"; +export { MigrateAccountView } from "./MigrateAccountView"; diff --git a/apps/portal/src/features/catalog/components/internet/PlanHeader.tsx b/apps/portal/src/features/catalog/components/internet/PlanHeader.tsx new file mode 100644 index 00000000..4ff5c759 --- /dev/null +++ b/apps/portal/src/features/catalog/components/internet/PlanHeader.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; +import { CardBadge } from "@/features/catalog/components/base/CardBadge"; +import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge"; +import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; +import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog"; + +interface PlanHeaderProps { + plan: InternetPlanCatalogItem; + backHref?: string; + backLabel?: string; + title?: string; + className?: string; +} + +export function PlanHeader({ + plan, + backHref, + backLabel = "Back to Internet Plans", + title = "Configure your plan", + className = "", +}: PlanHeaderProps) { + const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); + + return ( +
+ {backHref && ( + + )} + +

{title}

+ + {planBaseName} + {planDetail ? ` (${planDetail})` : ""} + + +
+ {plan.internetPlanTier ? ( + + ) : null} + {planDetail ? : null} + {plan.monthlyPrice && plan.monthlyPrice > 0 ? ( + + ¥{plan.monthlyPrice.toLocaleString()}/month + + ) : null} +
+
+ ); +} + +function getTierBadgeVariant(tier?: string | null): BadgeVariant { + switch (tier) { + case "Gold": + return "gold"; + case "Platinum": + return "platinum"; + case "Silver": + return "silver"; + case "Recommended": + return "recommended"; + default: + return "default"; + } +} diff --git a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx index a00662dc..9de60fc9 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx @@ -3,10 +3,7 @@ import { useEffect, useState, type ReactElement } from "react"; import { PageLayout } from "@/components/templates/PageLayout"; import { ProgressSteps } from "@/components/molecules"; -import { Button } from "@/components/atoms/button"; -import { CardBadge } from "@/features/catalog/components/base/CardBadge"; -import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge"; -import { ServerIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { ServerIcon } from "@heroicons/react/24/outline"; import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, @@ -19,8 +16,8 @@ import { InstallationStep } from "./steps/InstallationStep"; import { AddonsStep } from "./steps/AddonsStep"; import { ReviewOrderStep } from "./steps/ReviewOrderStep"; import { useConfigureState } from "./hooks/useConfigureState"; -import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { PlanHeader } from "@/features/catalog/components/internet/PlanHeader"; interface Props { plan: InternetPlanCatalogItem | null; @@ -61,6 +58,7 @@ export function InternetConfigureContainer({ currentStep, setCurrentStep, }: Props) { + const shopBasePath = useShopBasePath(); const [renderedStep, setRenderedStep] = useState(currentStep); const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle"); // Use local state ONLY for step validation, step management now in Zustand @@ -214,7 +212,11 @@ export function InternetConfigureContainer({
{/* Plan Header */} - + {/* Progress Steps */}
@@ -230,60 +232,3 @@ export function InternetConfigureContainer({ ); } - -function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) { - const shopBasePath = useShopBasePath(); - const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); - - return ( -
- - -

Configure your plan

- - {planBaseName} - {planDetail ? ` (${planDetail})` : ""} - - -
- {plan.internetPlanTier ? ( - - ) : null} - {planDetail ? : null} - {plan.monthlyPrice && plan.monthlyPrice > 0 ? ( - - ¥{plan.monthlyPrice.toLocaleString()}/month - - ) : null} -
-
- ); -} - -function getTierBadgeVariant(tier?: string | null): BadgeVariant { - switch (tier) { - case "Gold": - return "gold"; - case "Platinum": - return "platinum"; - case "Silver": - return "silver"; - case "Recommended": - return "recommended"; - default: - return "default"; - } -} diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index af2a2671..aaf4ee16 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -69,7 +69,10 @@ export function InternetPlansContainer() { const isPending = eligibilityStatus === "pending"; const isNotRequested = eligibilityStatus === "not_requested"; const isIneligible = eligibilityStatus === "ineligible"; - const orderingLocked = isPending || isNotRequested || isIneligible; + + // While loading eligibility, we assume locked to prevent showing unfiltered catalog for a split second + const orderingLocked = + eligibilityLoading || isPending || isNotRequested || isIneligible || !eligibilityStatus; const hasServiceAddress = Boolean( user?.address?.address1 && user?.address?.city && diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx index cd1fdc55..e6559941 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx @@ -1,17 +1,16 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { CheckIcon, ServerIcon } from "@heroicons/react/24/outline"; +import { WifiIcon, CheckCircleIcon, ClockIcon, BellIcon } from "@heroicons/react/24/outline"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; -import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; /** * Public Internet Configure View * - * Shows selected plan information and prompts for authentication via modal. - * Much better UX than redirecting to a full signup page. + * Generic signup flow for internet availability check. + * Focuses on account creation, not plan details. */ export function PublicInternetConfigureView() { const shopBasePath = useShopBasePath(); @@ -23,74 +22,45 @@ export function PublicInternetConfigureView() { : "/account/shop/internet?autoEligibilityRequest=1"; return ( - <> -
- +
+ - - -
-
-
-
-
- -
-
-
-

- We'll check availability for your address -

-

- This request unlocks the internet plans that match your building type and local - infrastructure. -

-
-
- -
-

- What happens next -

-
    -
  • - - - Create your account and confirm your service address. - -
  • -
  • - - - We submit an availability request to our team. - -
  • -
  • - - - You'll be notified when your personalized plans are ready. - -
  • -
-
+ {/* Header */} +
+
+
+
+
+

Check Internet Availability

+

+ Create an account to verify service availability at your address. +

+
- + {/* Process Steps - Compact */} +
+
+ + Create account +
+
+ + We verify availability +
+
+ + Get notified
- + + {/* Auth Section */} + +
); } diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 7fb25fb9..6d51d1d7 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -127,7 +127,7 @@ export function PublicLandingView() { Migrate account diff --git a/apps/portal/src/features/marketing/views/PublicLandingView.tsx b/apps/portal/src/features/marketing/views/PublicLandingView.tsx index e2721127..6f3e0186 100644 --- a/apps/portal/src/features/marketing/views/PublicLandingView.tsx +++ b/apps/portal/src/features/marketing/views/PublicLandingView.tsx @@ -72,7 +72,7 @@ export function PublicLandingView() { Login to Portal Migrate Account diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index d2a501a0..c64ff6b2 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -22,7 +22,7 @@ import { logger } from "@/lib/logger"; const AUTH_ENDPOINTS = [ "/api/auth/login", "/api/auth/signup", - "/api/auth/link-whmcs", + "/api/auth/migrate", "/api/auth/set-password", "/api/auth/reset-password", "/api/auth/check-password-needed", diff --git a/docs/_archive/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md b/docs/_archive/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md index 1dd99b57..92baa6f3 100644 --- a/docs/_archive/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md +++ b/docs/_archive/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md @@ -40,7 +40,7 @@ 2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page. -3. **Opportunity Lifecycle Fields Exist** - `Opportunity_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working. +3. **Opportunity Lifecycle Fields Exist** - `Portal_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working. 4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification. @@ -68,7 +68,7 @@ ├─────────────────────────────────────────────────────────────────────────────────┤ │ CatalogService │ CheckoutService │ VerificationService │ OrderService │ │ │ │ │ │ -│ OpportunityMatchingService │ OrderOrchestrator │ FulfillmentOrchestrator │ +│ Opportunity Resolution │ OrderOrchestrator │ FulfillmentOrchestrator │ └───────┬──────────────────────┴─────────┬──────────┴────────────┬───────────────┘ │ │ │ ▼ ▼ ▼ @@ -364,7 +364,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking, 4. **Manual Agent Work Required** - Agent checks NTT serviceability 5. Agent updates Account with eligibility result 6. Salesforce Flow sends email notification to customer -7. **Note:** Opportunity is NOT created during eligibility - only at order placement +7. **Note:** Opportunity **is created/reused during eligibility** (Stage = Introduction) so the Case can link to it (`Case.OpportunityId`) and the journey can be reused at order placement. **Account Fields Updated:** | Field | Value Set | When | @@ -425,14 +425,18 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking, ### Opportunity Management Module -**Location:** `apps/bff/src/modules/orders/services/opportunity-matching.service.ts` +**Location (current code paths):** + +- `apps/bff/src/modules/catalog/services/internet-catalog.service.ts` (eligibility request creates/reuses Opportunity + Case) +- `apps/bff/src/modules/orders/services/order-orchestrator.service.ts` (order placement resolves Opportunity) +- `apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts` (Salesforce query/create/update) **Matching Rules:** | Scenario | Action | |----------|--------| | Order has `opportunityId` | Use it directly | | Internet order without Opp | Find Introduction/Ready stage or create new | -| SIM order without Opp | Find open Opportunity or create new | +| SIM order without Opp | Find Introduction/Ready stage or create new | | VPN order | Always create new Opportunity | **Stage Transitions by Trigger:** @@ -539,7 +543,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking, - Salesforce Flow automatically sends email to customer when eligibility fields are updated - Portal polls Account for eligibility changes (customer sees result on next visit) -- **Opportunity is created later at order placement** (not during eligibility check) +- Opportunity is created/reused at eligibility request (Stage = Introduction) and then reused at order placement when possible --- @@ -662,7 +666,7 @@ LONG TERM: **Status:** ✅ Confirmed fields exist: -- `Opportunity_Source__c` - Picklist with portal values +- `Portal_Source__c` - Picklist with portal values - `WHMCS_Service_ID__c` - Number field for WHMCS linking **Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder). diff --git a/docs/how-it-works/eligibility-and-verification.md b/docs/how-it-works/eligibility-and-verification.md index 931ebde5..aaf050b4 100644 --- a/docs/how-it-works/eligibility-and-verification.md +++ b/docs/how-it-works/eligibility-and-verification.md @@ -21,7 +21,7 @@ This guide describes how eligibility and verification work in the customer porta 1. Customer navigates to `/account/shop/internet` 2. Customer enters service address and requests eligibility check -3. Portal creates a Salesforce Case for agent review +3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review 4. Agent performs NTT serviceability check (manual process) 5. Agent updates Account eligibility fields 6. Salesforce Flow sends email notification to customer diff --git a/docs/integrations/salesforce/opportunity-lifecycle.md b/docs/integrations/salesforce/opportunity-lifecycle.md index d2039e3b..57615b11 100644 --- a/docs/integrations/salesforce/opportunity-lifecycle.md +++ b/docs/integrations/salesforce/opportunity-lifecycle.md @@ -352,10 +352,10 @@ This guide documents the Salesforce Opportunity integration for service lifecycl │ │ │ 4. BIDIRECTIONAL LINK COMPLETE │ │ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │ -│ └─ WHMCS Service.OpportunityId → Opportunity │ +│ └─ WHMCS Service.OpportunityId → Opportunity (optional; helpful for ops/debugging) │ │ │ │ 5. CUSTOMER SEES SERVICE PAGE │ -│ └─ Portal queries WHMCS → gets OpportunityId → queries Opp │ +│ └─ Portal shows active services from WHMCS; lifecycle/cancellation tracking lives on the linked Salesforce Opportunity (via WHMCS_Service_ID__c) │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` @@ -448,15 +448,15 @@ On/After 25th → Must select NEXT month or later - [x] Case.OpportunityId (standard lookup) - [x] Order.OpportunityId (standard lookup) -**New Fields to Create on Opportunity:** +**Opportunity Fields Required (Portal writes these):** -- [ ] `Portal_Source__c` picklist -- [ ] `WHMCS_Service_ID__c` number field +- [ ] `Portal_Source__c` picklist (used to track how the Opportunity was created) +- [ ] `WHMCS_Service_ID__c` number field (used to link WHMCS service → Salesforce Opportunity for cancellations) ### WHMCS Admin Tasks -- [ ] Create `OpportunityId` custom field on Services/Hosting -- [ ] Document custom field ID for AddOrder API +- [ ] Create an `OpportunityId` custom field on Services/Hosting (optional but recommended for ops/debugging) +- [ ] Confirm whether your WHMCS expects `customfields[]` keys by **name** (`OpportunityId`) or by **numeric field id**, and configure accordingly ### BFF Development Tasks @@ -466,22 +466,20 @@ On/After 25th → Must select NEXT month or later - [x] Field map configuration - [x] `SalesforceOpportunityService` - [x] Cancellation deadline helpers (25th rule) +- [x] Eligibility request creates/reuses Opportunity (Stage `Introduction`) and links Case (`Case.OpportunityId`) +- [x] Order placement reuses Opportunity in `Introduction`/`Ready` (otherwise creates new `Post Processing`) +- [x] Fulfillment passes `OpportunityId` to WHMCS and links `WHMCS_Service_ID__c` back to the Opportunity +- [x] Cancellation Case creation + Opportunity cancellation field updates (Internet cancellations) -**Pending:** +**Optional / Future:** -- [ ] Register services in Salesforce module -- [ ] Integrate Opportunity matching into eligibility flow -- [ ] Integrate Opportunity matching into order placement -- [ ] Update order provisioning to pass OpportunityId to WHMCS -- [ ] Update WHMCS mapper to read OpportunityId custom field -- [ ] Create cancellation Case service -- [ ] Integrate Case creation into cancellation flow +- [ ] Read `OpportunityId` back from WHMCS service custom fields (portal currently relies on Salesforce `WHMCS_Service_ID__c` for linking) ### Frontend Tasks - [ ] Order tracking page (from Salesforce Order) - [ ] Service page with cancellation status -- [ ] Cancellation form with 25th deadline logic +- [x] Cancellation forms (Internet + SIM) with 25th deadline logic --- diff --git a/packages/domain/opportunity/contract.ts b/packages/domain/opportunity/contract.ts index 6746d909..35aa1888 100644 --- a/packages/domain/opportunity/contract.ts +++ b/packages/domain/opportunity/contract.ts @@ -201,12 +201,16 @@ export const OPPORTUNITY_SOURCE = { export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE]; // ============================================================================ -// Opportunity Matching Constants +// Opportunity Matching / Stage Sets // ============================================================================ /** - * Stages considered "open" for matching purposes - * Opportunities in these stages can be linked to new orders + * Stages considered "open" (i.e. not closed) in our lifecycle. + * + * IMPORTANT: + * - Do NOT use this list to match a *new* order or eligibility request to an existing Opportunity. + * For those flows we intentionally match a much smaller set of early-stage Opportunities. + * - Use the `OPPORTUNITY_MATCH_STAGES_*` constants below instead. */ export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [ OPPORTUNITY_STAGE.INTRODUCTION, @@ -215,6 +219,27 @@ export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [ OPPORTUNITY_STAGE.ACTIVE, ]; +/** + * Stages eligible for matching during an Internet eligibility request. + * + * We only ever reuse the initial "Introduction" opportunity; later stages indicate the + * customer journey has progressed beyond eligibility. + */ +export const OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY: OpportunityStageValue[] = [ + OPPORTUNITY_STAGE.INTRODUCTION, +]; + +/** + * Stages eligible for matching during order placement. + * + * If a customer came from eligibility, the Opportunity will usually be in "Introduction" or "Ready". + * We must never match an "Active" Opportunity for a new order (that would corrupt lifecycle tracking). + */ +export const OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT: OpportunityStageValue[] = [ + OPPORTUNITY_STAGE.INTRODUCTION, + OPPORTUNITY_STAGE.READY, +]; + /** * Stages that indicate the Opportunity is closed */ diff --git a/packages/domain/opportunity/index.ts b/packages/domain/opportunity/index.ts index bb915817..4db15efe 100644 --- a/packages/domain/opportunity/index.ts +++ b/packages/domain/opportunity/index.ts @@ -45,6 +45,8 @@ export { type OpportunitySourceValue, // Matching constants OPEN_OPPORTUNITY_STAGES, + OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY, + OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT, CLOSED_OPPORTUNITY_STAGES, // Deadline constants CANCELLATION_DEADLINE_DAY,