From 49d6d2197455fca82dc5df962afee52ff4cab1a7 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 25 Feb 2026 14:33:19 +0900 Subject: [PATCH] fix: restore lost error classifications and address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Salesforce: add validation/duplicate/access/storage error classes and restore classification in error handler (400/409/403/503 vs generic 502) - Freebit: add auth/rate-limit/validation/network error classes and restore result-code-based classification (215, 381, 382) - Portal: replace unsafe string→enum casts with typed state variables - BaseRepository: narrow orderBy from unknown to Record - WHMCS: narrow WhmcsNotFoundError.providerCode from string to union type - Remove unused UnitOfWork service from PrismaModule --- .../bff/src/infra/database/base.repository.ts | 2 +- apps/bff/src/infra/database/prisma.module.ts | 3 - .../common/errors/freebit.errors.ts | 44 +++++++++++++ .../common/errors/salesforce.errors.ts | 44 +++++++++++++ .../common/errors/whmcs.errors.ts | 10 ++- .../services/freebit-error-handler.service.ts | 61 +++++++++++++++---- .../salesforce-error-handler.service.ts | 34 +++++++++++ .../support/views/SupportCasesView.tsx | 18 +++--- packages/domain/common/provider-errors.ts | 19 ++++++ 9 files changed, 209 insertions(+), 26 deletions(-) diff --git a/apps/bff/src/infra/database/base.repository.ts b/apps/bff/src/infra/database/base.repository.ts index 786d025d..2d1fc088 100644 --- a/apps/bff/src/infra/database/base.repository.ts +++ b/apps/bff/src/infra/database/base.repository.ts @@ -55,7 +55,7 @@ export abstract class BaseRepository } ): Promise { return this.d.findMany({ ...(where !== undefined && { where }), diff --git a/apps/bff/src/infra/database/prisma.module.ts b/apps/bff/src/infra/database/prisma.module.ts index 42283029..b5e5c08a 100644 --- a/apps/bff/src/infra/database/prisma.module.ts +++ b/apps/bff/src/infra/database/prisma.module.ts @@ -2,7 +2,6 @@ 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"; @@ -13,7 +12,6 @@ import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repo PrismaService, TransactionService, DistributedTransactionService, - UnitOfWork, IdMappingRepository, AuditLogRepository, SimVoiceOptionsRepository, @@ -22,7 +20,6 @@ import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repo PrismaService, TransactionService, DistributedTransactionService, - UnitOfWork, IdMappingRepository, AuditLogRepository, SimVoiceOptionsRepository, diff --git a/apps/bff/src/integrations/common/errors/freebit.errors.ts b/apps/bff/src/integrations/common/errors/freebit.errors.ts index 1cf9a176..7a6ddf16 100644 --- a/apps/bff/src/integrations/common/errors/freebit.errors.ts +++ b/apps/bff/src/integrations/common/errors/freebit.errors.ts @@ -34,3 +34,47 @@ export class FreebitTimeoutError extends BaseProviderError { super("Freebit request timed out", providerMessage, cause); } } + +export class FreebitAuthError extends BaseProviderError { + readonly provider = "freebit" as const; + readonly providerCode = FreebitProviderError.AUTH_FAILED; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.SERVICE_UNAVAILABLE; + + constructor(providerMessage: string, cause?: unknown) { + super("Freebit authentication failed", providerMessage, cause); + } +} + +export class FreebitRateLimitError extends BaseProviderError { + readonly provider = "freebit" as const; + readonly providerCode = FreebitProviderError.RATE_LIMITED; + readonly domainErrorCode = ErrorCode.RATE_LIMITED; + readonly httpStatus = HttpStatus.TOO_MANY_REQUESTS; + + constructor(providerMessage: string, cause?: unknown) { + super("Freebit rate limit exceeded", providerMessage, cause); + } +} + +export class FreebitValidationError extends BaseProviderError { + readonly provider = "freebit" as const; + readonly providerCode = FreebitProviderError.VALIDATION_ERROR; + readonly domainErrorCode = ErrorCode.VALIDATION_FAILED; + readonly httpStatus = HttpStatus.BAD_REQUEST; + + constructor(providerMessage: string, cause?: unknown) { + super("Freebit validation failed", providerMessage, cause); + } +} + +export class FreebitNetworkError extends BaseProviderError { + readonly provider = "freebit" as const; + readonly providerCode = FreebitProviderError.NETWORK_ERROR; + readonly domainErrorCode = ErrorCode.NETWORK_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(providerMessage: string, cause?: unknown) { + super("Freebit network error", providerMessage, cause); + } +} diff --git a/apps/bff/src/integrations/common/errors/salesforce.errors.ts b/apps/bff/src/integrations/common/errors/salesforce.errors.ts index 3e9cb75a..8ea9bf94 100644 --- a/apps/bff/src/integrations/common/errors/salesforce.errors.ts +++ b/apps/bff/src/integrations/common/errors/salesforce.errors.ts @@ -67,3 +67,47 @@ export class SalesforceRateLimitError extends BaseProviderError { super("Salesforce rate limit exceeded", providerMessage, cause); } } + +export class SalesforceValidationError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.VALIDATION_ERROR; + readonly domainErrorCode = ErrorCode.VALIDATION_FAILED; + readonly httpStatus = HttpStatus.BAD_REQUEST; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce validation failed", providerMessage, cause); + } +} + +export class SalesforceDuplicateError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.DUPLICATE_RECORD; + readonly domainErrorCode = ErrorCode.ACCOUNT_EXISTS; + readonly httpStatus = HttpStatus.CONFLICT; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce duplicate record", providerMessage, cause); + } +} + +export class SalesforceAccessError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.ACCESS_DENIED; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.FORBIDDEN; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce insufficient access", providerMessage, cause); + } +} + +export class SalesforceStorageLimitError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.STORAGE_LIMIT; + readonly domainErrorCode = ErrorCode.SERVICE_UNAVAILABLE; + readonly httpStatus = HttpStatus.SERVICE_UNAVAILABLE; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce storage limit exceeded", providerMessage, cause); + } +} diff --git a/apps/bff/src/integrations/common/errors/whmcs.errors.ts b/apps/bff/src/integrations/common/errors/whmcs.errors.ts index 122b5c19..c41173ae 100644 --- a/apps/bff/src/integrations/common/errors/whmcs.errors.ts +++ b/apps/bff/src/integrations/common/errors/whmcs.errors.ts @@ -1,6 +1,10 @@ import { HttpStatus } from "@nestjs/common"; -import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; -import { WhmcsProviderError } from "@customer-portal/domain/common"; +import { + ErrorCode, + type ErrorCodeType, + WhmcsProviderError, + type WhmcsProviderErrorCode, +} from "@customer-portal/domain/common"; import { BaseProviderError } from "./base-provider.error.js"; export class WhmcsApiError extends BaseProviderError { @@ -16,7 +20,7 @@ export class WhmcsApiError extends BaseProviderError { export class WhmcsNotFoundError extends BaseProviderError { readonly provider = "whmcs" as const; - readonly providerCode: string; + readonly providerCode: WhmcsProviderErrorCode; readonly domainErrorCode = ErrorCode.NOT_FOUND; readonly httpStatus = HttpStatus.NOT_FOUND; 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 c2fb3cb9..3572f75b 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 @@ -8,6 +8,10 @@ import { FreebitApiError, FreebitAccountNotFoundError, FreebitTimeoutError, + FreebitAuthError, + FreebitRateLimitError, + FreebitValidationError, + FreebitNetworkError, } from "@bff/integrations/common/errors/index.js"; /** @@ -36,6 +40,22 @@ export class FreebitErrorHandlerService { handleRequestError(error: unknown, context: string): never { const message = extractErrorMessage(error); + // Check for auth errors (FreebitError-aware) + if (error instanceof FreebitError && error.isAuthError?.()) { + throw new FreebitAuthError(error.message, error); + } + if (this.isAuthError(message)) { + throw new FreebitAuthError(message, error); + } + + // Check for rate limiting (FreebitError-aware) + if (error instanceof FreebitError && error.isRateLimitError?.()) { + throw new FreebitRateLimitError(error.message, error); + } + if (this.isRateLimitError(message)) { + throw new FreebitRateLimitError(message, error); + } + // Check for timeout if (this.isTimeoutError(message)) { throw new FreebitTimeoutError(message, error); @@ -43,17 +63,7 @@ export class FreebitErrorHandlerService { // Check for network errors if (this.isNetworkError(message)) { - throw new FreebitApiError("Freebit network error", message, error); - } - - // Check for rate limiting - if (this.isRateLimitError(message)) { - throw new FreebitApiError("Freebit rate limit exceeded", message, error); - } - - // Check for auth errors - if (this.isAuthError(message)) { - throw new FreebitApiError("Freebit authentication failed", message, error); + throw new FreebitNetworkError(message, error); } // Re-throw if already a BaseProviderError or DomainHttpException @@ -72,18 +82,47 @@ export class FreebitErrorHandlerService { * Map FreebitError to typed provider error and throw */ private throwTypedFreebitError(error: FreebitError): never { + const resultCode = String(error.resultCode || ""); + const statusCode = String(error.statusCode || ""); const message = error.message.toLowerCase(); + // Authentication errors + if (error.isAuthError?.()) { + throw new FreebitAuthError(error.message, error); + } + + // Rate limit errors + if (error.isRateLimitError?.()) { + throw new FreebitRateLimitError(error.message, error); + } + // Not found errors if (message.includes("account not found") || message.includes("no such account")) { throw new FreebitAccountNotFoundError(error.message, error); } + // Validation result codes (215 = plan change, 381/382 = network type change) + if ( + resultCode === "215" || + statusCode === "215" || + resultCode === "381" || + statusCode === "381" || + resultCode === "382" || + statusCode === "382" + ) { + throw new FreebitValidationError(error.message, error); + } + // Timeout if (this.isTimeoutError(error.message)) { throw new FreebitTimeoutError(error.message, error); } + // Retryable server errors + if (error.isRetryable?.()) { + throw new FreebitApiError("Freebit server error", error.message, error); + } + // 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 61efd845..921cdc01 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 @@ -9,6 +9,10 @@ import { SalesforceTimeoutError, SalesforceNetworkError, SalesforceRateLimitError, + SalesforceValidationError, + SalesforceDuplicateError, + SalesforceAccessError, + SalesforceStorageLimitError, } from "@bff/integrations/common/errors/index.js"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; @@ -61,11 +65,41 @@ export class SalesforceErrorHandlerService { throw new SalesforceQueryError(message); } + // Validation errors + if ( + sfErrorCode === "REQUIRED_FIELD_MISSING" || + sfErrorCode === "INVALID_FIELD" || + sfErrorCode === "MALFORMED_ID" || + sfErrorCode === "FIELD_CUSTOM_VALIDATION_EXCEPTION" || + sfErrorCode === "FIELD_INTEGRITY_EXCEPTION" + ) { + throw new SalesforceValidationError(message); + } + + // Duplicate detection + if (sfErrorCode === "DUPLICATE_VALUE" || sfErrorCode === "DUPLICATE_EXTERNAL_ID") { + throw new SalesforceDuplicateError(message); + } + + // Insufficient access + if ( + sfErrorCode === "INSUFFICIENT_ACCESS" || + sfErrorCode === "INSUFFICIENT_ACCESS_OR_READONLY" || + sfErrorCode === "CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY" + ) { + throw new SalesforceAccessError(message); + } + // Rate limiting if (sfErrorCode === "REQUEST_LIMIT_EXCEEDED" || sfErrorCode === "TOO_MANY_REQUESTS") { throw new SalesforceRateLimitError(message); } + // Storage limit + if (sfErrorCode === "STORAGE_LIMIT_EXCEEDED") { + throw new SalesforceStorageLimitError(message); + } + // Default throw new SalesforceApiError(`Salesforce ${context} failed`, message); } diff --git a/apps/portal/src/features/support/views/SupportCasesView.tsx b/apps/portal/src/features/support/views/SupportCasesView.tsx index b5477c8e..e6edb12d 100644 --- a/apps/portal/src/features/support/views/SupportCasesView.tsx +++ b/apps/portal/src/features/support/views/SupportCasesView.tsx @@ -38,17 +38,19 @@ import { formatIsoRelative } from "@/shared/utils"; export function SupportCasesView() { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [priorityFilter, setPriorityFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [priorityFilter, setPriorityFilter] = useState( + "all" + ); const deferredSearchTerm = useDeferredValue(searchTerm); const queryFilters = useMemo(() => { const nextFilters: SupportCaseFilter = {}; if (statusFilter !== "all") { - nextFilters.status = statusFilter as SupportCaseFilter["status"]; + nextFilters.status = statusFilter; } if (priorityFilter !== "all") { - nextFilters.priority = priorityFilter as SupportCaseFilter["priority"]; + nextFilters.priority = priorityFilter; } if (deferredSearchTerm.trim()) { nextFilters.search = deferredSearchTerm.trim(); @@ -145,15 +147,15 @@ export function SupportCasesView() { searchValue={searchTerm} onSearchChange={setSearchTerm} searchPlaceholder="Search by case number or subject..." - filterValue={statusFilter} - onFilterChange={setStatusFilter} + filterValue={statusFilter ?? "all"} + onFilterChange={v => setStatusFilter(v as SupportCaseFilter["status"] | "all")} filterOptions={statusFilterOptions} filterLabel="Filter by status" > {/* Priority filter as additional child */} setPriorityFilter(v as SupportCaseFilter["priority"] | "all")} options={priorityFilterOptions} label="Filter by priority" width="w-40" diff --git a/packages/domain/common/provider-errors.ts b/packages/domain/common/provider-errors.ts index cb4e009d..f6a90701 100644 --- a/packages/domain/common/provider-errors.ts +++ b/packages/domain/common/provider-errors.ts @@ -39,6 +39,12 @@ export const SalesforceProviderError = { QUERY_ERROR: "SF_QUERY_ERROR", RECORD_NOT_FOUND: "SF_RECORD_NOT_FOUND", + // Validation + VALIDATION_ERROR: "SF_VALIDATION_ERROR", + DUPLICATE_RECORD: "SF_DUPLICATE_RECORD", + ACCESS_DENIED: "SF_ACCESS_DENIED", + STORAGE_LIMIT: "SF_STORAGE_LIMIT", + // Network/Infrastructure TIMEOUT: "SF_TIMEOUT", NETWORK_ERROR: "SF_NETWORK_ERROR", @@ -52,8 +58,21 @@ export type SalesforceProviderErrorCode = (typeof SalesforceProviderError)[keyof typeof SalesforceProviderError]; export const FreebitProviderError = { + // Not found ACCOUNT_NOT_FOUND: "FREEBIT_ACCOUNT_NOT_FOUND", + + // Authentication + AUTH_FAILED: "FREEBIT_AUTH_FAILED", + + // Validation + VALIDATION_ERROR: "FREEBIT_VALIDATION_ERROR", + + // Network/Infrastructure TIMEOUT: "FREEBIT_TIMEOUT", + NETWORK_ERROR: "FREEBIT_NETWORK_ERROR", + RATE_LIMITED: "FREEBIT_RATE_LIMITED", + + // Generic API_ERROR: "FREEBIT_API_ERROR", } as const;