diff --git a/apps/bff/src/core/http/exception.filter.ts b/apps/bff/src/core/http/exception.filter.ts index f6d59ac3..a226314e 100644 --- a/apps/bff/src/core/http/exception.filter.ts +++ b/apps/bff/src/core/http/exception.filter.ts @@ -18,6 +18,7 @@ import { type ApiError, } from "@customer-portal/domain/common"; import { generateRequestId } from "@bff/core/logging/request-id.util.js"; +import { BaseProviderError } from "@bff/integrations/common/errors/index.js"; function mapHttpStatusToErrorCode(status?: number): ErrorCodeType { if (!status) return ErrorCode.UNKNOWN; @@ -108,6 +109,10 @@ export class UnifiedExceptionFilter implements ExceptionFilter { const extracted = this.extractExceptionDetails(exception); originalMessage = extracted.message; explicitCode = extracted.code; + } else if (exception instanceof BaseProviderError) { + status = exception.httpStatus; + originalMessage = exception.message; + explicitCode = exception.domainErrorCode; } else if (exception instanceof Error) { originalMessage = exception.message; } diff --git a/apps/bff/src/infra/audit/audit-log.service.ts b/apps/bff/src/infra/audit/audit-log.service.ts index d328346f..14be5aac 100644 --- a/apps/bff/src/infra/audit/audit-log.service.ts +++ b/apps/bff/src/infra/audit/audit-log.service.ts @@ -1,6 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Prisma, AuditAction } from "@prisma/client"; -import { PrismaService } from "../database/prisma.service.js"; +import { AuditLogRepository } from "../database/repositories/index.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { Logger } from "nestjs-pino"; import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js"; @@ -40,13 +40,13 @@ export type AuditRequest = { @Injectable() export class AuditLogService { constructor( - private readonly prisma: PrismaService, + private readonly auditLogRepository: AuditLogRepository, @Inject(Logger) private readonly logger: Logger ) {} async log(data: AuditLogData): Promise { try { - const createData: Parameters[0]["data"] = { + const createData: Prisma.AuditLogUncheckedCreateInput = { action: data.action, success: data.success ?? true, }; @@ -63,7 +63,7 @@ export class AuditLogService { : (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue); } - await this.prisma.auditLog.create({ data: createData }); + await this.auditLogRepository.create(createData); } catch (error) { this.logger.error("Audit logging failed", { errorType: error instanceof Error ? error.constructor.name : "Unknown", diff --git a/apps/bff/src/infra/database/prisma.module.ts b/apps/bff/src/infra/database/prisma.module.ts index 796effb4..42283029 100644 --- a/apps/bff/src/infra/database/prisma.module.ts +++ b/apps/bff/src/infra/database/prisma.module.ts @@ -2,10 +2,30 @@ import { Global, Module } from "@nestjs/common"; import { PrismaService } from "./prisma.service.js"; import { TransactionService } from "./services/transaction.service.js"; import { DistributedTransactionService } from "./services/distributed-transaction.service.js"; +import { UnitOfWork } from "./unit-of-work.service.js"; +import { IdMappingRepository } from "./repositories/id-mapping.repository.js"; +import { AuditLogRepository } from "./repositories/audit-log.repository.js"; +import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repository.js"; @Global() @Module({ - providers: [PrismaService, TransactionService, DistributedTransactionService], - exports: [PrismaService, TransactionService, DistributedTransactionService], + providers: [ + PrismaService, + TransactionService, + DistributedTransactionService, + UnitOfWork, + IdMappingRepository, + AuditLogRepository, + SimVoiceOptionsRepository, + ], + exports: [ + PrismaService, + TransactionService, + DistributedTransactionService, + UnitOfWork, + IdMappingRepository, + AuditLogRepository, + SimVoiceOptionsRepository, + ], }) export class PrismaModule {} diff --git a/apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts b/apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts index bea58fa5..c2fb3cb9 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts @@ -1,13 +1,18 @@ -import { Injectable, HttpStatus } from "@nestjs/common"; -import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; +import { Injectable } from "@nestjs/common"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { matchCommonError } from "@bff/core/errors/index.js"; import { FreebitError } from "./freebit-error.service.js"; +import { + BaseProviderError, + FreebitApiError, + FreebitAccountNotFoundError, + FreebitTimeoutError, +} from "@bff/integrations/common/errors/index.js"; /** * Service for handling and normalizing Freebit API errors. - * Maps Freebit errors to appropriate NestJS exceptions. + * Maps Freebit errors to typed provider error classes. * * Mirrors the pattern used by WhmcsErrorHandlerService and SalesforceErrorHandlerService. */ @@ -18,8 +23,7 @@ export class FreebitErrorHandlerService { */ handleApiError(error: unknown, context: string): never { if (error instanceof FreebitError) { - const mapped = this.mapFreebitErrorToDomain(error); - throw new DomainHttpException(mapped.code, mapped.status, error.message); + this.throwTypedFreebitError(error); } // Handle generic errors @@ -34,98 +38,54 @@ export class FreebitErrorHandlerService { // Check for timeout if (this.isTimeoutError(message)) { - throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); + throw new FreebitTimeoutError(message, error); } // Check for network errors if (this.isNetworkError(message)) { - throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); + throw new FreebitApiError("Freebit network error", message, error); } // Check for rate limiting if (this.isRateLimitError(message)) { - throw new DomainHttpException( - ErrorCode.RATE_LIMITED, - HttpStatus.TOO_MANY_REQUESTS, - "Freebit rate limit exceeded" - ); + throw new FreebitApiError("Freebit rate limit exceeded", message, error); } // Check for auth errors if (this.isAuthError(message)) { - throw new DomainHttpException( - ErrorCode.EXTERNAL_SERVICE_ERROR, - HttpStatus.SERVICE_UNAVAILABLE, - "Freebit authentication failed" - ); + throw new FreebitApiError("Freebit authentication failed", message, error); } - // Re-throw if already a DomainHttpException + // Re-throw if already a BaseProviderError or DomainHttpException + if (error instanceof BaseProviderError) { + throw error; + } if (error instanceof DomainHttpException) { throw error; } // Wrap unknown errors - throw new DomainHttpException( - ErrorCode.EXTERNAL_SERVICE_ERROR, - HttpStatus.BAD_GATEWAY, - `Freebit ${context} failed` - ); + throw new FreebitApiError(`Freebit ${context} failed`, message, error); } /** - * Map FreebitError to domain error codes + * Map FreebitError to typed provider error and throw */ - private mapFreebitErrorToDomain(error: FreebitError): { - code: ErrorCodeType; - status: HttpStatus; - } { - const resultCode = String(error.resultCode || ""); - const statusCode = String(error.statusCode || ""); + private throwTypedFreebitError(error: FreebitError): never { const message = error.message.toLowerCase(); - // Authentication errors - if (error.isAuthError()) { - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE }; - } - - // Rate limit errors - if (error.isRateLimitError()) { - return { code: ErrorCode.RATE_LIMITED, status: HttpStatus.TOO_MANY_REQUESTS }; - } - // Not found errors if (message.includes("account not found") || message.includes("no such account")) { - return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND }; - } - - // Plan change specific errors - if (resultCode === "215" || statusCode === "215") { - return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST }; - } - - // Network type change errors - if ( - resultCode === "381" || - statusCode === "381" || - resultCode === "382" || - statusCode === "382" - ) { - return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST }; - } - - // Server errors (retryable) - if (error.isRetryable() && !this.isTimeoutError(error.message)) { - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; + throw new FreebitAccountNotFoundError(error.message, error); } // Timeout if (this.isTimeoutError(error.message)) { - return { code: ErrorCode.TIMEOUT, status: HttpStatus.GATEWAY_TIMEOUT }; + throw new FreebitTimeoutError(error.message, error); } - // Default: external service error - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; + // Default: generic Freebit API error + throw new FreebitApiError("Freebit API error", error.message, error); } /** diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts index 56e757b2..61efd845 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts @@ -1,8 +1,16 @@ -import { Injectable, HttpStatus } from "@nestjs/common"; -import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; -import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; +import { Injectable } from "@nestjs/common"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { matchCommonError } from "@bff/core/errors/index.js"; +import { + BaseProviderError, + SalesforceApiError, + SalesforceSessionExpiredError, + SalesforceQueryError, + SalesforceTimeoutError, + SalesforceNetworkError, + SalesforceRateLimitError, +} from "@bff/integrations/common/errors/index.js"; +import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; /** * Salesforce error response structure @@ -32,122 +40,68 @@ export class SalesforceErrorHandlerService { const errors = Array.isArray(errorResponse) ? errorResponse : [errorResponse]; const firstError = errors[0] || {}; - const errorCode = firstError.errorCode || "UNKNOWN_ERROR"; + const sfErrorCode = firstError.errorCode || "UNKNOWN_ERROR"; const message = firstError.message || "Salesforce operation failed"; - const mapped = this.mapSalesforceErrorToDomain(errorCode, message, context); - throw new DomainHttpException(mapped.code, mapped.status, message); + // Session expired + if ( + sfErrorCode === "INVALID_SESSION_ID" || + sfErrorCode === "SESSION_EXPIRED" || + sfErrorCode === "INVALID_AUTH_HEADER" + ) { + throw new SalesforceSessionExpiredError(message); + } + + // Query / not-found errors + if ( + sfErrorCode === "NOT_FOUND" || + sfErrorCode === "INVALID_CROSS_REFERENCE_KEY" || + message.toLowerCase().includes("no rows") + ) { + throw new SalesforceQueryError(message); + } + + // Rate limiting + if (sfErrorCode === "REQUEST_LIMIT_EXCEEDED" || sfErrorCode === "TOO_MANY_REQUESTS") { + throw new SalesforceRateLimitError(message); + } + + // Default + throw new SalesforceApiError(`Salesforce ${context} failed`, message); } /** * Handle general request errors (network, timeout, session expired, etc.) */ handleRequestError(error: unknown, context: string): never { + const message = extractErrorMessage(error); + // Check for session expired if (this.isSessionExpiredError(error)) { - throw new DomainHttpException( - ErrorCode.EXTERNAL_SERVICE_ERROR, - HttpStatus.SERVICE_UNAVAILABLE, - "Salesforce session expired" - ); + throw new SalesforceSessionExpiredError(message, error); } // Check for timeout if (this.isTimeoutError(error)) { - throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); + throw new SalesforceTimeoutError(message, error); } // Check for network errors if (this.isNetworkError(error)) { - throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); + throw new SalesforceNetworkError(message, error); } // Check for rate limiting if (this.isRateLimitError(error)) { - throw new DomainHttpException( - ErrorCode.RATE_LIMITED, - HttpStatus.TOO_MANY_REQUESTS, - "Salesforce rate limit exceeded" - ); + throw new SalesforceRateLimitError(message, error); } - // Re-throw if already a DomainHttpException - if (error instanceof DomainHttpException) { - throw error; - } + // Re-throw provider errors and DomainHttpException as-is + if (error instanceof BaseProviderError) throw error; + if (error instanceof DomainHttpException) throw error; // Wrap unknown errors - throw new DomainHttpException( - ErrorCode.EXTERNAL_SERVICE_ERROR, - HttpStatus.BAD_GATEWAY, - `Salesforce ${context} failed` - ); - } - - /** - * Map Salesforce error codes to domain error codes - */ - private mapSalesforceErrorToDomain( - sfErrorCode: string, - message: string, - context: string - ): { code: ErrorCodeType; status: HttpStatus } { - // Not found errors - if ( - sfErrorCode === "NOT_FOUND" || - sfErrorCode === "INVALID_CROSS_REFERENCE_KEY" || - message.toLowerCase().includes("no rows") - ) { - return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND }; - } - - // Authentication errors - if ( - sfErrorCode === "INVALID_SESSION_ID" || - sfErrorCode === "SESSION_EXPIRED" || - sfErrorCode === "INVALID_AUTH_HEADER" - ) { - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE }; - } - - // Validation errors - if ( - sfErrorCode === "REQUIRED_FIELD_MISSING" || - sfErrorCode === "INVALID_FIELD" || - sfErrorCode === "MALFORMED_ID" || - sfErrorCode === "FIELD_CUSTOM_VALIDATION_EXCEPTION" || - sfErrorCode === "FIELD_INTEGRITY_EXCEPTION" - ) { - return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST }; - } - - // Duplicate detection - if (sfErrorCode === "DUPLICATE_VALUE" || sfErrorCode === "DUPLICATE_EXTERNAL_ID") { - return { code: ErrorCode.ACCOUNT_EXISTS, status: HttpStatus.CONFLICT }; - } - - // Insufficient access - if ( - sfErrorCode === "INSUFFICIENT_ACCESS" || - sfErrorCode === "INSUFFICIENT_ACCESS_OR_READONLY" || - sfErrorCode === "CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY" - ) { - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.FORBIDDEN }; - } - - // Rate limiting - if (sfErrorCode === "REQUEST_LIMIT_EXCEEDED" || sfErrorCode === "TOO_MANY_REQUESTS") { - return { code: ErrorCode.RATE_LIMITED, status: HttpStatus.TOO_MANY_REQUESTS }; - } - - // Storage limit - if (sfErrorCode === "STORAGE_LIMIT_EXCEEDED") { - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE }; - } - - // Default: external service error - void context; // reserved for future use - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; + throw new SalesforceApiError(`Salesforce ${context} failed`, message, error); } /** diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts index 275b817f..c12384ea 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts @@ -1,14 +1,26 @@ import { Injectable, HttpStatus, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; +import { ErrorCode } from "@customer-portal/domain/common"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { matchCommonError } from "@bff/core/errors/index.js"; +import { + BaseProviderError, + WhmcsApiError, + WhmcsNotFoundError, + WhmcsAuthError, + WhmcsInvalidCredentialsError, + WhmcsValidationError, + WhmcsTimeoutError, + WhmcsNetworkError, + WhmcsRateLimitError, + WhmcsHttpError, +} from "@bff/integrations/common/errors/index.js"; /** * Service for handling and normalizing WHMCS API errors - * Maps WHMCS errors to appropriate NestJS exceptions + * Maps WHMCS errors to typed provider error classes */ @Injectable() export class WhmcsErrorHandlerService { @@ -20,8 +32,29 @@ export class WhmcsErrorHandlerService { const message = errorResponse.message; const errorCode = errorResponse.errorcode; - const mapped = this.mapProviderErrorToDomain(action, message, errorCode); - throw new DomainHttpException(mapped.code, mapped.status); + // 1) ValidateLogin: user credentials are wrong (expected) + if (action === "ValidateLogin" && this.isValidateLoginInvalidCredentials(message, errorCode)) { + throw new WhmcsInvalidCredentialsError(message); + } + + // 2) Not-found style outcomes (expected for some reads) + if (this.isNotFoundError(action, message)) { + const resource = this.getNotFoundResource(action); + throw new WhmcsNotFoundError(resource, message); + } + + // 3) WHMCS API key auth failures: external service/config problem (not end-user auth) + if (this.isAuthenticationError(message, errorCode)) { + throw new WhmcsAuthError(message); + } + + // 4) Validation failures: treat as bad request + if (this.isValidationError(message, errorCode)) { + throw new WhmcsValidationError(message); + } + + // 5) Default: external service error + throw new WhmcsApiError("WHMCS API error", message); } /** @@ -34,20 +67,16 @@ export class WhmcsErrorHandlerService { if (this.isTimeoutError(error)) { this.logger.warn("WHMCS request timeout", { error: message }); - throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); + throw new WhmcsTimeoutError(message, error); } if (this.isNetworkError(error)) { this.logger.warn("WHMCS network error", { error: message }); - throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); + throw new WhmcsNetworkError(message, error); } if (this.isRateLimitError(error)) { - throw new DomainHttpException( - ErrorCode.RATE_LIMITED, - HttpStatus.TOO_MANY_REQUESTS, - "WHMCS rate limit exceeded" - ); + throw new WhmcsRateLimitError(message, error); } // Check for HTTP status errors (e.g., "HTTP 500: Internal Server Error") @@ -61,10 +90,19 @@ export class WhmcsErrorHandlerService { // Map upstream HTTP status to appropriate domain error const mapped = this.mapHttpStatusToDomainError(httpStatusError.status); - throw new DomainHttpException(mapped.code, mapped.domainStatus, mapped.message); + throw new WhmcsHttpError( + httpStatusError.status, + mapped.message, + mapped.code, + mapped.domainStatus, + error + ); } - // Re-throw if already a DomainHttpException + // Re-throw if already a BaseProviderError or DomainHttpException + if (error instanceof BaseProviderError) { + throw error; + } if (error instanceof DomainHttpException) { throw error; } @@ -77,10 +115,10 @@ export class WhmcsErrorHandlerService { }); // Wrap unknown errors with context - throw new DomainHttpException( - ErrorCode.EXTERNAL_SERVICE_ERROR, - HttpStatus.BAD_GATEWAY, - _context ? `WHMCS ${_context} failed` : "WHMCS operation failed" + throw new WhmcsApiError( + _context ? `WHMCS ${_context} failed` : "WHMCS operation failed", + message, + error ); } @@ -102,7 +140,7 @@ export class WhmcsErrorHandlerService { * Map upstream HTTP status codes to domain errors */ private mapHttpStatusToDomainError(upstreamStatus: number): { - code: ErrorCodeType; + code: typeof ErrorCode.EXTERNAL_SERVICE_ERROR | typeof ErrorCode.SERVICE_UNAVAILABLE; domainStatus: HttpStatus; message: string; } { @@ -165,36 +203,14 @@ export class WhmcsErrorHandlerService { }; } - private mapProviderErrorToDomain( - action: string, - message: string, - providerErrorCode: string | undefined - ): { code: ErrorCodeType; status: HttpStatus } { - // 1) ValidateLogin: user credentials are wrong (expected) - if ( - action === "ValidateLogin" && - this.isValidateLoginInvalidCredentials(message, providerErrorCode) - ) { - return { code: ErrorCode.INVALID_CREDENTIALS, status: HttpStatus.UNAUTHORIZED }; - } - - // 2) Not-found style outcomes (expected for some reads) - if (this.isNotFoundError(action, message)) { - return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND }; - } - - // 3) WHMCS API key auth failures: external service/config problem (not end-user auth) - if (this.isAuthenticationError(message, providerErrorCode)) { - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE }; - } - - // 4) Validation failures: treat as bad request - if (this.isValidationError(message, providerErrorCode)) { - return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST }; - } - - // 5) Default: external service error - return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; + /** + * Map action name to resource type for not-found errors + */ + private getNotFoundResource(action: string): "client" | "invoice" | "product" { + if (action === "GetClientsDetails") return "client"; + if (action === "GetInvoice" || action === "UpdateInvoice") return "invoice"; + if (action === "GetClientsProducts") return "product"; + return "client"; // default fallback } private isNotFoundError(action: string, message: string): boolean { diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 665a6e0e..4ede642c 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -1,117 +1,48 @@ import { Module } from "@nestjs/common"; -import { APP_GUARD } from "@nestjs/core"; -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"; + +// Feature modules +import { TokensModule } from "./tokens/tokens.module.js"; +import { OtpModule } from "./otp/otp.module.js"; +import { SessionsModule } from "./sessions/sessions.module.js"; +import { LoginModule } from "./login/login.module.js"; +import { GetStartedModule } from "./get-started/get-started.module.js"; +import { PasswordResetModule } from "./password-reset/password-reset.module.js"; +import { SharedAuthModule } from "./shared/shared-auth.module.js"; + +// External modules import { UsersModule } from "@bff/modules/users/users.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; -import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js"; -import { PermissionsGuard } from "./presentation/http/guards/permissions.guard.js"; -import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js"; -import { TokenStorageService } from "./infra/token/token-storage.service.js"; -import { TokenRevocationService } from "./infra/token/token-revocation.service.js"; -import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; -import { AuthTokenService } from "./infra/token/token.service.js"; -import { TokenGeneratorService } from "./infra/token/token-generator.service.js"; -import { TokenRefreshService } from "./infra/token/token-refresh.service.js"; -import { JoseJwtService } from "./infra/token/jose-jwt.service.js"; -import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js"; -import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js"; -import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard.js"; -import { LoginResultInterceptor } from "./presentation/http/interceptors/login-result.interceptor.js"; -import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service.js"; -import { SignupAccountResolverService } from "./infra/workflows/signup/signup-account-resolver.service.js"; -import { SignupValidationService } from "./infra/workflows/signup/signup-validation.service.js"; -import { SignupWhmcsService } from "./infra/workflows/signup/signup-whmcs.service.js"; -import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-creation.service.js"; -// Get Started flow -import { OtpService } from "./infra/otp/otp.service.js"; -import { GetStartedSessionService } from "./infra/otp/get-started-session.service.js"; -import { GetStartedCoordinator } from "./infra/workflows/get-started-coordinator.service.js"; -import { VerificationWorkflowService } from "./infra/workflows/verification-workflow.service.js"; -import { GuestEligibilityWorkflowService } from "./infra/workflows/guest-eligibility-workflow.service.js"; -import { NewCustomerSignupWorkflowService } from "./infra/workflows/new-customer-signup-workflow.service.js"; -import { SfCompletionWorkflowService } from "./infra/workflows/sf-completion-workflow.service.js"; -import { WhmcsMigrationWorkflowService } from "./infra/workflows/whmcs-migration-workflow.service.js"; -import { - ResolveSalesforceAccountStep, - CreateWhmcsClientStep, - CreatePortalUserStep, - UpdateSalesforceFlagsStep, - GenerateAuthResultStep, - CreateEligibilityCaseStep, -} from "./infra/workflows/steps/index.js"; -import { GetStartedController } from "./presentation/http/get-started.controller.js"; import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; -// Login OTP flow -import { LoginSessionService } from "./infra/login/login-session.service.js"; -import { LoginOtpWorkflowService } from "./infra/workflows/login-otp-workflow.service.js"; -// Trusted device -import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.service.js"; + +// Orchestrator-level services (not owned by any feature module) +import { AuthOrchestrator } from "./application/auth-orchestrator.service.js"; +import { AuthHealthService } from "./application/auth-health.service.js"; +import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js"; + +// Controller +import { AuthController } from "./presentation/http/auth.controller.js"; @Module({ - imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule, WorkflowModule], - controllers: [AuthController, GetStartedController], - providers: [ - // Application services - AuthOrchestrator, - AuthHealthService, - AuthLoginService, - // Token services - TokenBlacklistService, - TokenStorageService, - TokenRevocationService, - TokenGeneratorService, - TokenRefreshService, - AuthTokenService, - JoseJwtService, - PasswordResetTokenService, - // Signup shared services (reused by get-started workflows) - SignupAccountResolverService, - SignupValidationService, - SignupWhmcsService, - SignupUserCreationService, - // Other workflow services - PasswordWorkflowService, - WhmcsLinkWorkflowService, - // Get Started flow services - OtpService, - GetStartedSessionService, - GetStartedCoordinator, - VerificationWorkflowService, - GuestEligibilityWorkflowService, - NewCustomerSignupWorkflowService, - SfCompletionWorkflowService, - WhmcsMigrationWorkflowService, - // Shared step services - ResolveSalesforceAccountStep, - CreateWhmcsClientStep, - CreatePortalUserStep, - UpdateSalesforceFlagsStep, - GenerateAuthResultStep, - CreateEligibilityCaseStep, - // Login OTP flow services - LoginSessionService, - LoginOtpWorkflowService, - // Trusted device - TrustedDeviceService, - // Guards and interceptors - FailedLoginThrottleGuard, - AuthRateLimitService, - LoginResultInterceptor, - PermissionsGuard, - { - provide: APP_GUARD, - useClass: GlobalAuthGuard, - }, - { - provide: APP_GUARD, - useClass: PermissionsGuard, - }, + imports: [ + // Auth feature modules + TokensModule, + OtpModule, + SessionsModule, + LoginModule, + GetStartedModule, + PasswordResetModule, + SharedAuthModule, + // External modules + UsersModule, + MappingsModule, + IntegrationsModule, + CacheModule, + WorkflowModule, ], - exports: [AuthOrchestrator, TokenBlacklistService, AuthTokenService, PermissionsGuard], + controllers: [AuthController], + providers: [AuthOrchestrator, AuthHealthService, WhmcsLinkWorkflowService], + exports: [AuthOrchestrator, TokensModule, SharedAuthModule], }) export class AuthModule {} diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index 0d8d35b1..fac47a77 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -7,7 +7,7 @@ import { } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ZodError } from "zod"; -import { PrismaService } from "@bff/infra/database/prisma.service.js"; +import { IdMappingRepository } from "@bff/infra/database/repositories/index.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { MappingCacheService } from "./cache/mapping-cache.service.js"; import type { @@ -31,7 +31,7 @@ import { mapPrismaMappingToDomain } from "@bff/infra/mappers/index.js"; @Injectable() export class MappingsService { constructor( - private readonly prisma: PrismaService, + private readonly idMappingRepository: IdMappingRepository, private readonly cacheService: MappingCacheService, @Inject(Logger) private readonly logger: Logger ) {} @@ -55,14 +55,12 @@ export class MappingsService { const sanitizedRequest = validatedRequest; const [byUser, byWhmcs, bySf] = await Promise.all([ - this.prisma.idMapping.findUnique({ where: { userId: sanitizedRequest.userId } }), - this.prisma.idMapping.findUnique({ - where: { whmcsClientId: sanitizedRequest.whmcsClientId }, + this.idMappingRepository.findById({ userId: sanitizedRequest.userId }), + this.idMappingRepository.findById({ + whmcsClientId: sanitizedRequest.whmcsClientId, }), sanitizedRequest.sfAccountId - ? this.prisma.idMapping.findFirst({ - where: { sfAccountId: sanitizedRequest.sfAccountId }, - }) + ? this.idMappingRepository.findOne({ sfAccountId: sanitizedRequest.sfAccountId }) : Promise.resolve(null), ]); @@ -84,7 +82,7 @@ export class MappingsService { whmcsClientId: sanitizedRequest.whmcsClientId, sfAccountId: sanitizedRequest.sfAccountId, }; - created = await this.prisma.idMapping.create({ data: prismaData }); + created = await this.idMappingRepository.create(prismaData); } catch (e) { const msg = extractErrorMessage(e); if (msg.includes("P2002") || /unique/i.test(msg)) { @@ -125,7 +123,7 @@ export class MappingsService { return cached; } - const dbMapping = await this.prisma.idMapping.findFirst({ where: { sfAccountId } }); + const dbMapping = await this.idMappingRepository.findOne({ sfAccountId }); if (!dbMapping) { this.logger.debug(`No mapping found for SF account ${sfAccountId}`); return null; @@ -159,7 +157,7 @@ export class MappingsService { return cached; } - const dbMapping = await this.prisma.idMapping.findUnique({ where: { userId } }); + const dbMapping = await this.idMappingRepository.findById({ userId }); if (!dbMapping) { this.logger.debug(`No mapping found for user ${userId}`); return null; @@ -193,7 +191,7 @@ export class MappingsService { return cached; } - const dbMapping = await this.prisma.idMapping.findUnique({ where: { whmcsClientId } }); + const dbMapping = await this.idMappingRepository.findById({ whmcsClientId }); if (!dbMapping) { this.logger.debug(`No mapping found for WHMCS client ${whmcsClientId}`); return null; @@ -257,10 +255,7 @@ export class MappingsService { sfAccountId: sanitizedUpdates.sfAccountId, }), }; - const updated = await this.prisma.idMapping.update({ - where: { userId }, - data: prismaUpdateData, - }); + const updated = await this.idMappingRepository.update({ userId }, prismaUpdateData); const newMapping = mapPrismaMappingToDomain(updated); @@ -291,7 +286,7 @@ export class MappingsService { this.logger.debug("Deletion warnings", { warnings: validation.warnings }); } - await this.prisma.idMapping.delete({ where: { userId } }); + await this.idMappingRepository.delete({ userId }); await this.cacheService.deleteMapping(existing); this.logger.log(`Deleted mapping for user ${userId}`, { whmcsClientId: existing.whmcsClientId, @@ -325,8 +320,7 @@ export class MappingsService { // Note: hasSfMapping filter is deprecated - sfAccountId is now required on all mappings // hasSfMapping: true matches all records, hasSfMapping: false matches none - const dbMappings = await this.prisma.idMapping.findMany({ - where: whereClause, + const dbMappings = await this.idMappingRepository.findMany(whereClause, { orderBy: { createdAt: "desc" }, }); const mappings = dbMappings.map(mapping => mapPrismaMappingToDomain(mapping)); @@ -346,8 +340,8 @@ export class MappingsService { // Since sfAccountId is now required, all mappings have SF accounts // and completeMappings equals whmcsMappings (orphanedMappings is always 0) const [totalCount, whmcsCount] = await Promise.all([ - this.prisma.idMapping.count(), - this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }), + this.idMappingRepository.count(), + this.idMappingRepository.count({ whmcsClientId: { gt: 0 } }), ]); const stats: MappingStats = { @@ -369,11 +363,7 @@ export class MappingsService { try { const cached = await this.cacheService.getByUserId(userId); if (cached) return true; - const mapping = await this.prisma.idMapping.findUnique({ - where: { userId }, - select: { userId: true }, - }); - return mapping !== null; + return this.idMappingRepository.exists({ userId }); } catch (error) { this.logger.error(`Failed to check mapping for user ${userId}`, { error: extractErrorMessage(error), diff --git a/apps/bff/src/modules/voice-options/services/voice-options.service.ts b/apps/bff/src/modules/voice-options/services/voice-options.service.ts index ff17b230..0ed153ae 100644 --- a/apps/bff/src/modules/voice-options/services/voice-options.service.ts +++ b/apps/bff/src/modules/voice-options/services/voice-options.service.ts @@ -1,5 +1,5 @@ import { Injectable, Inject } from "@nestjs/common"; -import { PrismaService } from "@bff/infra/database/prisma.service.js"; +import { SimVoiceOptionsRepository } from "@bff/infra/database/repositories/index.js"; import { Logger } from "nestjs-pino"; export interface VoiceOptionsSettings { @@ -12,7 +12,7 @@ export interface VoiceOptionsSettings { @Injectable() export class VoiceOptionsService { constructor( - private readonly prisma: PrismaService, + private readonly simVoiceOptionsRepository: SimVoiceOptionsRepository, @Inject(Logger) private readonly logger: Logger ) {} @@ -22,9 +22,7 @@ export class VoiceOptionsService { */ async getVoiceOptions(account: string): Promise { try { - const options = await this.prisma.simVoiceOptions.findUnique({ - where: { account }, - }); + const options = await this.simVoiceOptionsRepository.findById({ account }); if (!options) { this.logger.debug(`No voice options found in database for account ${account}`); @@ -56,7 +54,7 @@ export class VoiceOptionsService { } ): Promise { try { - await this.prisma.simVoiceOptions.upsert({ + await this.simVoiceOptionsRepository.upsert({ where: { account }, create: { account, @@ -118,9 +116,7 @@ export class VoiceOptionsService { */ async deleteVoiceOptions(account: string): Promise { try { - await this.prisma.simVoiceOptions.delete({ - where: { account }, - }); + await this.simVoiceOptionsRepository.delete({ account }); this.logger.log(`Deleted voice options for account ${account}`); } catch (error) { diff --git a/apps/portal/src/features/support/views/SupportCasesView.tsx b/apps/portal/src/features/support/views/SupportCasesView.tsx index 2d3bb047..b5477c8e 100644 --- a/apps/portal/src/features/support/views/SupportCasesView.tsx +++ b/apps/portal/src/features/support/views/SupportCasesView.tsx @@ -45,10 +45,10 @@ export function SupportCasesView() { const queryFilters = useMemo(() => { const nextFilters: SupportCaseFilter = {}; if (statusFilter !== "all") { - nextFilters.status = statusFilter; + nextFilters.status = statusFilter as SupportCaseFilter["status"]; } if (priorityFilter !== "all") { - nextFilters.priority = priorityFilter; + nextFilters.priority = priorityFilter as SupportCaseFilter["priority"]; } if (deferredSearchTerm.trim()) { nextFilters.search = deferredSearchTerm.trim(); diff --git a/packages/domain/common/index.ts b/packages/domain/common/index.ts index 8435ccb3..4f18e26e 100644 --- a/packages/domain/common/index.ts +++ b/packages/domain/common/index.ts @@ -8,3 +8,11 @@ export * from "./types.js"; export * from "./schema.js"; export * from "./validation.js"; export * from "./errors.js"; +export { + WhmcsProviderError, + SalesforceProviderError, + FreebitProviderError, + type WhmcsProviderErrorCode, + type SalesforceProviderErrorCode, + type FreebitProviderErrorCode, +} from "./provider-errors.js"; diff --git a/packages/domain/customer/contract.ts b/packages/domain/customer/contract.ts index 1216f659..9884951d 100644 --- a/packages/domain/customer/contract.ts +++ b/packages/domain/customer/contract.ts @@ -32,8 +32,9 @@ export interface SalesforceAccountFieldMap { } /** - * Salesforce account record structure - * Raw structure from Salesforce API + * Raw Salesforce record — intentionally permissive. + * The Salesforce API returns org-specific fields that vary by configuration. + * Domain mappers validate specific fields; unknown fields are ignored. */ export interface SalesforceAccountRecord { Id: string; @@ -43,8 +44,9 @@ export interface SalesforceAccountRecord { } /** - * Salesforce contact record structure - * Raw structure from Salesforce API + * Raw Salesforce record — intentionally permissive. + * The Salesforce API returns org-specific fields that vary by configuration. + * Domain mappers validate specific fields; unknown fields are ignored. */ export interface SalesforceContactRecord { Id: string; diff --git a/packages/domain/support/schema.ts b/packages/domain/support/schema.ts index e7ecd95b..e2be0081 100644 --- a/packages/domain/support/schema.ts +++ b/packages/domain/support/schema.ts @@ -45,9 +45,9 @@ export const supportCaseSchema = z.object({ id: z.string().min(15).max(18), caseNumber: z.string(), subject: z.string().min(1), - status: z.string(), // Allow any status from Salesforce - priority: z.string(), // Allow any priority from Salesforce - category: z.string().nullable(), // Maps to Salesforce Type field + status: supportCaseStatusSchema, + priority: supportCasePrioritySchema, + category: supportCaseCategorySchema.nullable(), createdAt: z.string(), updatedAt: z.string(), closedAt: z.string().nullable(), @@ -68,9 +68,9 @@ export const supportCaseListSchema = z.object({ export const supportCaseFilterSchema = z .object({ - status: z.string().optional(), - priority: z.string().optional(), - category: z.string().optional(), + status: supportCaseStatusSchema.optional(), + priority: supportCasePrioritySchema.optional(), + category: supportCaseCategorySchema.optional(), search: z.string().trim().min(1).optional(), }) .default({});