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:
barsa 2026-01-19 11:25:30 +09:00
parent d3b94b1ed3
commit be164cf287
39 changed files with 1854 additions and 357 deletions

View 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
View 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;

View 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";

View 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" },
];
}
}

View 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;
}

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

View File

@ -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) });

View File

@ -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,
});

View File

@ -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;

View File

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

View File

@ -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
*/

View File

@ -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");

View File

@ -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) {

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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);

View File

@ -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");
}

View File

@ -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);
}
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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:
"Were verifying if our service is available at your residence. Well 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 couldnt 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",

View File

@ -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")

View File

@ -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";

View File

@ -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 };
}
}

View File

@ -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")

View File

@ -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,

View File

@ -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"
);
}
}
}

View File

@ -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>

View File

@ -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
*/

View File

@ -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;

View 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";

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View 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();
}
}

View File

@ -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!;
}
}

View File

@ -15,7 +15,7 @@ type AuthUpdateData = Partial<
>;
@Injectable()
export class UsersFacade {
export class UsersService {
constructor(
private readonly authRepository: UserAuthRepository,
private readonly profileService: UserProfileAggregator,

View File

@ -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);
}
}

View File

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

View File

@ -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)