feat: Implement Me Status Aggregator to consolidate user status data
feat: Add Fulfillment Side Effects Service for order processing notifications and cache management feat: Create base validation interfaces and implement various order validators feat: Develop Internet Order Validator to check eligibility and prevent duplicate services feat: Implement SIM Order Validator to ensure residence card verification and activation fee presence feat: Create SKU Validator to validate product SKUs against the Salesforce pricebook feat: Implement User Mapping Validator to ensure necessary account mappings exist before ordering feat: Enhance Users Service with methods for user profile management and summary retrieval
This commit is contained in:
parent
d3b94b1ed3
commit
be164cf287
211
apps/bff/src/core/utils/safe-operation.util.ts
Normal file
211
apps/bff/src/core/utils/safe-operation.util.ts
Normal file
@ -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<T> {
|
||||
/** 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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a safe operation
|
||||
*/
|
||||
export interface SafeOperationResult<T> {
|
||||
/** 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<T>(
|
||||
executor: () => Promise<T>,
|
||||
options: SafeOperationOptions<T>
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
executor: () => Promise<T>,
|
||||
options: SafeOperationOptions<T>
|
||||
): Promise<SafeOperationResult<T>> {
|
||||
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<T>(
|
||||
criticality: OperationCriticality,
|
||||
fallback: T,
|
||||
context: string,
|
||||
logger: Logger
|
||||
): (executor: () => Promise<T>, metadata?: Record<string, unknown>) => Promise<T> {
|
||||
return async (executor: () => Promise<T>, metadata?: Record<string, unknown>) => {
|
||||
const options: SafeOperationOptions<T> = {
|
||||
criticality,
|
||||
fallback,
|
||||
context,
|
||||
logger,
|
||||
};
|
||||
if (metadata !== undefined) {
|
||||
options.metadata = metadata;
|
||||
}
|
||||
return safeOperation(executor, options);
|
||||
};
|
||||
}
|
||||
187
apps/bff/src/infra/cache/cache-keys.ts
vendored
Normal file
187
apps/bff/src/infra/cache/cache-keys.ts
vendored
Normal file
@ -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;
|
||||
8
apps/bff/src/infra/workflow/index.ts
Normal file
8
apps/bff/src/infra/workflow/index.ts
Normal file
@ -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";
|
||||
128
apps/bff/src/infra/workflow/workflow-step-tracker.service.ts
Normal file
128
apps/bff/src/infra/workflow/workflow-step-tracker.service.ts
Normal file
@ -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<T>(stepId: string, executor: () => Promise<T>): () => Promise<T> {
|
||||
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" },
|
||||
];
|
||||
}
|
||||
}
|
||||
60
apps/bff/src/infra/workflow/workflow-step-tracker.types.ts
Normal file
60
apps/bff/src/infra/workflow/workflow-step-tracker.types.ts
Normal file
@ -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<T>(stepId: string, executor: () => Promise<T>): () => Promise<T>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
17
apps/bff/src/infra/workflow/workflow.module.ts
Normal file
17
apps/bff/src/infra/workflow/workflow.module.ts
Normal file
@ -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 {}
|
||||
@ -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) });
|
||||
|
||||
@ -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<ValidatedUser | null> {
|
||||
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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -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 {}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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<AuthTokens> {
|
||||
// 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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<AuthResultInternal> {
|
||||
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");
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<boolean> {
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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",
|
||||
@ -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")
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<OrderUpdateEventPayload, "orderId">): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const tasks: Array<Promise<unknown>> = [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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<string, unknown>, key: string, value: unknown): void {
|
||||
@ -17,7 +17,7 @@ function assignIfString(target: Record<string, unknown>, 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<void> {
|
||||
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<string, unknown>)?.["address"] as
|
||||
| Record<string, unknown>
|
||||
|
||||
@ -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<void> {
|
||||
const tasks: Array<Promise<unknown>> = [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<void> {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -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<T = void> {
|
||||
/** 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<void>;
|
||||
export function validationSuccess<T>(data: T): ValidationResult<T>;
|
||||
export function validationSuccess<T>(data?: T): ValidationResult<T> {
|
||||
if (data === undefined) {
|
||||
return { success: true } as ValidationResult<T>;
|
||||
}
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed validation result
|
||||
*/
|
||||
export function validationFailure(errors: ValidationError[]): ValidationResult<never> {
|
||||
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;
|
||||
15
apps/bff/src/modules/orders/validators/index.ts
Normal file
15
apps/bff/src/modules/orders/validators/index.ts
Normal file
@ -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";
|
||||
@ -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<ValidationResult> {
|
||||
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<ValidationResult> {
|
||||
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<ValidationResult> {
|
||||
// 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<void> {
|
||||
const result = await this.validate(userId, whmcsClientId);
|
||||
|
||||
if (!result.success) {
|
||||
const error = result.errors?.[0];
|
||||
throw new BadRequestException(error?.message ?? "Internet order validation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ValidationResult> {
|
||||
// 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<void> {
|
||||
const result = await this.validate(userId, skus);
|
||||
|
||||
if (!result.success) {
|
||||
const error = result.errors?.[0];
|
||||
throw new BadRequestException(error?.message ?? "SIM order validation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
86
apps/bff/src/modules/orders/validators/sku.validator.ts
Normal file
86
apps/bff/src/modules/orders/validators/sku.validator.ts
Normal file
@ -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<ValidationResult<Map<string, PricebookProductMeta>>> {
|
||||
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<Map<string, PricebookProductMeta>> {
|
||||
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<string> {
|
||||
return this.pricebookService.findPortalPricebookId();
|
||||
}
|
||||
}
|
||||
@ -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<ValidationResult<UserMappingData>> {
|
||||
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<UserMappingData> {
|
||||
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!;
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ type AuthUpdateData = Partial<
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class UsersFacade {
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly authRepository: UserAuthRepository,
|
||||
private readonly profileService: UserProfileAggregator,
|
||||
@ -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<Address | null> {
|
||||
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<Address> {
|
||||
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<Address> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user