diff --git a/apps/bff/src/core/utils/safe-operation.util.ts b/apps/bff/src/core/utils/safe-operation.util.ts new file mode 100644 index 00000000..6490ae50 --- /dev/null +++ b/apps/bff/src/core/utils/safe-operation.util.ts @@ -0,0 +1,211 @@ +import type { Logger } from "nestjs-pino"; +import { extractErrorMessage } from "./error.util.js"; + +/** + * Operation criticality levels determine error handling behavior + */ +export enum OperationCriticality { + /** Throw error - workflow cannot continue without this operation */ + CRITICAL = "critical", + + /** Log error, return fallback - continue in degraded mode */ + DEGRADABLE = "degradable", + + /** Log warning, return fallback - expected to fail sometimes */ + OPTIONAL = "optional", + + /** Debug log only - non-essential operation */ + SILENT = "silent", +} + +/** + * Options for safe operation execution + */ +export interface SafeOperationOptions { + /** How critical is this operation? Determines error handling behavior */ + criticality: OperationCriticality; + + /** Value to return if operation fails (required for non-CRITICAL) */ + fallback?: T; + + /** Context description for logging */ + context: string; + + /** Logger instance for error reporting */ + logger: Logger; + + /** Additional metadata for logging */ + metadata?: Record; +} + +/** + * Result of a safe operation + */ +export interface SafeOperationResult { + /** Whether the operation succeeded */ + success: boolean; + + /** The result value (from operation or fallback) */ + value: T; + + /** Error message if operation failed */ + error?: string; +} + +/** + * Execute an operation with criticality-based error handling + * + * @example + * // Critical operation - throws on error + * const user = await safeOperation( + * () => userService.findById(id), + * { + * criticality: OperationCriticality.CRITICAL, + * context: 'Load user for authentication', + * logger: this.logger, + * } + * ); + * + * @example + * // Optional operation - returns fallback on error + * const orders = await safeOperation( + * () => orderService.getOrders(userId), + * { + * criticality: OperationCriticality.OPTIONAL, + * fallback: [], + * context: 'Load orders for dashboard', + * logger: this.logger, + * } + * ); + */ +export async function safeOperation( + executor: () => Promise, + options: SafeOperationOptions +): Promise { + const { criticality, fallback, context, logger, metadata = {} } = options; + + try { + return await executor(); + } catch (error) { + const errorMessage = extractErrorMessage(error); + const logPayload = { + context, + error: errorMessage, + criticality, + ...metadata, + }; + + switch (criticality) { + case OperationCriticality.CRITICAL: + logger.error(logPayload, `Critical operation failed: ${context}`); + throw error; + + case OperationCriticality.DEGRADABLE: + logger.error(logPayload, `Degradable operation failed: ${context}`); + if (fallback === undefined) { + throw new Error(`Fallback required for degradable operation: ${context}`); + } + return fallback; + + case OperationCriticality.OPTIONAL: + logger.warn(logPayload, `Optional operation failed: ${context}`); + if (fallback === undefined) { + throw new Error(`Fallback required for optional operation: ${context}`); + } + return fallback; + + case OperationCriticality.SILENT: + logger.debug(logPayload, `Silent operation failed: ${context}`); + if (fallback === undefined) { + throw new Error(`Fallback required for silent operation: ${context}`); + } + return fallback; + + default: + throw error; + } + } +} + +/** + * Execute an operation and return a result object with success/failure info + * + * Useful when you need to know if the operation failed but still want + * to continue with a fallback value. + * + * @example + * const result = await safeOperationWithResult( + * () => externalService.fetchData(), + * { + * criticality: OperationCriticality.OPTIONAL, + * fallback: null, + * context: 'Fetch external data', + * logger: this.logger, + * } + * ); + * + * if (!result.success) { + * // Handle degraded state + * } + */ +export async function safeOperationWithResult( + executor: () => Promise, + options: SafeOperationOptions +): Promise> { + try { + const value = await executor(); + return { success: true, value }; + } catch (error) { + const errorMessage = extractErrorMessage(error); + + // If critical, still throw + if (options.criticality === OperationCriticality.CRITICAL) { + const result = await safeOperation(executor, options).catch((e: Error) => { + throw e; + }); + return { success: true, value: result }; + } + + // For non-critical, get the fallback value + const fallbackValue = await safeOperation(executor, options).catch(() => options.fallback as T); + + return { + success: false, + value: fallbackValue, + error: errorMessage, + }; + } +} + +/** + * Create a pre-configured safe operation executor for a specific context + * + * @example + * const safeLoadOrders = createSafeExecutor( + * OperationCriticality.OPTIONAL, + * [], + * 'Load orders', + * this.logger + * ); + * + * const orders = await safeLoadOrders(() => this.orderService.getOrders(userId)); + */ +export function createSafeExecutor( + criticality: OperationCriticality, + fallback: T, + context: string, + logger: Logger +): (executor: () => Promise, metadata?: Record) => Promise { + return async (executor: () => Promise, metadata?: Record) => { + const options: SafeOperationOptions = { + criticality, + fallback, + context, + logger, + }; + if (metadata !== undefined) { + options.metadata = metadata; + } + return safeOperation(executor, options); + }; +} diff --git a/apps/bff/src/infra/cache/cache-keys.ts b/apps/bff/src/infra/cache/cache-keys.ts new file mode 100644 index 00000000..44f66005 --- /dev/null +++ b/apps/bff/src/infra/cache/cache-keys.ts @@ -0,0 +1,187 @@ +/** + * Centralized Cache Key Registry + * + * Provides standardized key patterns and TTLs for all cached data. + * This ensures consistent naming conventions and makes it easy to: + * - Find all cache keys for a domain + * - Understand cache structure at a glance + * - Audit cache invalidation patterns + * + * Naming convention: {domain}:{scope}:{identifier} + * Examples: + * - orders:account:001ABC → All orders for account + * - orders:detail:ORDER123 → Single order details + * - user:profile:user-456 → User profile + */ + +/** + * Cache key builder functions + * Each domain has its own namespace to prevent collisions + */ +export const CacheKeys = { + // ============================================================================ + // Orders Domain + // ============================================================================ + orders: { + /** Orders list for a Salesforce account */ + accountOrders: (sfAccountId: string) => `orders:account:${sfAccountId}`, + + /** Single order details */ + orderDetails: (orderId: string) => `orders:detail:${orderId}`, + + /** Order validation result (temporary) */ + validationResult: (orderId: string) => `orders:validation:${orderId}`, + }, + + // ============================================================================ + // User Domain + // ============================================================================ + user: { + /** User profile data */ + profile: (userId: string) => `user:profile:${userId}`, + + /** User dashboard summary */ + dashboardSummary: (userId: string) => `user:dashboard:${userId}`, + }, + + // ============================================================================ + // Services Domain (Subscriptions) + // ============================================================================ + services: { + /** All services for a WHMCS client */ + clientServices: (whmcsClientId: number) => `services:client:${whmcsClientId}`, + + /** Single service/subscription details */ + serviceDetails: (serviceId: number) => `services:detail:${serviceId}`, + + /** SIM usage data */ + simUsage: (subscriptionId: number) => `services:sim:usage:${subscriptionId}`, + + /** Internet service details */ + internetDetails: (subscriptionId: number) => `services:internet:${subscriptionId}`, + }, + + // ============================================================================ + // Billing Domain + // ============================================================================ + billing: { + /** Invoices list for a WHMCS client */ + clientInvoices: (whmcsClientId: number) => `billing:invoices:${whmcsClientId}`, + + /** Single invoice details */ + invoiceDetails: (invoiceId: number) => `billing:invoice:${invoiceId}`, + + /** Payment methods for a WHMCS client */ + paymentMethods: (whmcsClientId: number) => `billing:payments:${whmcsClientId}`, + }, + + // ============================================================================ + // ID Mappings Domain + // ============================================================================ + mappings: { + /** User to external IDs mapping */ + byUserId: (userId: string) => `mappings:user:${userId}`, + + /** WHMCS client to user mapping */ + byWhmcsClientId: (clientId: number) => `mappings:whmcs:${clientId}`, + + /** Salesforce account to user mapping */ + bySfAccountId: (accountId: string) => `mappings:sf:${accountId}`, + }, + + // ============================================================================ + // Support Domain + // ============================================================================ + support: { + /** Support cases for a Salesforce account */ + accountCases: (sfAccountId: string) => `support:cases:${sfAccountId}`, + + /** Single case details */ + caseDetails: (caseId: string) => `support:case:${caseId}`, + }, + + // ============================================================================ + // Catalog Domain + // ============================================================================ + catalog: { + /** Products list */ + products: () => `catalog:products`, + + /** Single product details */ + productDetails: (productId: string) => `catalog:product:${productId}`, + + /** Pricebook entries */ + pricebook: (pricebookId: string) => `catalog:pricebook:${pricebookId}`, + }, +} as const; + +/** + * Cache TTL configuration in seconds + * + * Categories: + * - CDC_DRIVEN (null): No TTL - invalidated by Change Data Capture events + * - SHORT: 60 seconds - frequently changing data + * - MEDIUM: 300 seconds (5 min) - moderately stable data + * - LONG: 3600 seconds (1 hour) - rarely changing data + * - EXTENDED: 86400 seconds (24 hours) - static/reference data + */ +export const CacheTTL = { + // CDC-driven caches (no expiry, invalidated by events) + orders: { + accountOrders: null, // CDC-driven + orderDetails: null, // CDC-driven + validationResult: 300, // 5 minutes - temporary validation data + }, + + user: { + profile: 600, // 10 minutes + dashboardSummary: 60, // 1 minute - frequently changing + }, + + services: { + clientServices: 300, // 5 minutes + serviceDetails: 300, // 5 minutes + simUsage: 60, // 1 minute - real-time usage data + internetDetails: 300, // 5 minutes + }, + + billing: { + clientInvoices: 300, // 5 minutes + invoiceDetails: 600, // 10 minutes + paymentMethods: 600, // 10 minutes + }, + + mappings: { + byUserId: 3600, // 1 hour - rarely changes + byWhmcsClientId: 3600, // 1 hour + bySfAccountId: 3600, // 1 hour + }, + + support: { + accountCases: 60, // 1 minute - case updates are frequent + caseDetails: 60, // 1 minute + }, + + catalog: { + products: 3600, // 1 hour + productDetails: 3600, // 1 hour + pricebook: 86400, // 24 hours - rarely changes + }, +} as const; + +/** + * Pattern builders for bulk operations (e.g., invalidating all orders for a user) + */ +export const CachePatterns = { + /** All orders-related keys for an account */ + allAccountOrders: (sfAccountId: string) => `orders:*:${sfAccountId}*`, + + /** All user-related keys */ + allUserKeys: (userId: string) => `user:*:${userId}*`, + + /** All services for a WHMCS client */ + allClientServices: (whmcsClientId: number) => `services:*:${whmcsClientId}*`, + + /** All mappings for a user */ + allUserMappings: (userId: string) => `mappings:*:${userId}*`, +} as const; diff --git a/apps/bff/src/infra/workflow/index.ts b/apps/bff/src/infra/workflow/index.ts new file mode 100644 index 00000000..eb2750e6 --- /dev/null +++ b/apps/bff/src/infra/workflow/index.ts @@ -0,0 +1,8 @@ +export { WorkflowInfraModule } from "./workflow.module.js"; +export { WorkflowStepTrackerService } from "./workflow-step-tracker.service.js"; +export type { + WorkflowStep, + WorkflowStepConfig, + WorkflowStepContext, + WorkflowStepStatus, +} from "./workflow-step-tracker.types.js"; diff --git a/apps/bff/src/infra/workflow/workflow-step-tracker.service.ts b/apps/bff/src/infra/workflow/workflow-step-tracker.service.ts new file mode 100644 index 00000000..5d267ceb --- /dev/null +++ b/apps/bff/src/infra/workflow/workflow-step-tracker.service.ts @@ -0,0 +1,128 @@ +import { Injectable } from "@nestjs/common"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import type { + WorkflowStep, + WorkflowStepConfig, + WorkflowStepContext, + WorkflowStepStatus, +} from "./workflow-step-tracker.types.js"; + +/** + * Workflow Step Tracker Service + * + * Provides reusable step tracking for any multi-step workflow. + * Creates a context that tracks step status, timing, and errors. + * + * Usage: + * ```typescript + * const tracker = workflowStepTracker.createContext([ + * { id: 'validation', label: 'Validate request' }, + * { id: 'process', label: 'Process data' }, + * { id: 'cleanup', label: 'Cleanup', condition: needsCleanup }, + * ]); + * + * // Option 1: Wrap executors for automatic tracking + * const wrappedExecutor = tracker.wrap('validation', async () => { + * return await validateRequest(); + * }); + * + * // Option 2: Manual status updates + * tracker.updateStatus('validation', 'in_progress'); + * try { + * await validateRequest(); + * tracker.updateStatus('validation', 'completed'); + * } catch (error) { + * tracker.updateStatus('validation', 'failed', error.message); + * throw error; + * } + * ``` + */ +@Injectable() +export class WorkflowStepTrackerService { + /** + * Create a new workflow step context from step configurations + * Steps with `condition: false` are automatically excluded + */ + createContext(configs: WorkflowStepConfig[]): WorkflowStepContext { + const steps: WorkflowStep[] = configs + .filter(config => config.condition !== false) + .map(config => ({ + id: config.id, + label: config.label, + status: "pending" as const, + })); + + const context: WorkflowStepContext = { + steps, + + updateStatus(stepId: string, status: WorkflowStepStatus, error?: string): void { + const step = steps.find(s => s.id === stepId); + if (!step) return; + + const timestamp = new Date(); + + if (status === "in_progress") { + step.status = "in_progress"; + step.startedAt = timestamp; + delete step.error; + return; + } + + step.status = status; + step.completedAt = timestamp; + + if (status === "failed" && error !== undefined) { + step.error = error; + } else { + delete step.error; + } + }, + + wrap(stepId: string, executor: () => Promise): () => Promise { + return async () => { + context.updateStatus(stepId, "in_progress"); + try { + const result = await executor(); + context.updateStatus(stepId, "completed"); + return result; + } catch (error) { + context.updateStatus(stepId, "failed", extractErrorMessage(error)); + throw error; + } + }; + }, + + getFailedStep(): WorkflowStep | undefined { + return steps.find(s => s.status === "failed"); + }, + + isComplete(): boolean { + return steps.every(s => s.status === "completed" || s.status === "skipped"); + }, + + skip(stepId: string): void { + context.updateStatus(stepId, "skipped"); + }, + }; + + return context; + } + + /** + * Create step configurations for order fulfillment workflow + * This is a convenience method for the common fulfillment use case + */ + createFulfillmentStepConfigs(orderType?: string): WorkflowStepConfig[] { + return [ + { id: "validation", label: "Validate fulfillment request" }, + { id: "sf_status_update", label: "Update Salesforce order status" }, + { id: "order_details", label: "Load order details" }, + { id: "mapping", label: "Map order items to WHMCS format" }, + { id: "whmcs_create", label: "Create order in WHMCS" }, + { id: "whmcs_accept", label: "Accept/provision WHMCS order" }, + { id: "sim_fulfillment", label: "SIM-specific fulfillment", condition: orderType === "SIM" }, + { id: "sf_success_update", label: "Update Salesforce with success" }, + { id: "opportunity_update", label: "Update linked Opportunity" }, + ]; + } +} diff --git a/apps/bff/src/infra/workflow/workflow-step-tracker.types.ts b/apps/bff/src/infra/workflow/workflow-step-tracker.types.ts new file mode 100644 index 00000000..07124f75 --- /dev/null +++ b/apps/bff/src/infra/workflow/workflow-step-tracker.types.ts @@ -0,0 +1,60 @@ +/** + * Workflow Step Status + */ +export type WorkflowStepStatus = "pending" | "in_progress" | "completed" | "failed" | "skipped"; + +/** + * Represents a single step in a workflow + */ +export interface WorkflowStep { + id: string; + label: string; + status: WorkflowStepStatus; + startedAt?: Date | undefined; + completedAt?: Date | undefined; + error?: string | undefined; +} + +/** + * Configuration for defining a workflow step + */ +export interface WorkflowStepConfig { + id: string; + label: string; + /** Whether this step should be included based on runtime conditions */ + condition?: boolean; +} + +/** + * Context for tracking workflow steps + */ +export interface WorkflowStepContext { + /** All steps in the workflow */ + steps: WorkflowStep[]; + + /** + * Update the status of a step + */ + updateStatus(stepId: string, status: WorkflowStepStatus, error?: string): void; + + /** + * Wrap an async executor to automatically track step status + * Returns a function that can be passed to distributed transaction steps + */ + wrap(stepId: string, executor: () => Promise): () => Promise; + + /** + * Get the first failed step, if any + */ + getFailedStep(): WorkflowStep | undefined; + + /** + * Check if all steps completed successfully + */ + isComplete(): boolean; + + /** + * Mark a step as skipped + */ + skip(stepId: string): void; +} diff --git a/apps/bff/src/infra/workflow/workflow.module.ts b/apps/bff/src/infra/workflow/workflow.module.ts new file mode 100644 index 00000000..ddb9e24b --- /dev/null +++ b/apps/bff/src/infra/workflow/workflow.module.ts @@ -0,0 +1,17 @@ +import { Module, Global } from "@nestjs/common"; +import { WorkflowStepTrackerService } from "./workflow-step-tracker.service.js"; + +/** + * Workflow Infrastructure Module + * + * Provides reusable workflow infrastructure for multi-step operations. + * + * Exports: + * - WorkflowStepTrackerService: Creates step tracking contexts for workflows + */ +@Global() +@Module({ + providers: [WorkflowStepTrackerService], + exports: [WorkflowStepTrackerService], +}) +export class WorkflowInfraModule {} diff --git a/apps/bff/src/modules/auth/application/auth-health.service.ts b/apps/bff/src/modules/auth/application/auth-health.service.ts index bc9beb88..0a69dce8 100644 --- a/apps/bff/src/modules/auth/application/auth-health.service.ts +++ b/apps/bff/src/modules/auth/application/auth-health.service.ts @@ -4,7 +4,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -30,7 +30,7 @@ export interface AuthHealthCheckResult { @Injectable() export class AuthHealthService { constructor( - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly configService: ConfigService, private readonly whmcsConnectionService: WhmcsConnectionFacade, private readonly salesforceService: SalesforceFacade, @@ -57,7 +57,7 @@ export class AuthHealthService { // Check database try { - await this.usersFacade.findByEmail("health-check@test.com"); + await this.usersService.findByEmail("health-check@test.com"); health.database = true; } catch (error) { this.logger.debug("Database health check failed", { error: extractErrorMessage(error) }); diff --git a/apps/bff/src/modules/auth/application/auth-login.service.ts b/apps/bff/src/modules/auth/application/auth-login.service.ts index 03a272f0..42082937 100644 --- a/apps/bff/src/modules/auth/application/auth-login.service.ts +++ b/apps/bff/src/modules/auth/application/auth-login.service.ts @@ -6,7 +6,7 @@ import { Logger } from "nestjs-pino"; import * as argon2 from "argon2"; import type { Request } from "express"; import type { User as PrismaUser } from "@prisma/client"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -22,7 +22,7 @@ export class AuthLoginService { private readonly LOCKOUT_DURATION_MINUTES = 15; constructor( - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly auditService: AuditService, @Inject(Logger) private readonly logger: Logger ) {} @@ -35,7 +35,7 @@ export class AuthLoginService { password: string, request?: Request ): Promise { - const user = await this.usersFacade.findByEmailInternal(email); + const user = await this.usersService.findByEmailInternal(email); if (!user) { await this.auditService.logAuthEvent( @@ -118,7 +118,7 @@ export class AuthLoginService { isAccountLocked = true; } - await this.usersFacade.update(user.id, { + await this.usersService.update(user.id, { failedLoginAttempts: newFailedAttempts, lockedUntil, }); diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts similarity index 95% rename from apps/bff/src/modules/auth/application/auth.facade.ts rename to apps/bff/src/modules/auth/application/auth-orchestrator.service.ts index 97b6ab70..acda5c08 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth-orchestrator.service.ts @@ -1,5 +1,5 @@ import { Injectable, UnauthorizedException, BadRequestException, Inject } from "@nestjs/common"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js"; @@ -30,9 +30,9 @@ import { AuthHealthService } from "./auth-health.service.js"; import { AuthLoginService } from "./auth-login.service.js"; /** - * Auth Facade + * Auth Orchestrator * - * Application layer facade that orchestrates authentication operations. + * Application layer orchestrator that coordinates authentication operations. * Delegates to specialized services for specific functionality: * - AuthHealthService: Health checks * - AuthLoginService: Login validation @@ -41,9 +41,9 @@ import { AuthLoginService } from "./auth-login.service.js"; * - WhmcsLinkWorkflowService: WHMCS account linking */ @Injectable() -export class AuthFacade { +export class AuthOrchestrator { constructor( - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly mappingsService: MappingsService, private readonly whmcsSsoService: WhmcsSsoService, private readonly discoveryService: WhmcsAccountDiscoveryService, @@ -88,7 +88,7 @@ export class AuthFacade { } // Update last login time and reset failed attempts - await this.usersFacade.update(user.id, { + await this.usersService.update(user.id, { lastLoginAt: new Date(), failedLoginAttempts: 0, lockedUntil: null, @@ -102,7 +102,7 @@ export class AuthFacade { true ); - const prismaUser = await this.usersFacade.findByIdInternal(user.id); + const prismaUser = await this.usersService.findByIdInternal(user.id); if (!prismaUser) { throw new UnauthorizedException("User record missing"); } @@ -238,7 +238,7 @@ export class AuthFacade { let needsPasswordSet = false; try { - portalUser = await this.usersFacade.findByEmailInternal(normalized); + portalUser = await this.usersService.findByEmailInternal(normalized); if (portalUser) { mapped = await this.mappingsService.hasMapping(portalUser.id); needsPasswordSet = !portalUser.passwordHash; diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 07526220..7d1d803c 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -1,6 +1,6 @@ import { Module } from "@nestjs/common"; import { APP_GUARD } from "@nestjs/core"; -import { AuthFacade } from "./application/auth.facade.js"; +import { AuthOrchestrator } from "./application/auth-orchestrator.service.js"; import { AuthHealthService } from "./application/auth-health.service.js"; import { AuthLoginService } from "./application/auth-login.service.js"; import { AuthController } from "./presentation/http/auth.controller.js"; @@ -38,7 +38,7 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; controllers: [AuthController, GetStartedController], providers: [ // Application services - AuthFacade, + AuthOrchestrator, AuthHealthService, AuthLoginService, // Token services @@ -74,6 +74,6 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; useClass: PermissionsGuard, }, ], - exports: [AuthFacade, TokenBlacklistService, AuthTokenService, PermissionsGuard], + exports: [AuthOrchestrator, TokenBlacklistService, AuthTokenService, PermissionsGuard], }) export class AuthModule {} diff --git a/apps/bff/src/modules/auth/infra/token/token-storage.service.ts b/apps/bff/src/modules/auth/infra/token/token-storage.service.ts index cc590233..62d8f992 100644 --- a/apps/bff/src/modules/auth/infra/token/token-storage.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-storage.service.ts @@ -148,6 +148,153 @@ export class TokenStorageService { await this.redis.setex(key, ttlSeconds, JSON.stringify({ familyId, userId, valid: false })); } + /** + * Atomically validate and rotate a refresh token. + * Uses Redis Lua script to prevent race conditions during concurrent refresh requests. + * + * @returns Object with success flag and error reason if failed + */ + async atomicTokenRotation(params: { + oldTokenHash: string; + newTokenHash: string; + familyId: string; + userId: string; + deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined } | undefined; + createdAt: string; + absoluteExpiresAt: string; + ttlSeconds: number; + }): Promise<{ success: boolean; error?: string | undefined }> { + const { + oldTokenHash, + newTokenHash, + familyId, + userId, + deviceInfo, + createdAt, + absoluteExpiresAt, + ttlSeconds, + } = params; + + const tokenKey = `${this.REFRESH_TOKEN_PREFIX}${oldTokenHash}`; + const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`; + const newTokenKey = `${this.REFRESH_TOKEN_PREFIX}${newTokenHash}`; + const userSetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`; + + // Lua script for atomic token rotation + // This script: + // 1. Validates the old token exists and is valid + // 2. Validates the family exists and references the old token + // 3. Marks the old token as invalid + // 4. Updates the family with the new token + // 5. Creates the new token record + const luaScript = ` + local tokenKey = KEYS[1] + local familyKey = KEYS[2] + local newTokenKey = KEYS[3] + local userSetKey = KEYS[4] + + local oldTokenHash = ARGV[1] + local newTokenHash = ARGV[2] + local familyId = ARGV[3] + local userId = ARGV[4] + local ttl = tonumber(ARGV[5]) + local invalidTokenData = ARGV[6] + local newFamilyData = ARGV[7] + local newTokenData = ARGV[8] + + -- Get current token data + local tokenData = redis.call('GET', tokenKey) + if not tokenData then + return {0, 'TOKEN_NOT_FOUND'} + end + + -- Parse and validate token + local token = cjson.decode(tokenData) + if not token.valid then + return {0, 'TOKEN_ALREADY_INVALID'} + end + if token.familyId ~= familyId then + return {0, 'FAMILY_MISMATCH'} + end + if token.userId ~= userId then + return {0, 'USER_MISMATCH'} + end + + -- Get family data + local familyData = redis.call('GET', familyKey) + if familyData then + local family = cjson.decode(familyData) + if family.tokenHash ~= oldTokenHash then + return {0, 'TOKEN_NOT_CURRENT'} + end + end + + -- All validations passed - perform atomic rotation + -- Mark old token as invalid + redis.call('SETEX', tokenKey, ttl, invalidTokenData) + + -- Update family with new token + redis.call('SETEX', familyKey, ttl, newFamilyData) + + -- Create new token record + redis.call('SETEX', newTokenKey, ttl, newTokenData) + + -- Update user set + redis.call('SADD', userSetKey, familyId) + redis.call('EXPIRE', userSetKey, ttl) + + return {1, 'SUCCESS'} + `; + + try { + const invalidTokenData = JSON.stringify({ familyId, userId, valid: false }); + const newFamilyData = JSON.stringify({ + userId, + tokenHash: newTokenHash, + deviceId: deviceInfo?.deviceId, + userAgent: deviceInfo?.userAgent, + createdAt, + absoluteExpiresAt, + }); + const newTokenData = JSON.stringify({ familyId, userId, valid: true }); + + const result = (await this.redis.eval( + luaScript, + 4, // number of keys + tokenKey, + familyKey, + newTokenKey, + userSetKey, + oldTokenHash, + newTokenHash, + familyId, + userId, + ttlSeconds.toString(), + invalidTokenData, + newFamilyData, + newTokenData + )) as [number, string]; + + if (result[0] === 1) { + return { success: true }; + } + + this.logger.warn("Atomic token rotation failed", { + reason: result[1], + familyId: familyId.slice(0, 8), + tokenHash: oldTokenHash.slice(0, 8), + }); + + return { success: false, error: result[1] }; + } catch (error) { + this.logger.error("Atomic token rotation error", { + error: error instanceof Error ? error.message : String(error), + familyId: familyId.slice(0, 8), + }); + return { success: false, error: "REDIS_ERROR" }; + } + } + /** * Update token family with new token */ diff --git a/apps/bff/src/modules/auth/infra/token/token.service.ts b/apps/bff/src/modules/auth/infra/token/token.service.ts index 88a31a93..07e6b419 100644 --- a/apps/bff/src/modules/auth/infra/token/token.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token.service.ts @@ -10,8 +10,8 @@ import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; import type { JWTPayload } from "jose"; import type { AuthTokens } from "@customer-portal/domain/auth"; -import type { User, UserRole } from "@customer-portal/domain/customer"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import type { UserAuth, UserRole } from "@customer-portal/domain/customer"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { JoseJwtService } from "./jose-jwt.service.js"; import { TokenStorageService } from "./token-storage.service.js"; @@ -57,7 +57,7 @@ export class AuthTokenService { private readonly revocation: TokenRevocationService, @Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject(Logger) private readonly logger: Logger, - private readonly usersFacade: UsersFacade + private readonly usersService: UsersService ) { this.allowRedisFailOpen = this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true"; @@ -104,6 +104,20 @@ export class AuthTokenService { userAgent?: string; } ): Promise { + // Validate required user fields + if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) { + this.logger.error("Invalid user ID provided for token generation", { + userId: user.id, + }); + throw new Error("Invalid user ID for token generation"); + } + if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) { + this.logger.error("Invalid user email provided for token generation", { + userId: user.id, + }); + throw new Error("Invalid user email for token generation"); + } + this.checkServiceAvailability(); const accessTokenId = this.generateTokenId(); @@ -141,37 +155,34 @@ export class AuthTokenService { Date.now() + refreshExpirySeconds * 1000 ).toISOString(); - if (this.redis.status === "ready") { - try { - await this.storage.storeRefreshToken( - user.id, - refreshFamilyId, - refreshTokenHash, - deviceInfo, - refreshExpirySeconds, - refreshAbsoluteExpiresAt - ); - } catch (error) { - this.logger.error("Failed to store refresh token in Redis", { - error: error instanceof Error ? error.message : String(error), - userId: user.id, - }); - - if (this.requireRedisForTokens) { - throw new ServiceUnavailableException("Authentication service temporarily unavailable"); - } - } - } else { - if (this.requireRedisForTokens) { - this.logger.error("Redis required but not ready for token issuance", { - status: this.redis.status, - }); - throw new ServiceUnavailableException("Authentication service temporarily unavailable"); - } - - this.logger.warn("Redis not ready for token issuance; issuing non-rotating tokens", { + // Store refresh token in Redis - this is required for secure token rotation + if (this.redis.status !== "ready") { + this.logger.error("Redis not ready for token issuance", { status: this.redis.status, + requireRedisForTokens: this.requireRedisForTokens, }); + // Always fail if Redis is unavailable - tokens without storage cannot be + // securely rotated or revoked, creating a security vulnerability + throw new ServiceUnavailableException("Authentication service temporarily unavailable"); + } + + try { + await this.storage.storeRefreshToken( + user.id, + refreshFamilyId, + refreshTokenHash, + deviceInfo, + refreshExpirySeconds, + refreshAbsoluteExpiresAt + ); + } catch (error) { + this.logger.error("Failed to store refresh token in Redis", { + error: error instanceof Error ? error.message : String(error), + userId: user.id, + }); + // Always fail on storage error - issuing tokens that can't be validated + // or rotated creates a security vulnerability + throw new ServiceUnavailableException("Authentication service temporarily unavailable"); } const accessExpiresAt = new Date( @@ -204,7 +215,7 @@ export class AuthTokenService { deviceId?: string | undefined; userAgent?: string | undefined; } - ): Promise<{ tokens: AuthTokens; user: User }> { + ): Promise<{ tokens: AuthTokens; user: UserAuth }> { if (!refreshToken) { throw new UnauthorizedException("Invalid refresh token"); } @@ -326,7 +337,7 @@ export class AuthTokenService { } // Get user info from database - const user = await this.usersFacade.findByIdInternal(payload.userId); + const user = await this.usersService.findByIdInternal(payload.userId); if (!user) { this.logger.warn("User not found during token refresh", { userId: payload.userId }); throw new UnauthorizedException("User not found"); @@ -334,14 +345,6 @@ export class AuthTokenService { const userProfile = mapPrismaUserToDomain(user); - // Mark current refresh token as invalid - await this.storage.markTokenInvalid( - refreshTokenHash, - tokenRecord.familyId, - tokenRecord.userId, - remainingSeconds - ); - // Generate new token pair const accessTokenId = this.generateTokenId(); const refreshTokenId = this.generateTokenId(); @@ -371,16 +374,30 @@ export class AuthTokenService { const refreshExpiresAt = absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString(); - // Update family with new token - await this.storage.updateFamily( + // Use atomic token rotation to prevent race conditions + // This ensures that concurrent refresh requests with the same token + // cannot both succeed - only one will rotate the token + const rotationResult = await this.storage.atomicTokenRotation({ + oldTokenHash: refreshTokenHash, + newTokenHash: newRefreshTokenHash, familyId, - user.id, - newRefreshTokenHash, + userId: user.id, deviceInfo, createdAt, - refreshExpiresAt, - remainingSeconds - ); + absoluteExpiresAt: refreshExpiresAt, + ttlSeconds: remainingSeconds, + }); + + if (!rotationResult.success) { + this.logger.warn("Atomic token rotation failed - possible concurrent refresh", { + error: rotationResult.error, + familyId: familyId.slice(0, 8), + tokenHash: refreshTokenHash.slice(0, 8), + }); + // Invalidate the entire family on rotation failure for security + await this.revocation.invalidateTokenFamily(familyId); + throw new UnauthorizedException("Invalid refresh token"); + } const accessExpiresAt = new Date( Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) @@ -399,14 +416,19 @@ export class AuthTokenService { user: userProfile, }; } catch (error) { - this.logger.error("Token refresh failed", { + // Rethrow known exception types to preserve error context + if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) { + throw error; + } + + this.logger.error("Token refresh failed with unexpected error", { error: error instanceof Error ? error.message : String(error), }); + // Check Redis status for appropriate error response if (this.redis.status !== "ready") { this.logger.error("Redis unavailable for token refresh - failing closed for security", { redisStatus: this.redis.status, - allowRedisFailOpen: this.allowRedisFailOpen, securityReason: "refresh_token_rotation_requires_redis", }); throw new ServiceUnavailableException("Token refresh temporarily unavailable"); 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 1b2d82bb..67f418ca 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 @@ -20,7 +20,7 @@ import { } from "@customer-portal/domain/get-started"; import { EmailService } from "@bff/infra/email/email.service.js"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; 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"; @@ -70,7 +70,7 @@ export class GetStartedWorkflowService { private readonly otpService: OtpService, private readonly sessionService: GetStartedSessionService, private readonly emailService: EmailService, - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly mappingsService: MappingsService, private readonly auditService: AuditService, private readonly salesforceAccountService: SalesforceAccountService, @@ -378,7 +378,7 @@ export class GetStartedWorkflowService { } // Check for existing portal user - const existingPortalUser = await this.usersFacade.findByEmailInternal(session.email); + const existingPortalUser = await this.usersService.findByEmailInternal(session.email); if (existingPortalUser) { throw new ConflictException("An account already exists. Please log in."); } @@ -420,7 +420,7 @@ export class GetStartedWorkflowService { }); // Fetch fresh user and generate tokens - const freshUser = await this.usersFacade.findByIdInternal(userId); + const freshUser = await this.usersService.findByIdInternal(userId); if (!freshUser) { throw new Error("Failed to load created user"); } @@ -518,7 +518,7 @@ export class GetStartedWorkflowService { lockKey, async () => { // Check for existing Portal user - const existingPortalUser = await this.usersFacade.findByEmailInternal(normalizedEmail); + const existingPortalUser = await this.usersService.findByEmailInternal(normalizedEmail); if (existingPortalUser) { return { success: false, @@ -609,7 +609,7 @@ export class GetStartedWorkflowService { }); // Fetch fresh user and generate tokens - const freshUser = await this.usersFacade.findByIdInternal(userId); + const freshUser = await this.usersService.findByIdInternal(userId); if (!freshUser) { throw new Error("Failed to load created user"); } @@ -742,7 +742,7 @@ export class GetStartedWorkflowService { email: string ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { // Check Portal user first - const portalUser = await this.usersFacade.findByEmailInternal(email); + const portalUser = await this.usersService.findByEmailInternal(email); if (portalUser) { const hasMapping = await this.mappingsService.hasMapping(portalUser.id); if (hasMapping) { diff --git a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts index 2ed97437..b5ea51cf 100644 --- a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts @@ -9,7 +9,7 @@ import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import * as argon2 from "argon2"; import type { Request } from "express"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { EmailService } from "@bff/infra/email/email.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -27,7 +27,7 @@ import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; @Injectable() export class PasswordWorkflowService { constructor( - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly auditService: AuditService, private readonly configService: ConfigService, private readonly emailService: EmailService, @@ -38,7 +38,7 @@ export class PasswordWorkflowService { ) {} async checkPasswordNeeded(email: string) { - const user = await this.usersFacade.findByEmailInternal(email); + const user = await this.usersService.findByEmailInternal(email); if (!user) { return { needsPasswordSet: false, userExists: false }; } @@ -51,7 +51,7 @@ export class PasswordWorkflowService { } async setPassword(email: string, password: string) { - const user = await this.usersFacade.findByEmailInternal(email); + const user = await this.usersService.findByEmailInternal(email); if (!user) { throw new UnauthorizedException("User not found"); } @@ -65,7 +65,7 @@ export class PasswordWorkflowService { return withErrorHandling( async () => { try { - await this.usersFacade.update(user.id, { passwordHash }); + await this.usersService.update(user.id, { passwordHash }); } catch (error) { const message = extractErrorMessage(error); // Avoid surfacing downstream WHMCS mapping lookups as system errors during setup @@ -84,7 +84,7 @@ export class PasswordWorkflowService { throw error; } } - const prismaUser = await this.usersFacade.findByIdInternal(user.id); + const prismaUser = await this.usersService.findByIdInternal(user.id); if (!prismaUser) { throw new Error("Failed to load user after password setup"); } @@ -111,7 +111,7 @@ export class PasswordWorkflowService { if (request) { await this.authRateLimitService.consumePasswordReset(request); } - const user = await this.usersFacade.findByEmailInternal(email); + const user = await this.usersService.findByEmailInternal(email); if (!user) { // Don't reveal whether user exists return; @@ -151,13 +151,13 @@ export class PasswordWorkflowService { return withErrorHandling( async () => { - const prismaUser = await this.usersFacade.findByIdInternal(userId); + const prismaUser = await this.usersService.findByIdInternal(userId); if (!prismaUser) throw new BadRequestException("Invalid token"); const passwordHash = await argon2.hash(newPassword); - await this.usersFacade.update(prismaUser.id, { passwordHash }); - const freshUser = await this.usersFacade.findByIdInternal(prismaUser.id); + await this.usersService.update(prismaUser.id, { passwordHash }); + const freshUser = await this.usersService.findByIdInternal(prismaUser.id); if (!freshUser) { throw new Error("Failed to load user after password reset"); } @@ -178,7 +178,7 @@ export class PasswordWorkflowService { data: ChangePasswordRequest, request?: Request ): Promise { - const user = await this.usersFacade.findByIdInternal(userId); + const user = await this.usersService.findByIdInternal(userId); if (!user) { throw new UnauthorizedException("User not found"); @@ -211,8 +211,8 @@ export class PasswordWorkflowService { return withErrorHandling( async () => { - await this.usersFacade.update(user.id, { passwordHash }); - const prismaUser = await this.usersFacade.findByIdInternal(user.id); + await this.usersService.update(user.id, { passwordHash }); + const prismaUser = await this.usersService.findByIdInternal(user.id); if (!prismaUser) { throw new Error("Failed to load user after password change"); } diff --git a/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts index 19b63549..21ff3cb6 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts @@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino"; import * as argon2 from "argon2"; import type { Request } from "express"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; import { AuthTokenService } from "../token/token.service.js"; @@ -40,7 +40,7 @@ import { @Injectable() export class SignupWorkflowService { constructor( - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly mappingsService: MappingsService, private readonly salesforceService: SalesforceFacade, private readonly auditService: AuditService, @@ -85,7 +85,7 @@ export class SignupWorkflowService { } = signupData; // Check for existing portal user - const existingUser = await this.usersFacade.findByEmailInternal(email); + const existingUser = await this.usersService.findByEmailInternal(email); if (existingUser) { const mapped = await this.mappingsService.hasMapping(existingUser.id); const message = mapped @@ -159,7 +159,7 @@ export class SignupWorkflowService { }); // Step 6: Fetch fresh user and generate tokens - const freshUser = await this.usersFacade.findByIdInternal(userId); + const freshUser = await this.usersService.findByIdInternal(userId); if (!freshUser) { throw new Error("Failed to load created user"); } diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts index d4420605..fc4d673c 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts @@ -5,7 +5,7 @@ import { Injectable, Inject, BadRequestException, ConflictException } from "@nes import { Logger } from "nestjs-pino"; import type { Request } from "express"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; @@ -17,7 +17,7 @@ import type { SignupPreflightResult } from "./signup.types.js"; @Injectable() export class SignupValidationService { constructor( - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly mappingsService: MappingsService, private readonly discoveryService: WhmcsAccountDiscoveryService, private readonly salesforceAccountService: SalesforceAccountService, @@ -142,7 +142,7 @@ export class SignupValidationService { needsPasswordSet?: boolean; }> { const normalizedEmail = email.toLowerCase().trim(); - const existingUser = await this.usersFacade.findByEmailInternal(normalizedEmail); + const existingUser = await this.usersService.findByEmailInternal(normalizedEmail); if (!existingUser) { return { exists: false }; @@ -175,7 +175,7 @@ export class SignupValidationService { }; // Check portal user - const portalUserAuth = await this.usersFacade.findByEmailInternal(normalizedEmail); + const portalUserAuth = await this.usersService.findByEmailInternal(normalizedEmail); if (portalUserAuth) { result.portal.userExists = true; const mapped = await this.mappingsService.hasMapping(portalUserAuth.id); diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts index d9d3b69a..b28b03d8 100644 --- a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts @@ -6,7 +6,7 @@ import { UnauthorizedException, } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; @@ -24,7 +24,7 @@ import { @Injectable() export class WhmcsLinkWorkflowService { constructor( - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly mappingsService: MappingsService, private readonly whmcsClientService: WhmcsClientService, private readonly discoveryService: WhmcsAccountDiscoveryService, @@ -33,7 +33,7 @@ export class WhmcsLinkWorkflowService { ) {} async linkWhmcsUser(email: string, password: string) { - const existingUser = await this.usersFacade.findByEmailInternal(email); + const existingUser = await this.usersService.findByEmailInternal(email); if (existingUser) { if (!existingUser.passwordHash) { this.logger.log("User exists but has no password - allowing password setup to continue", { @@ -93,7 +93,7 @@ export class WhmcsLinkWorkflowService { throw new BadRequestException("Salesforce account not found. Please contact support."); } - const createdUser = await this.usersFacade.create( + const createdUser = await this.usersService.create( { email, passwordHash: null, @@ -108,7 +108,7 @@ export class WhmcsLinkWorkflowService { sfAccountId: sfAccount.id, }); - const prismaUser = await this.usersFacade.findByIdInternal(createdUser.id); + const prismaUser = await this.usersService.findByIdInternal(createdUser.id); if (!prismaUser) { throw new Error("Failed to load newly linked user"); } 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 1fd9497b..f1af04a5 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -11,7 +11,7 @@ import { } from "@nestjs/common"; import type { Request, Response } from "express"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; -import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js"; +import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js"; import { LocalAuthGuard } from "./guards/local-auth.guard.js"; import { FailedLoginThrottleGuard, @@ -74,7 +74,7 @@ class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededRes @Controller("auth") export class AuthController { constructor( - private authFacade: AuthFacade, + private authOrchestrator: AuthOrchestrator, private readonly jwtService: JoseJwtService ) {} @@ -85,13 +85,13 @@ export class AuthController { @Public() @Get("health-check") async healthCheck() { - return this.authFacade.healthCheck(); + return this.authOrchestrator.healthCheck(); } @Public() @Post("account-status") async accountStatus(@Body() body: AccountStatusRequestDto) { - return this.authFacade.getAccountStatus(body.email); + return this.authOrchestrator.getAccountStatus(body.email); } @Public() @@ -103,7 +103,7 @@ export class AuthController { @Req() req: Request, @Res({ passthrough: true }) res: Response ) { - const result = await this.authFacade.signup(signupData, req); + const result = await this.authOrchestrator.signup(signupData, req); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(result.tokens) }; } @@ -116,7 +116,7 @@ export class AuthController { @Req() req: RequestWithUser & RequestWithRateLimit, @Res({ passthrough: true }) res: Response ) { - const result = await this.authFacade.login(req.user, req); + const result = await this.authOrchestrator.login(req.user, req); setAuthCookies(res, result.tokens); this.applyAuthRateLimitHeaders(req, res); return { user: result.user, session: buildSessionInfo(result.tokens) }; @@ -138,7 +138,7 @@ export class AuthController { } } - await this.authFacade.logout(userId, token, req as Request); + await this.authOrchestrator.logout(userId, token, req as Request); // Always clear cookies, even if session expired clearAuthCookies(res); @@ -157,7 +157,7 @@ export class AuthController { const refreshToken = body.refreshToken ?? req.cookies?.["refresh_token"]; const rawUserAgent = req.headers["user-agent"]; const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; - const result = await this.authFacade.refreshTokens(refreshToken, { + const result = await this.authOrchestrator.refreshTokens(refreshToken, { deviceId: body.deviceId ?? undefined, userAgent: userAgent ?? undefined, }); @@ -171,7 +171,7 @@ export class AuthController { @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto }) async migrateAccount(@Body() linkData: LinkWhmcsRequestDto) { - const result = await this.authFacade.linkWhmcsUser(linkData); + const result = await this.authOrchestrator.linkWhmcsUser(linkData); return result; } @@ -184,7 +184,7 @@ export class AuthController { @Req() _req: Request, @Res({ passthrough: true }) res: Response ) { - const result = await this.authFacade.setPassword(setPasswordData); + const result = await this.authOrchestrator.setPassword(setPasswordData); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(result.tokens) }; } @@ -198,7 +198,7 @@ export class AuthController { type: CheckPasswordNeededResponseDto, }) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) { - const response = await this.authFacade.checkPasswordNeeded(data.email); + const response = await this.authOrchestrator.checkPasswordNeeded(data.email); return response; } @@ -207,7 +207,7 @@ export class AuthController { @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) { - await this.authFacade.requestPasswordReset(body.email, req); + await this.authOrchestrator.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; } @@ -220,7 +220,7 @@ export class AuthController { @Body() body: ResetPasswordRequestDto, @Res({ passthrough: true }) res: Response ) { - await this.authFacade.resetPassword(body.token, body.password); + await this.authOrchestrator.resetPassword(body.token, body.password); // Clear auth cookies after password reset to force re-login clearAuthCookies(res); @@ -235,7 +235,7 @@ export class AuthController { @Body() body: ChangePasswordRequestDto, @Res({ passthrough: true }) res: Response ) { - const result = await this.authFacade.changePassword(req.user.id, body, req); + const result = await this.authOrchestrator.changePassword(req.user.id, body, req); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(result.tokens) }; } @@ -261,6 +261,6 @@ export class AuthController { @Body() body: SsoLinkRequestDto ) { const destination = body?.destination; - return this.authFacade.createSsoLink(req.user.id, destination); + return this.authOrchestrator.createSsoLink(req.user.id, destination); } } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index 21afac6c..54bcee57 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -12,7 +12,7 @@ import { } from "../../../decorators/public.decorator.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import type { UserAuth } from "@customer-portal/domain/customer"; import { extractAccessTokenFromRequest } from "../../../utils/token-from-request.util.js"; @@ -36,7 +36,7 @@ export class GlobalAuthGuard implements CanActivate { private reflector: Reflector, private readonly tokenBlacklistService: TokenBlacklistService, private readonly jwtService: JoseJwtService, - private readonly usersFacade: UsersFacade + private readonly usersService: UsersService ) {} async canActivate(context: ExecutionContext): Promise { @@ -199,7 +199,7 @@ export class GlobalAuthGuard implements CanActivate { throw new UnauthorizedException("Token has been revoked"); } - const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); + const prismaUser = await this.usersService.findByIdInternal(payload.sub); if (!prismaUser) { throw new UnauthorizedException("User not found"); } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts index 265c1cce..b99eb8b7 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts @@ -1,11 +1,11 @@ import { Injectable, UnauthorizedException } from "@nestjs/common"; import type { CanActivate, ExecutionContext } from "@nestjs/common"; import type { Request } from "express"; -import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js"; +import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js"; @Injectable() export class LocalAuthGuard implements CanActivate { - constructor(private readonly authFacade: AuthFacade) {} + constructor(private readonly authOrchestrator: AuthOrchestrator) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); @@ -18,7 +18,7 @@ export class LocalAuthGuard implements CanActivate { throw new UnauthorizedException("Invalid credentials"); } - const user = await this.authFacade.validateUser(email, password, request); + const user = await this.authOrchestrator.validateUser(email, password, request); if (!user) { throw new UnauthorizedException("Invalid credentials"); } diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.aggregator.ts similarity index 96% rename from apps/bff/src/modules/me-status/me-status.service.ts rename to apps/bff/src/modules/me-status/me-status.aggregator.ts index 7bc40761..bee614b0 100644 --- a/apps/bff/src/modules/me-status/me-status.service.ts +++ b/apps/bff/src/modules/me-status/me-status.aggregator.ts @@ -1,6 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js"; import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; @@ -32,7 +32,7 @@ import type { OrderSummary } from "@customer-portal/domain/orders"; @Injectable() export class MeStatusAggregator { constructor( - private readonly users: UsersFacade, + private readonly users: UsersService, private readonly orders: OrderOrchestrator, private readonly internetEligibility: InternetEligibilityService, private readonly residenceCards: ResidenceCardService, @@ -231,7 +231,7 @@ export class MeStatusAggregator { type: "internet_eligibility", title: "Internet availability review", description: - "We’re verifying if our service is available at your residence. We’ll notify you when review is complete.", + "We're verifying if our service is available at your residence. We'll notify you when review is complete.", actionLabel: "View status", detailHref: "/account/services/internet", tone: "info", @@ -245,7 +245,7 @@ export class MeStatusAggregator { priority: 4, type: "id_verification", title: "ID verification requires attention", - description: "We couldn’t verify your ID. Please review the feedback and resubmit.", + description: "We couldn't verify your ID. Please review the feedback and resubmit.", actionLabel: "Resubmit", detailHref: "/account/settings/verification", tone: "warning", diff --git a/apps/bff/src/modules/me-status/me-status.controller.ts b/apps/bff/src/modules/me-status/me-status.controller.ts index 2ed963eb..f3f7c120 100644 --- a/apps/bff/src/modules/me-status/me-status.controller.ts +++ b/apps/bff/src/modules/me-status/me-status.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Req, UseGuards } from "@nestjs/common"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; -import { MeStatusAggregator } from "./me-status.service.js"; +import { MeStatusAggregator } from "./me-status.aggregator.js"; import type { MeStatus } from "@customer-portal/domain/dashboard"; @Controller("me") diff --git a/apps/bff/src/modules/me-status/me-status.module.ts b/apps/bff/src/modules/me-status/me-status.module.ts index ead92567..eb494a44 100644 --- a/apps/bff/src/modules/me-status/me-status.module.ts +++ b/apps/bff/src/modules/me-status/me-status.module.ts @@ -1,6 +1,6 @@ import { Module } from "@nestjs/common"; import { MeStatusController } from "./me-status.controller.js"; -import { MeStatusAggregator } from "./me-status.service.js"; +import { MeStatusAggregator } from "./me-status.aggregator.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; import { OrdersModule } from "@bff/modules/orders/orders.module.js"; import { ServicesModule } from "@bff/modules/services/services.module.js"; diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index af97ad0f..4965f484 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -1,5 +1,4 @@ -import { Body, Controller, Get, Param, Post, Request, UseGuards, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; +import { Body, Controller, Get, Param, Post, Request, UseGuards } from "@nestjs/common"; import { createZodDto, ZodResponse } from "nestjs-zod"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { CheckoutService } from "../services/checkout.service.js"; @@ -26,8 +25,7 @@ class ValidateCartResponseDto extends createZodDto(checkoutValidateCartDataSchem export class CheckoutController { constructor( private readonly checkoutService: CheckoutService, - private readonly checkoutSessions: CheckoutSessionService, - @Inject(Logger) private readonly logger: Logger + private readonly checkoutSessions: CheckoutSessionService ) {} @Post("cart") @@ -38,28 +36,12 @@ export class CheckoutController { type: CheckoutBuildCartResponseDto, }) async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) { - this.logger.log("Building checkout cart", { - userId: req.user?.id, - orderType: body.orderType, - }); - - try { - const cart = await this.checkoutService.buildCart( - body.orderType, - body.selections, - body.configuration, - req.user?.id - ); - - return cart; - } catch (error) { - this.logger.error("Failed to build checkout cart", { - error: error instanceof Error ? error.message : String(error), - userId: req.user?.id, - orderType: body.orderType, - }); - throw error; - } + return this.checkoutService.buildCart( + body.orderType, + body.selections, + body.configuration, + req.user?.id + ); } /** @@ -74,38 +56,24 @@ export class CheckoutController { type: CheckoutSessionResponseDto, }) async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) { - this.logger.log("Creating checkout session", { - userId: req.user?.id, + const cart = await this.checkoutService.buildCart( + body.orderType, + body.selections, + body.configuration, + req.user?.id + ); + + const session = await this.checkoutSessions.createSession(body, cart); + + return { + sessionId: session.sessionId, + expiresAt: session.expiresAt, orderType: body.orderType, - }); - - try { - const cart = await this.checkoutService.buildCart( - body.orderType, - body.selections, - body.configuration, - req.user?.id - ); - - const session = await this.checkoutSessions.createSession(body, cart); - - return { - sessionId: session.sessionId, - expiresAt: session.expiresAt, - orderType: body.orderType, - cart: { - items: cart.items, - totals: cart.totals, - }, - }; - } catch (error) { - this.logger.error("Failed to create checkout session", { - error: error instanceof Error ? error.message : String(error), - userId: req.user?.id, - orderType: body.orderType, - }); - throw error; - } + cart: { + items: cart.items, + totals: cart.totals, + }, + }; } @Get("session/:sessionId") @@ -135,20 +103,7 @@ export class CheckoutController { type: ValidateCartResponseDto, }) validateCart(@Body() cart: CheckoutCartDto) { - this.logger.log("Validating checkout cart", { - itemCount: cart.items.length, - }); - - try { - this.checkoutService.validateCart(cart); - - return { valid: true }; - } catch (error) { - this.logger.error("Checkout cart validation failed", { - error: error instanceof Error ? error.message : String(error), - itemCount: cart.items.length, - }); - throw error; - } + this.checkoutService.validateCart(cart); + return { valid: true }; } } diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index c6299832..b76c6fb2 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -55,29 +55,7 @@ export class OrdersController { @RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute @ZodResponse({ status: 201, description: "Create order", type: CreateOrderResponseDto }) async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequestDto) { - this.logger.log( - { - userId: req.user?.id, - orderType: body.orderType, - skuCount: body.skus?.length || 0, - }, - "Order creation request received" - ); - - try { - const result = await this.orderOrchestrator.createOrder(req.user.id, body); - return result; - } catch (error) { - this.logger.error( - { - error: error instanceof Error ? error.message : String(error), - userId: req.user?.id, - orderType: body.orderType, - }, - "Order creation failed" - ); - throw error; - } + return this.orderOrchestrator.createOrder(req.user.id, body); } @Post("from-checkout-session") diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 7812b2a5..fa3d45f7 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -28,6 +28,7 @@ import { OrderFulfillmentValidator } from "./services/order-fulfillment-validato import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service.js"; import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service.js"; import { SimFulfillmentService } from "./services/sim-fulfillment.service.js"; +import { FulfillmentSideEffectsService } from "./services/fulfillment-side-effects.service.js"; import { ProvisioningQueueService } from "./queue/provisioning.queue.js"; import { ProvisioningProcessor } from "./queue/provisioning.processor.js"; import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/config/salesforce-order-field-config.module.js"; @@ -66,6 +67,7 @@ import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/c OrderFulfillmentOrchestrator, OrderFulfillmentErrorService, SimFulfillmentService, + FulfillmentSideEffectsService, // Async provisioning queue ProvisioningQueueService, ProvisioningProcessor, diff --git a/apps/bff/src/modules/orders/services/fulfillment-side-effects.service.ts b/apps/bff/src/modules/orders/services/fulfillment-side-effects.service.ts new file mode 100644 index 00000000..079861d3 --- /dev/null +++ b/apps/bff/src/modules/orders/services/fulfillment-side-effects.service.ts @@ -0,0 +1,222 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { OrderEventsService } from "./order-events.service.js"; +import { OrdersCacheService } from "./orders-cache.service.js"; +import type { OrderUpdateEventPayload } from "@customer-portal/domain/orders"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; +import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; + +/** + * Fulfillment Side Effects Service + * + * Handles all non-critical side effects during order fulfillment: + * - Event publishing (SSE/WebSocket notifications) + * - In-app notification creation + * - Cache invalidation + * + * All methods are safe and do not throw - they log warnings on failure. + * This keeps the main orchestration flow clean and focused on critical operations. + */ +@Injectable() +export class FulfillmentSideEffectsService { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly orderEvents: OrderEventsService, + private readonly ordersCache: OrdersCacheService, + private readonly mappingsService: MappingsService, + private readonly notifications: NotificationService + ) {} + + /** + * Publish a status update event for real-time UI updates + */ + publishStatusUpdate(sfOrderId: string, event: Omit): void { + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + ...event, + }); + } + + /** + * Publish event for order starting activation + */ + publishActivating(sfOrderId: string): void { + this.publishStatusUpdate(sfOrderId, { + status: "Processing", + activationStatus: "Activating", + stage: "in_progress", + source: "fulfillment", + timestamp: new Date().toISOString(), + }); + } + + /** + * Publish event for order completed successfully + */ + publishCompleted(sfOrderId: string, whmcsOrderId?: number, whmcsServiceIds?: number[]): void { + this.publishStatusUpdate(sfOrderId, { + status: "Completed", + activationStatus: "Activated", + stage: "completed", + source: "fulfillment", + timestamp: new Date().toISOString(), + payload: { + ...(whmcsOrderId !== undefined && { whmcsOrderId }), + ...(whmcsServiceIds !== undefined && { whmcsServiceIds }), + }, + }); + } + + /** + * Publish event for order already provisioned (idempotent case) + */ + publishAlreadyProvisioned(sfOrderId: string, whmcsOrderId?: string): void { + this.publishStatusUpdate(sfOrderId, { + status: "Completed", + activationStatus: "Activated", + stage: "completed", + source: "fulfillment", + message: "Order already provisioned", + timestamp: new Date().toISOString(), + payload: { + ...(whmcsOrderId !== undefined && { whmcsOrderId }), + }, + }); + } + + /** + * Publish event for order fulfillment failed + */ + publishFailed(sfOrderId: string, reason: string): void { + this.publishStatusUpdate(sfOrderId, { + status: "Pending Review", + activationStatus: "Failed", + stage: "failed", + source: "fulfillment", + timestamp: new Date().toISOString(), + reason, + }); + } + + /** + * Create in-app notification for order approved (started processing) + */ + async notifyOrderApproved(sfOrderId: string, accountId?: unknown): Promise { + await this.safeCreateNotification({ + type: NOTIFICATION_TYPE.ORDER_APPROVED, + sfOrderId, + accountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); + } + + /** + * Create in-app notification for order activated (completed) + */ + async notifyOrderActivated(sfOrderId: string, accountId?: unknown): Promise { + await this.safeCreateNotification({ + type: NOTIFICATION_TYPE.ORDER_ACTIVATED, + sfOrderId, + accountId, + actionUrl: "/account/services", + }); + } + + /** + * Create in-app notification for order failed + */ + async notifyOrderFailed(sfOrderId: string, accountId?: unknown): Promise { + await this.safeCreateNotification({ + type: NOTIFICATION_TYPE.ORDER_FAILED, + sfOrderId, + accountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); + } + + /** + * Invalidate caches for order data + */ + async invalidateCaches(sfOrderId: string, accountId?: string | null): Promise { + const tasks: Array> = [this.ordersCache.invalidateOrder(sfOrderId)]; + + if (accountId) { + tasks.push(this.ordersCache.invalidateAccountOrders(accountId)); + } + + try { + await Promise.all(tasks); + } catch (error) { + this.logger.warn("Failed to invalidate order caches", { + sfOrderId, + accountId: accountId ?? undefined, + error: extractErrorMessage(error), + }); + } + } + + /** + * Execute all completion side effects + */ + async onFulfillmentComplete( + sfOrderId: string, + accountId?: string | null, + whmcsOrderId?: number, + whmcsServiceIds?: number[] + ): Promise { + this.publishCompleted(sfOrderId, whmcsOrderId, whmcsServiceIds); + await this.notifyOrderActivated(sfOrderId, accountId); + await this.invalidateCaches(sfOrderId, accountId); + } + + /** + * Execute all failure side effects + */ + async onFulfillmentFailed( + sfOrderId: string, + accountId?: string | null, + reason: string = "Fulfillment failed" + ): Promise { + this.publishFailed(sfOrderId, reason); + await this.notifyOrderFailed(sfOrderId, accountId); + await this.invalidateCaches(sfOrderId, accountId); + } + + /** + * Safe notification creation - never throws + */ + private async safeCreateNotification(params: { + type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; + sfOrderId: string; + accountId?: unknown; + actionUrl: string; + }): Promise { + try { + const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId); + if (!sfAccountId.success) return; + + const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data); + if (!mapping?.userId) return; + + await this.notifications.createNotification({ + userId: mapping.userId, + type: params.type, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: params.sfOrderId, + actionUrl: params.actionUrl, + }); + } catch (error) { + this.logger.warn( + { + sfOrderId: params.sfOrderId, + type: params.type, + err: error instanceof Error ? error.message : String(error), + }, + "Failed to create in-app order notification" + ); + } + } +} diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index ef5fee4a..06676b32 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { OrderBusinessValidation, UserMapping } from "@customer-portal/domain/orders"; -import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; import { SalesforceOrderFieldMapService } from "@bff/integrations/salesforce/config/order-field-map.service.js"; function assignIfString(target: Record, key: string, value: unknown): void { @@ -17,7 +17,7 @@ function assignIfString(target: Record, key: string, value: unk export class OrderBuilder { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly usersFacade: UsersFacade, + private readonly usersService: UsersService, private readonly orderFieldMap: SalesforceOrderFieldMapService ) {} @@ -122,7 +122,7 @@ export class OrderBuilder { fieldNames: SalesforceOrderFieldMapService["fields"]["order"] ): Promise { try { - const profile = await this.usersFacade.getProfile(userId); + const profile = await this.usersService.getProfile(userId); const address = profile.address; const orderAddress = (body.configurations as Record)?.["address"] as | Record diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 8f08e747..b7b10f36 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -8,18 +8,13 @@ import { OrderOrchestrator } from "./order-orchestrator.service.js"; import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service.js"; import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js"; import { SimFulfillmentService } from "./sim-fulfillment.service.js"; +import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js"; import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { OrderEventsService } from "./order-events.service.js"; -import { OrdersCacheService } from "./orders-cache.service.js"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import type { OrderDetails } from "@customer-portal/domain/orders"; import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; -import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; -import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; import { OrderValidationException, FulfillmentException, @@ -62,10 +57,7 @@ export class OrderFulfillmentOrchestrator { private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, private readonly simFulfillmentService: SimFulfillmentService, private readonly distributedTransactionService: DistributedTransactionService, - private readonly orderEvents: OrderEventsService, - private readonly ordersCache: OrdersCacheService, - private readonly mappingsService: MappingsService, - private readonly notifications: NotificationService + private readonly sideEffects: FulfillmentSideEffectsService ) {} /** @@ -114,19 +106,11 @@ export class OrderFulfillmentOrchestrator { if (context.validation.isAlreadyProvisioned) { this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Completed", - activationStatus: "Activated", - stage: "completed", - source: "fulfillment", - message: "Order already provisioned", - timestamp: new Date().toISOString(), - payload: { - whmcsOrderId: context.validation.whmcsOrderId, - }, - }); - await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); + this.sideEffects.publishAlreadyProvisioned(sfOrderId, context.validation.whmcsOrderId); + await this.sideEffects.invalidateCaches( + sfOrderId, + context.validation?.sfOrder?.AccountId + ); return context; } } catch (error) { @@ -172,20 +156,11 @@ export class OrderFulfillmentOrchestrator { Id: sfOrderId, Activation_Status__c: "Activating", }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Processing", - activationStatus: "Activating", - stage: "in_progress", - source: "fulfillment", - timestamp: new Date().toISOString(), - }); - await this.safeNotifyOrder({ - type: NOTIFICATION_TYPE.ORDER_APPROVED, + this.sideEffects.publishActivating(sfOrderId); + await this.sideEffects.notifyOrderApproved( sfOrderId, - accountId: context.validation?.sfOrder?.AccountId, - actionUrl: `/account/orders/${sfOrderId}`, - }); + context.validation?.sfOrder?.AccountId + ); return result; }), rollback: async () => { @@ -343,24 +318,15 @@ export class OrderFulfillmentOrchestrator { Activation_Status__c: "Activated", WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(), }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Completed", - activationStatus: "Activated", - stage: "completed", - source: "fulfillment", - timestamp: new Date().toISOString(), - payload: { - whmcsOrderId: whmcsCreateResult?.orderId, - whmcsServiceIds: whmcsCreateResult?.serviceIds, - }, - }); - await this.safeNotifyOrder({ - type: NOTIFICATION_TYPE.ORDER_ACTIVATED, + this.sideEffects.publishCompleted( sfOrderId, - accountId: context.validation?.sfOrder?.AccountId, - actionUrl: "/account/services", - }); + whmcsCreateResult?.orderId, + whmcsCreateResult?.serviceIds + ); + await this.sideEffects.notifyOrderActivated( + sfOrderId, + context.validation?.sfOrder?.AccountId + ); return result; }), rollback: async () => { @@ -457,26 +423,15 @@ export class OrderFulfillmentOrchestrator { duration: fulfillmentResult.duration, }); - await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); + await this.sideEffects.invalidateCaches(sfOrderId, context.validation?.sfOrder?.AccountId); return context; } catch (error) { - await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); await this.handleFulfillmentError(context, error as Error); - await this.safeNotifyOrder({ - type: NOTIFICATION_TYPE.ORDER_FAILED, + await this.sideEffects.onFulfillmentFailed( sfOrderId, - accountId: context.validation?.sfOrder?.AccountId, - actionUrl: `/account/orders/${sfOrderId}`, - }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Pending Review", - activationStatus: "Failed", - stage: "failed", - source: "fulfillment", - timestamp: new Date().toISOString(), - reason: error instanceof Error ? error.message : String(error), - }); + context.validation?.sfOrder?.AccountId, + error instanceof Error ? error.message : String(error) + ); throw error; } } @@ -510,55 +465,6 @@ export class OrderFulfillmentOrchestrator { return {}; } - private async invalidateOrderCaches(orderId: string, accountId?: string | null): Promise { - const tasks: Array> = [this.ordersCache.invalidateOrder(orderId)]; - if (accountId) { - tasks.push(this.ordersCache.invalidateAccountOrders(accountId)); - } - - try { - await Promise.all(tasks); - } catch (error) { - this.logger.warn("Failed to invalidate order caches", { - orderId, - accountId: accountId ?? undefined, - error: extractErrorMessage(error), - }); - } - } - - private async safeNotifyOrder(params: { - type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; - sfOrderId: string; - accountId?: unknown; - actionUrl: string; - }): Promise { - try { - const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId); - if (!sfAccountId.success) return; - - const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data); - if (!mapping?.userId) return; - - await this.notifications.createNotification({ - userId: mapping.userId, - type: params.type, - source: NOTIFICATION_SOURCE.SYSTEM, - sourceId: params.sfOrderId, - actionUrl: params.actionUrl, - }); - } catch (error) { - this.logger.warn( - { - sfOrderId: params.sfOrderId, - type: params.type, - err: error instanceof Error ? error.message : String(error), - }, - "Failed to create in-app order notification" - ); - } - } - /** * Handle fulfillment errors and update Salesforce */ diff --git a/apps/bff/src/modules/orders/validators/base-validator.interface.ts b/apps/bff/src/modules/orders/validators/base-validator.interface.ts new file mode 100644 index 00000000..06c3ffdb --- /dev/null +++ b/apps/bff/src/modules/orders/validators/base-validator.interface.ts @@ -0,0 +1,92 @@ +/** + * Base validation types for order validators + */ + +/** + * Validation error details + */ +export interface ValidationError { + /** Error code for programmatic handling */ + code: string; + /** Human-readable error message */ + message: string; + /** Optional field that caused the error */ + field?: string; +} + +/** + * Result of a validation operation + */ +export interface ValidationResult { + /** Whether validation passed */ + success: boolean; + /** Data returned on success (optional) */ + data?: T; + /** Errors on failure */ + errors?: ValidationError[]; +} + +/** + * Create a successful validation result + */ +export function validationSuccess(): ValidationResult; +export function validationSuccess(data: T): ValidationResult; +export function validationSuccess(data?: T): ValidationResult { + if (data === undefined) { + return { success: true } as ValidationResult; + } + return { success: true, data }; +} + +/** + * Create a failed validation result + */ +export function validationFailure(errors: ValidationError[]): ValidationResult { + return { success: false, errors }; +} + +/** + * Create a single validation error + */ +export function createValidationError( + code: string, + message: string, + field?: string +): ValidationError { + const error: ValidationError = { code, message }; + if (field !== undefined) { + error.field = field; + } + return error; +} + +/** + * Common validation error codes + */ +export const ValidationErrorCode = { + // User mapping errors + USER_MAPPING_NOT_FOUND: "USER_MAPPING_NOT_FOUND", + WHMCS_CLIENT_NOT_LINKED: "WHMCS_CLIENT_NOT_LINKED", + + // Payment errors + NO_PAYMENT_METHOD: "NO_PAYMENT_METHOD", + + // SKU errors + INVALID_SKU: "INVALID_SKU", + MISSING_REQUIRED_SKU: "MISSING_REQUIRED_SKU", + + // SIM order errors + RESIDENCE_CARD_NOT_SUBMITTED: "RESIDENCE_CARD_NOT_SUBMITTED", + RESIDENCE_CARD_REJECTED: "RESIDENCE_CARD_REJECTED", + SIM_ACTIVATION_FEE_REQUIRED: "SIM_ACTIVATION_FEE_REQUIRED", + + // Internet order errors + INTERNET_ELIGIBILITY_NOT_REQUESTED: "INTERNET_ELIGIBILITY_NOT_REQUESTED", + INTERNET_ELIGIBILITY_PENDING: "INTERNET_ELIGIBILITY_PENDING", + INTERNET_INELIGIBLE: "INTERNET_INELIGIBLE", + INTERNET_SERVICE_EXISTS: "INTERNET_SERVICE_EXISTS", + INTERNET_CHECK_FAILED: "INTERNET_CHECK_FAILED", + + // General errors + VALIDATION_ERROR: "VALIDATION_ERROR", +} as const; diff --git a/apps/bff/src/modules/orders/validators/index.ts b/apps/bff/src/modules/orders/validators/index.ts new file mode 100644 index 00000000..94bda173 --- /dev/null +++ b/apps/bff/src/modules/orders/validators/index.ts @@ -0,0 +1,15 @@ +// Base types and utilities +export { + ValidationErrorCode, + createValidationError, + validationFailure, + validationSuccess, + type ValidationError, + type ValidationResult, +} from "./base-validator.interface.js"; + +// Individual validators +export { UserMappingValidator, type UserMappingData } from "./user-mapping.validator.js"; +export { SkuValidator } from "./sku.validator.js"; +export { SimOrderValidator } from "./sim-order.validator.js"; +export { InternetOrderValidator } from "./internet-order.validator.js"; diff --git a/apps/bff/src/modules/orders/validators/internet-order.validator.ts b/apps/bff/src/modules/orders/validators/internet-order.validator.ts new file mode 100644 index 00000000..38adee14 --- /dev/null +++ b/apps/bff/src/modules/orders/validators/internet-order.validator.ts @@ -0,0 +1,169 @@ +import { Injectable, BadRequestException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; +import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { + ValidationErrorCode, + createValidationError, + validationFailure, + validationSuccess, + type ValidationResult, +} from "./base-validator.interface.js"; +import type * as Providers from "@customer-portal/domain/subscriptions/providers"; + +type WhmcsProduct = Providers.WhmcsProductRaw; + +/** + * Internet Order Validator + * + * Validates Internet-specific order requirements: + * - Eligibility status (must be approved) + * - No duplicate active Internet service + */ +@Injectable() +export class InternetOrderValidator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly internetEligibilityService: InternetEligibilityService, + private readonly whmcs: WhmcsConnectionFacade + ) {} + + /** + * Validate Internet order eligibility requirements + */ + async validateEligibility(userId: string): Promise { + const eligibility = await this.internetEligibilityService.getEligibilityDetailsForUser(userId); + + if (eligibility.status === "not_requested") { + return validationFailure([ + createValidationError( + ValidationErrorCode.INTERNET_ELIGIBILITY_NOT_REQUESTED, + "Internet eligibility review is required before ordering. Please request an eligibility review from the Internet services page and try again." + ), + ]); + } + + if (eligibility.status === "pending") { + return validationFailure([ + createValidationError( + ValidationErrorCode.INTERNET_ELIGIBILITY_PENDING, + "Internet eligibility review is still in progress. Please wait for review to complete and try again." + ), + ]); + } + + if (eligibility.status === "ineligible") { + return validationFailure([ + createValidationError( + ValidationErrorCode.INTERNET_INELIGIBLE, + "Internet service is not available for your address. Please contact support if you believe this is incorrect." + ), + ]); + } + + return validationSuccess(); + } + + /** + * Validate no duplicate active Internet service exists + * In development mode, logs warning but allows order + */ + async validateNoDuplication(userId: string, whmcsClientId: number): Promise { + const isDevelopment = process.env["NODE_ENV"] === "development"; + + try { + const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); + const productContainer = products.products?.product; + const existing = Array.isArray(productContainer) + ? productContainer + : productContainer + ? [productContainer] + : []; + + // Check for active Internet products + const activeInternetProducts = existing.filter((product: WhmcsProduct) => { + const groupName = (product.groupname || product.translated_groupname || "").toLowerCase(); + const status = (product.status || "").toLowerCase(); + return groupName.includes("internet") && status === "active"; + }); + + if (activeInternetProducts.length > 0) { + const message = "An active Internet service already exists for this account"; + + if (isDevelopment) { + this.logger.warn( + { + userId, + whmcsClientId, + activeInternetCount: activeInternetProducts.length, + environment: "development", + }, + `[DEV MODE] ${message} - allowing order to proceed in development` + ); + // In dev, just log warning and allow order + return validationSuccess(); + } + + // In production, block the order + this.logger.error( + { + userId, + whmcsClientId, + activeInternetCount: activeInternetProducts.length, + }, + message + ); + return validationFailure([ + createValidationError(ValidationErrorCode.INTERNET_SERVICE_EXISTS, message), + ]); + } + + return validationSuccess(); + } catch (error) { + const err = extractErrorMessage(error); + this.logger.error({ err, userId, whmcsClientId }, "Internet duplicate check failed"); + + if (isDevelopment) { + this.logger.warn( + { environment: "development" }, + "[DEV MODE] WHMCS check failed - allowing order to proceed in development" + ); + return validationSuccess(); + } + + return validationFailure([ + createValidationError( + ValidationErrorCode.INTERNET_CHECK_FAILED, + "Unable to verify existing Internet services. Please try again." + ), + ]); + } + } + + /** + * Validate all Internet order requirements + */ + async validate(userId: string, whmcsClientId: number): Promise { + // First check eligibility + const eligibilityResult = await this.validateEligibility(userId); + if (!eligibilityResult.success) { + return eligibilityResult; + } + + // Then check for duplicates + return this.validateNoDuplication(userId, whmcsClientId); + } + + /** + * Validate and throw BadRequestException on failure + */ + async validateOrThrow(userId: string, whmcsClientId: number): Promise { + const result = await this.validate(userId, whmcsClientId); + + if (!result.success) { + const error = result.errors?.[0]; + throw new BadRequestException(error?.message ?? "Internet order validation failed"); + } + } +} diff --git a/apps/bff/src/modules/orders/validators/sim-order.validator.ts b/apps/bff/src/modules/orders/validators/sim-order.validator.ts new file mode 100644 index 00000000..8aea04eb --- /dev/null +++ b/apps/bff/src/modules/orders/validators/sim-order.validator.ts @@ -0,0 +1,95 @@ +import { Injectable, BadRequestException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; +import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js"; +import { + ValidationErrorCode, + createValidationError, + validationFailure, + validationSuccess, + type ValidationResult, +} from "./base-validator.interface.js"; + +/** + * SIM Order Validator + * + * Validates SIM-specific order requirements: + * - Residence card verification status + * - Activation fee SKU presence + */ +@Injectable() +export class SimOrderValidator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly residenceCards: ResidenceCardService, + private readonly simCatalogService: SimServicesService + ) {} + + /** + * Validate SIM order requirements + */ + async validate(userId: string, skus: string[]): Promise { + // Check residence card verification status + const verification = await this.residenceCards.getStatusForUser(userId); + + if (verification.status === "not_submitted") { + return validationFailure([ + createValidationError( + ValidationErrorCode.RESIDENCE_CARD_NOT_SUBMITTED, + "Residence card submission required for SIM orders. Please upload your residence card and try again." + ), + ]); + } + + if (verification.status === "rejected") { + return validationFailure([ + createValidationError( + ValidationErrorCode.RESIDENCE_CARD_REJECTED, + "Your residence card submission was rejected. Please resubmit your residence card and try again." + ), + ]); + } + + // Check for activation fee SKU + const activationFees = await this.simCatalogService.getActivationFees(); + const activationSkus = new Set( + activationFees + .map(fee => fee.sku?.trim()) + .filter((sku): sku is string => Boolean(sku)) + .map(sku => sku.toUpperCase()) + ); + + const hasActivationSku = skus.some(sku => { + const normalized = sku?.trim().toUpperCase(); + if (!normalized) { + return false; + } + return activationSkus.has(normalized); + }); + + if (!hasActivationSku) { + this.logger.warn({ skus }, "SIM order missing activation SKU based on catalog metadata"); + return validationFailure([ + createValidationError( + ValidationErrorCode.SIM_ACTIVATION_FEE_REQUIRED, + "SIM orders require an activation fee", + "skus" + ), + ]); + } + + return validationSuccess(); + } + + /** + * Validate and throw BadRequestException on failure + */ + async validateOrThrow(userId: string, skus: string[]): Promise { + const result = await this.validate(userId, skus); + + if (!result.success) { + const error = result.errors?.[0]; + throw new BadRequestException(error?.message ?? "SIM order validation failed"); + } + } +} diff --git a/apps/bff/src/modules/orders/validators/sku.validator.ts b/apps/bff/src/modules/orders/validators/sku.validator.ts new file mode 100644 index 00000000..e5a5452e --- /dev/null +++ b/apps/bff/src/modules/orders/validators/sku.validator.ts @@ -0,0 +1,86 @@ +import { Injectable, BadRequestException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { + OrderPricebookService, + type PricebookProductMeta, +} from "../services/order-pricebook.service.js"; +import { + ValidationErrorCode, + createValidationError, + validationFailure, + validationSuccess, + type ValidationResult, +} from "./base-validator.interface.js"; + +/** + * SKU Validator + * + * Validates that product SKUs exist in the Salesforce pricebook. + */ +@Injectable() +export class SkuValidator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly pricebookService: OrderPricebookService + ) {} + + /** + * Validate SKUs exist in Salesforce pricebook + * Returns validation result with product metadata on success + */ + async validate( + skus: string[], + pricebookId: string + ): Promise>> { + const meta = await this.pricebookService.fetchProductMeta(pricebookId, skus); + const invalidSKUs: string[] = []; + + const normalizedSkus = skus + .map(sku => sku?.trim()) + .filter((sku): sku is string => Boolean(sku)) + .map(sku => ({ raw: sku, normalized: sku.toUpperCase() })); + + for (const sku of normalizedSkus) { + if (!meta.has(sku.normalized)) { + invalidSKUs.push(sku.raw); + } + } + + if (invalidSKUs.length > 0) { + this.logger.error({ invalidSKUs }, "Invalid SKUs found in order"); + return validationFailure([ + createValidationError( + ValidationErrorCode.INVALID_SKU, + `Invalid products: ${invalidSKUs.join(", ")}`, + "skus" + ), + ]); + } + + return validationSuccess(meta); + } + + /** + * Validate and throw BadRequestException on failure + */ + async validateOrThrow( + skus: string[], + pricebookId: string + ): Promise> { + const result = await this.validate(skus, pricebookId); + + if (!result.success) { + const error = result.errors?.[0]; + throw new BadRequestException(error?.message ?? "SKU validation failed"); + } + + return result.data!; + } + + /** + * Get the portal pricebook ID + */ + async getPricebookId(): Promise { + return this.pricebookService.findPortalPricebookId(); + } +} diff --git a/apps/bff/src/modules/orders/validators/user-mapping.validator.ts b/apps/bff/src/modules/orders/validators/user-mapping.validator.ts new file mode 100644 index 00000000..1ac8dac5 --- /dev/null +++ b/apps/bff/src/modules/orders/validators/user-mapping.validator.ts @@ -0,0 +1,79 @@ +import { Injectable, BadRequestException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { + ValidationErrorCode, + createValidationError, + validationFailure, + validationSuccess, + type ValidationResult, +} from "./base-validator.interface.js"; + +export interface UserMappingData { + userId: string; + sfAccountId: string; + whmcsClientId: number; +} + +/** + * User Mapping Validator + * + * Validates that a user has the required account mappings + * (Salesforce Account ID and WHMCS Client ID) before ordering. + */ +@Injectable() +export class UserMappingValidator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly mappings: MappingsService + ) {} + + /** + * Validate user mapping exists and has required IDs + * Returns validation result with mapping data on success + */ + async validate(userId: string): Promise> { + const mapping = await this.mappings.findByUserId(userId); + + if (!mapping) { + this.logger.warn({ userId }, "User mapping not found"); + return validationFailure([ + createValidationError( + ValidationErrorCode.USER_MAPPING_NOT_FOUND, + "User account mapping is required before ordering" + ), + ]); + } + + if (!mapping.whmcsClientId) { + this.logger.warn({ userId, mapping }, "WHMCS client ID missing from mapping"); + return validationFailure([ + createValidationError( + ValidationErrorCode.WHMCS_CLIENT_NOT_LINKED, + "WHMCS integration is required before ordering" + ), + ]); + } + + return validationSuccess({ + userId: mapping.userId, + sfAccountId: mapping.sfAccountId, + whmcsClientId: mapping.whmcsClientId, + }); + } + + /** + * Validate and throw BadRequestException on failure + * Use this when you need to fail fast with HTTP error + */ + async validateOrThrow(userId: string): Promise { + const result = await this.validate(userId); + + if (!result.success) { + const error = result.errors?.[0]; + throw new BadRequestException(error?.message ?? "User mapping validation failed"); + } + + return result.data!; + } +} diff --git a/apps/bff/src/modules/users/application/users.facade.ts b/apps/bff/src/modules/users/application/users.service.ts similarity index 99% rename from apps/bff/src/modules/users/application/users.facade.ts rename to apps/bff/src/modules/users/application/users.service.ts index 8e033f40..4e51872a 100644 --- a/apps/bff/src/modules/users/application/users.facade.ts +++ b/apps/bff/src/modules/users/application/users.service.ts @@ -15,7 +15,7 @@ type AuthUpdateData = Partial< >; @Injectable() -export class UsersFacade { +export class UsersService { constructor( private readonly authRepository: UserAuthRepository, private readonly profileService: UserProfileAggregator, diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index 8dcd601c..8545d42a 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -8,7 +8,7 @@ import { ClassSerializerInterceptor, UseGuards, } from "@nestjs/common"; -import { UsersFacade } from "./application/users.facade.js"; +import { UsersService } from "./application/users.service.js"; import { createZodDto, ZodResponse, ZodSerializerDto } from "nestjs-zod"; import { updateCustomerProfileRequestSchema } from "@customer-portal/domain/auth"; import { addressSchema, userSchema } from "@customer-portal/domain/customer"; @@ -27,7 +27,7 @@ class UserDto extends createZodDto(userSchema) {} @Controller("me") @UseInterceptors(ClassSerializerInterceptor) export class UsersController { - constructor(private usersFacade: UsersFacade) {} + constructor(private usersService: UsersService) {} /** * GET /me - Get complete customer profile (includes address) @@ -43,7 +43,7 @@ export class UsersController { if (!req.user) { return null; } - return this.usersFacade.getProfile(req.user.id); + return this.usersService.getProfile(req.user.id); } /** @@ -53,7 +53,7 @@ export class UsersController { @Get("address") @ZodSerializerDto(addressSchema.nullable()) async getAddress(@Req() req: RequestWithUser): Promise
{ - return this.usersFacade.getAddress(req.user.id); + return this.usersService.getAddress(req.user.id); } /** @@ -65,7 +65,7 @@ export class UsersController { @Req() req: RequestWithUser, @Body() address: UpdateAddressDto ): Promise
{ - return this.usersFacade.updateAddress(req.user.id, address); + return this.usersService.updateAddress(req.user.id, address); } /** @@ -96,7 +96,7 @@ export class UsersController { @Req() req: RequestWithUser, @Body() address: UpdateBilingualAddressDto ): Promise
{ - return this.usersFacade.updateBilingualAddress(req.user.id, address); + return this.usersService.updateBilingualAddress(req.user.id, address); } /** @@ -115,6 +115,6 @@ export class UsersController { @Req() req: RequestWithUser, @Body() updateData: UpdateCustomerProfileRequestDto ) { - return this.usersFacade.updateProfile(req.user.id, updateData); + return this.usersService.updateProfile(req.user.id, updateData); } } diff --git a/apps/bff/src/modules/users/users.module.ts b/apps/bff/src/modules/users/users.module.ts index ee4122a7..e56fd772 100644 --- a/apps/bff/src/modules/users/users.module.ts +++ b/apps/bff/src/modules/users/users.module.ts @@ -1,5 +1,5 @@ import { Module } from "@nestjs/common"; -import { UsersFacade } from "./application/users.facade.js"; +import { UsersService } from "./application/users.service.js"; import { UserAuthRepository } from "./infra/user-auth.repository.js"; import { UserProfileAggregator } from "./infra/user-profile.service.js"; import { UsersController } from "./users.controller.js"; @@ -11,7 +11,7 @@ import { PrismaModule } from "@bff/infra/database/prisma.module.js"; @Module({ imports: [PrismaModule, WhmcsModule, SalesforceModule, MappingsModule], controllers: [UsersController], - providers: [UsersFacade, UserAuthRepository, UserProfileAggregator], - exports: [UsersFacade, UserAuthRepository, UserProfileAggregator], + providers: [UsersService, UserAuthRepository, UserProfileAggregator], + exports: [UsersService, UserAuthRepository, UserProfileAggregator], }) export class UsersModule {} diff --git a/docs/decisions/007-service-classification.md b/docs/decisions/007-service-classification.md index 034eb9ba..10bc292a 100644 --- a/docs/decisions/007-service-classification.md +++ b/docs/decisions/007-service-classification.md @@ -83,7 +83,7 @@ Dashboard and profile endpoints combine data from multiple sources. An **Aggrega @Injectable() export class MeStatusAggregator { constructor( - private readonly users: UsersFacade, + private readonly users: UsersService, private readonly orders: OrderOrchestrator, private readonly payments: WhmcsPaymentService ) {} @@ -230,6 +230,124 @@ export class MeStatusService {} // Service doing aggregation export class SimOrchestratorService {} // Drop the Service suffix ``` +## Facade vs Orchestrator: Key Differences + +A common source of confusion is when to use a **Facade** vs an **Orchestrator**. Here's the distinction: + +| Aspect | Facade | Orchestrator | +| -------------------- | -------------------------------------- | ------------------------------ | +| **Scope** | Single integration (WHMCS, Salesforce) | Multiple integrations/systems | +| **Purpose** | Abstract internal complexity | Coordinate workflows | +| **Dependency** | Owns internal services | Consumes facades and services | +| **Mutation Pattern** | Direct API calls | Transaction coordination | +| **Error Handling** | System-specific errors | Cross-system rollback/recovery | +| **Example** | `WhmcsConnectionFacade` | `OrderFulfillmentOrchestrator` | + +### When to Use Each + +**Use a Facade when:** + +- You're building an entry point for a single external system +- You want to encapsulate multiple services within one integration +- Consumers shouldn't know about internal service structure + +**Use an Orchestrator when:** + +- You're coordinating operations across multiple systems +- You need distributed transaction patterns +- Failure in one system requires compensation in another + +## When to Split an Orchestrator + +**Guideline: Consider splitting when an orchestrator exceeds ~300 lines.** + +Signs an orchestrator needs refactoring: + +1. **Too many responsibilities**: Validation + transformation + execution + side effects +2. **Difficult to test**: Mocking 10+ dependencies +3. **Long methods**: Single methods exceeding 100 lines +4. **Mixed concerns**: Business logic mixed with infrastructure + +### Extraction Patterns + +Extract concerns into specialized services: + +```typescript +// BEFORE: Monolithic orchestrator +@Injectable() +export class OrderFulfillmentOrchestrator { + // 700 lines doing everything +} + +// AFTER: Focused orchestrator with extracted services +@Injectable() +export class OrderFulfillmentOrchestrator { + constructor( + private readonly stepTracker: WorkflowStepTrackerService, + private readonly sideEffects: FulfillmentSideEffectsService, + private readonly validator: OrderFulfillmentValidator + ) {} + // ~200 lines of pure orchestration +} +``` + +**Common extractions:** + +- **Step tracking** → `WorkflowStepTrackerService` (infrastructure) +- **Side effects** → `*SideEffectsService` (events, notifications, cache) +- **Validation** → `*Validator` (composable validators) +- **Error handling** → `*ErrorService` (error classification, recovery) + +## File Naming Conventions + +### Services + +| Type | File Name Pattern | Class Name Pattern | +| ------------ | ---------------------------------- | ---------------------- | +| Facade | `{domain}.facade.ts` | `{Domain}Facade` | +| Orchestrator | `{domain}-orchestrator.service.ts` | `{Domain}Orchestrator` | +| Aggregator | `{domain}.aggregator.ts` | `{Domain}Aggregator` | +| Service | `{domain}.service.ts` | `{Domain}Service` | +| Validator | `{domain}.validator.ts` | `{Domain}Validator` | + +### Examples + +``` +# Integration facades +integrations/whmcs/facades/whmcs.facade.ts → WhmcsConnectionFacade +integrations/salesforce/facades/salesforce.facade.ts → SalesforceFacade + +# Module orchestrators +modules/orders/services/order-fulfillment-orchestrator.service.ts → OrderFulfillmentOrchestrator +modules/auth/application/auth-orchestrator.service.ts → AuthOrchestrator + +# Aggregators +modules/me-status/me-status.aggregator.ts → MeStatusAggregator +modules/users/infra/user-profile.service.ts → UserProfileAggregator + +# Validators +modules/orders/validators/internet-order.validator.ts → InternetOrderValidator +modules/orders/validators/sim-order.validator.ts → SimOrderValidator +``` + +### File vs Class Naming + +- **File names**: Use kebab-case with `.service.ts`, `.facade.ts`, `.aggregator.ts` suffix +- **Class names**: Use PascalCase without `Service` suffix for Orchestrators/Aggregators + +```typescript +// ✅ GOOD +// File: order-fulfillment-orchestrator.service.ts +export class OrderFulfillmentOrchestrator {} + +// File: me-status.aggregator.ts +export class MeStatusAggregator {} + +// ❌ AVOID +// File: order-fulfillment-orchestrator.ts (missing .service) +export class OrderFulfillmentOrchestratorService {} // Don't add Service suffix +``` + ## Related - [ADR-006: Thin Controllers](./006-thin-controllers.md)