diff --git a/apps/bff/src/core/utils/error-handler.util.ts b/apps/bff/src/core/utils/error-handler.util.ts deleted file mode 100644 index d1e6cccb..00000000 --- a/apps/bff/src/core/utils/error-handler.util.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Centralized error handling utilities for consistent error handling across services - */ -import { - NotFoundException, - BadRequestException, - InternalServerErrorException, - type Type, -} from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { extractErrorMessage } from "./error.util.js"; - -/** - * Types of exceptions that can be rethrown without wrapping - */ -type RethrowableException = Type; - -/** - * Options for error handling - */ -interface ErrorHandlerOptions { - /** - * Context string for logging (e.g., "Get invoices for user xyz") - */ - context: string; - - /** - * Exception types that should be rethrown as-is without wrapping - * @default [NotFoundException, BadRequestException] - */ - rethrow?: RethrowableException[]; - - /** - * Custom fallback message for InternalServerErrorException - * @default `${context} failed` - */ - fallbackMessage?: string; -} - -/** - * Wraps an async operation with consistent error handling. - * - * - Logs errors with context - * - Rethrows specified exception types as-is - * - Wraps other errors in InternalServerErrorException - * - * @example - * return withErrorHandling( - * () => this.whmcsService.getInvoices(clientId, userId), - * this.logger, - * { context: `Get invoices for user ${userId}`, rethrow: [NotFoundException] } - * ); - */ -export async function withErrorHandling( - operation: () => Promise, - logger: Logger, - options: ErrorHandlerOptions -): Promise { - const { context, rethrow = [NotFoundException, BadRequestException], fallbackMessage } = options; - - try { - return await operation(); - } catch (error) { - logger.error(context, { error: extractErrorMessage(error) }); - - for (const ExceptionType of rethrow) { - if (error instanceof ExceptionType) { - throw error; - } - } - - throw new InternalServerErrorException(fallbackMessage ?? `${context} failed`); - } -} - -/** - * Safe wrapper that returns null instead of throwing. - * Useful for optional operations where failure is acceptable. - * - * @example - * const cached = await withErrorSuppression( - * () => this.cache.get(key), - * this.logger, - * "Get cached value" - * ); - */ -export async function withErrorSuppression( - operation: () => Promise, - logger: Logger, - context: string -): Promise { - try { - return await operation(); - } catch (error) { - logger.warn(`${context} (suppressed)`, { error: extractErrorMessage(error) }); - return null; - } -} - -/** - * Wraps an async operation and logs errors, but always rethrows them. - * Useful when you want consistent logging but don't want to change the error type. - * - * @example - * return withErrorLogging( - * () => this.externalService.call(), - * this.logger, - * "Call external service" - * ); - */ -export async function withErrorLogging( - operation: () => Promise, - logger: Logger, - context: string -): Promise { - try { - return await operation(); - } catch (error) { - logger.error(context, { error: extractErrorMessage(error) }); - throw error; - } -} diff --git a/apps/bff/src/core/utils/error.util.ts b/apps/bff/src/core/utils/error.util.ts index ce5c73a9..5e85d20d 100644 --- a/apps/bff/src/core/utils/error.util.ts +++ b/apps/bff/src/core/utils/error.util.ts @@ -185,19 +185,3 @@ export function createDeferredPromise(): { return { promise, resolve, reject }; } - -/** - * Safe async operation wrapper with proper error handling - */ -export async function safeAsync( - operation: () => Promise, - fallback?: T -): Promise<{ data: T | null; error: EnhancedError | null }> { - try { - const data = await operation(); - return { data, error: null }; - } catch (unknownError) { - const error = toError(unknownError); - return { data: fallback ?? null, error }; - } -} diff --git a/apps/bff/src/core/utils/index.ts b/apps/bff/src/core/utils/index.ts index 80a7c974..0c1cee6c 100644 --- a/apps/bff/src/core/utils/index.ts +++ b/apps/bff/src/core/utils/index.ts @@ -9,7 +9,7 @@ export { chunkArray, groupBy, uniqueBy } from "./array.util.js"; // Error utilities export { extractErrorMessage } from "./error.util.js"; -export { withErrorHandling, withErrorSuppression, withErrorLogging } from "./error-handler.util.js"; +export { safeOperation, OperationCriticality, type SafeOperationOptions, type SafeOperationResult } from "./safe-operation.util.js"; // Retry utilities export { diff --git a/apps/bff/src/core/utils/safe-operation.util.ts b/apps/bff/src/core/utils/safe-operation.util.ts index 6490ae50..4b2c6391 100644 --- a/apps/bff/src/core/utils/safe-operation.util.ts +++ b/apps/bff/src/core/utils/safe-operation.util.ts @@ -1,3 +1,4 @@ +import { InternalServerErrorException } from "@nestjs/common"; import type { Logger } from "nestjs-pino"; import { extractErrorMessage } from "./error.util.js"; @@ -36,6 +37,12 @@ export interface SafeOperationOptions { /** Additional metadata for logging */ metadata?: Record; + + /** Exception types to rethrow as-is (only applicable when criticality is CRITICAL) */ + rethrow?: Array Error>; + + /** Custom message for InternalServerErrorException wrapper (only applicable when criticality is CRITICAL) */ + fallbackMessage?: string; } /** @@ -82,7 +89,7 @@ export async function safeOperation( executor: () => Promise, options: SafeOperationOptions ): Promise { - const { criticality, fallback, context, logger, metadata = {} } = options; + const { criticality, fallback, context, logger, metadata = {}, rethrow, fallbackMessage } = options; try { return await executor(); @@ -98,6 +105,14 @@ export async function safeOperation( switch (criticality) { case OperationCriticality.CRITICAL: logger.error(logPayload, `Critical operation failed: ${context}`); + if (rethrow) { + for (const ExceptionType of rethrow) { + if (error instanceof ExceptionType) { + throw error; + } + } + throw new InternalServerErrorException(fallbackMessage ?? `${context} failed`); + } throw error; case OperationCriticality.DEGRADABLE: diff --git a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts index aa0d0aa1..f3b7d339 100644 --- a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts @@ -22,7 +22,7 @@ import { changePasswordRequestSchema, } from "@customer-portal/domain/auth"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; -import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; +import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js"; import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; @Injectable() @@ -64,7 +64,7 @@ export class PasswordWorkflowService { const passwordHash = await argon2.hash(password); - return withErrorHandling( + return safeOperation( async () => { try { await this.usersService.update(user.id, { passwordHash }); @@ -102,9 +102,11 @@ export class PasswordWorkflowService { tokens, }; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Set password for user ${user.id}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to set password", } ); @@ -151,7 +153,7 @@ export class PasswordWorkflowService { // This will throw BadRequestException if token is invalid, expired, or already used const { userId } = await this.passwordResetTokenService.consume(token); - return withErrorHandling( + return safeOperation( async () => { const prismaUser = await this.usersService.findByIdInternal(userId); if (!prismaUser) throw new BadRequestException("Invalid token"); @@ -168,11 +170,12 @@ export class PasswordWorkflowService { // Revoke all trusted devices - attacker could have set up a trusted device await this.trustedDeviceService.revokeAllUserDevices(freshUser.id); }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: "Reset password", - fallbackMessage: "Failed to reset password", + logger: this.logger, rethrow: [BadRequestException], + fallbackMessage: "Failed to reset password", } ); } @@ -213,7 +216,7 @@ export class PasswordWorkflowService { const passwordHash = await argon2.hash(newPassword); - return withErrorHandling( + return safeOperation( async () => { await this.usersService.update(user.id, { passwordHash }); const prismaUser = await this.usersService.findByIdInternal(user.id); @@ -244,9 +247,11 @@ export class PasswordWorkflowService { tokens, }; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Change password for user ${user.id}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to change password", } ); diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts index b28b03d8..eac8e708 100644 --- a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts @@ -14,7 +14,7 @@ import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforc import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { getCustomFieldValue } from "@customer-portal/domain/customer/providers"; -import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; +import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js"; import type { User } from "@customer-portal/domain/customer"; import { PORTAL_SOURCE_MIGRATED, @@ -50,7 +50,7 @@ export class WhmcsLinkWorkflowService { ); } - return withErrorHandling( + return safeOperation( async () => { const clientDetails = await this.discoveryService.findClientByEmail(email); if (!clientDetails) { @@ -133,11 +133,12 @@ export class WhmcsLinkWorkflowService { needsPasswordSet: true, }; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: "WHMCS account linking", - fallbackMessage: "Failed to link WHMCS account", + logger: this.logger, rethrow: [BadRequestException, ConflictException, UnauthorizedException], + fallbackMessage: "Failed to link WHMCS account", } ); } diff --git a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts index 1b43a5cb..154dbac6 100644 --- a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { Invoice, @@ -9,7 +9,7 @@ import type { import { INVOICE_PAGINATION, VALID_INVOICE_QUERY_STATUSES } from "@customer-portal/domain/billing"; import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; +import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js"; import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js"; /** @@ -40,7 +40,7 @@ export class InvoiceRetrievalService { parseUuidOrThrow(userId, "Invalid user ID format"); const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - return withErrorHandling( + return safeOperation( async () => { const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, { page, @@ -57,8 +57,13 @@ export class InvoiceRetrievalService { return invoiceList; }, - this.logger, - { context: `Get invoices for user ${userId}`, fallbackMessage: "Failed to retrieve invoices" } + { + criticality: OperationCriticality.CRITICAL, + context: `Get invoices for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], + fallbackMessage: "Failed to retrieve invoices", + } ); } @@ -69,7 +74,7 @@ export class InvoiceRetrievalService { parseUuidOrThrow(userId, "Invalid user ID format"); const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - return withErrorHandling( + return safeOperation( async () => { const invoice = await this.whmcsInvoiceService.getInvoiceById( whmcsClientId, @@ -79,9 +84,11 @@ export class InvoiceRetrievalService { this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`); return invoice; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Get invoice ${invoiceId} for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve invoice", } ); @@ -108,11 +115,13 @@ export class InvoiceRetrievalService { ); } - return withErrorHandling( + return safeOperation( async () => this.getInvoices(userId, { page, limit, status }), - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Get ${status} invoices for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve invoices", } ); diff --git a/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts index 52b66ba4..2869b67b 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; import { subscriptionListSchema, subscriptionStatusSchema, @@ -17,7 +17,7 @@ import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invo import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { Logger } from "nestjs-pino"; -import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; +import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; export interface GetSubscriptionsOptions { @@ -53,7 +53,7 @@ export class SubscriptionsOrchestrator { const { status } = options; const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - return withErrorHandling( + return safeOperation( async () => { const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions( whmcsClientId, @@ -74,9 +74,11 @@ export class SubscriptionsOrchestrator { totalCount: subscriptions.length, }); }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Get subscriptions for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve subscriptions", } ); @@ -94,7 +96,7 @@ export class SubscriptionsOrchestrator { // Get WHMCS client ID from user mapping const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - return withErrorHandling( + return safeOperation( async () => { const subscription = await this.whmcsSubscriptionService.getSubscriptionById( whmcsClientId, @@ -111,9 +113,11 @@ export class SubscriptionsOrchestrator { return subscription; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Get subscription ${subscriptionId} for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve subscription", } ); @@ -164,7 +168,7 @@ export class SubscriptionsOrchestrator { completed: number; cancelled: number; }> { - return withErrorHandling( + return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions: Subscription[] = subscriptionList.subscriptions; @@ -180,9 +184,11 @@ export class SubscriptionsOrchestrator { return subscriptionStatsSchema.parse(stats); }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Generate subscription stats for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], } ); } @@ -191,7 +197,7 @@ export class SubscriptionsOrchestrator { * Get subscriptions expiring soon (within next 30 days) */ async getExpiringSoon(userId: string, days: number = 30): Promise { - return withErrorHandling( + return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions: Subscription[] = subscriptionList.subscriptions; @@ -213,9 +219,11 @@ export class SubscriptionsOrchestrator { ); return expiringSoon; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Get expiring subscriptions for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], } ); } @@ -224,7 +232,7 @@ export class SubscriptionsOrchestrator { * Get recent subscription activity (newly created or status changed) */ async getRecentActivity(userId: string, days: number = 30): Promise { - return withErrorHandling( + return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions = subscriptionList.subscriptions; @@ -242,9 +250,11 @@ export class SubscriptionsOrchestrator { ); return recentActivity; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Get recent subscription activity for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], } ); } @@ -257,7 +267,7 @@ export class SubscriptionsOrchestrator { throw new BadRequestException("Search query must be at least 2 characters long"); } - return withErrorHandling( + return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions = subscriptionList.subscriptions; @@ -275,9 +285,11 @@ export class SubscriptionsOrchestrator { ); return matches; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Search subscriptions for user ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], } ); } @@ -292,7 +304,7 @@ export class SubscriptionsOrchestrator { ): Promise { const { page = 1, limit = 10 } = options; - return withErrorHandling( + return safeOperation( async () => { const cachedResult = await this.tryGetCachedInvoices(userId, subscriptionId, page, limit); if (cachedResult) return cachedResult; @@ -326,9 +338,11 @@ export class SubscriptionsOrchestrator { return result; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Get invoices for subscription ${subscriptionId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve subscription invoices", } ); diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index 49ed9042..9afb133d 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -38,7 +38,7 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js"; import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js"; import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; -import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; +import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js"; import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js"; import { UserAuthRepository } from "./user-auth.repository.js"; import { AddressReconcileQueueService } from "../queue/address-reconcile.queue.js"; @@ -279,7 +279,7 @@ export class UserProfileAggregator { const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(validId); - return withErrorHandling( + return safeOperation( async () => { await this.whmcsClientService.updateClientAddress(whmcsClientId, parsed); await this.whmcsClientService.invalidateUserCache(validId); @@ -297,9 +297,11 @@ export class UserProfileAggregator { const refreshedAddress = await this.whmcsClientService.getClientAddress(whmcsClientId); return addressSchema.parse(refreshedAddress ?? {}); }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Update address for user ${validId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to update address", } ); @@ -320,7 +322,7 @@ export class UserProfileAggregator { ): Promise
{ const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID); - return withErrorHandling( + return safeOperation( async () => { const mapping = await this.mappingsService.findByUserId(validId); if (!mapping?.whmcsClientId) { @@ -353,9 +355,11 @@ export class UserProfileAggregator { return this.fetchRefreshedAddress(validId, mapping.whmcsClientId); }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Update bilingual address for user ${validId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to update address", } ); @@ -438,7 +442,7 @@ export class UserProfileAggregator { const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID); const parsed = updateCustomerProfileRequestSchema.parse(update); - return withErrorHandling( + return safeOperation( async () => { // Explicitly disallow name changes from portal if (parsed.firstname !== undefined || parsed.lastname !== undefined) { @@ -482,17 +486,22 @@ export class UserProfileAggregator { return this.getProfile(validId); }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Update profile for user ${validId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to update profile", } ); } async getUserSummary(userId: string): Promise { - return withErrorHandling(async () => this.buildUserSummary(userId), this.logger, { + return safeOperation(async () => this.buildUserSummary(userId), { + criticality: OperationCriticality.CRITICAL, context: `Get user summary for ${userId}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to retrieve dashboard summary", }); } @@ -641,7 +650,7 @@ export class UserProfileAggregator { private async getProfileForUser(user: PrismaUser): Promise { const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(user.id); - return withErrorHandling( + return safeOperation( async () => { const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId); const userAuth = mapPrismaUserToUserAuth(user); @@ -676,9 +685,11 @@ export class UserProfileAggregator { gender, }; }, - this.logger, { + criticality: OperationCriticality.CRITICAL, context: `Fetch client profile from WHMCS for user ${user.id}`, + logger: this.logger, + rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Unable to retrieve customer profile from billing system", } );