fix: restore lost error classifications and address code review findings

- 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<string, "asc"|"desc">
- WHMCS: narrow WhmcsNotFoundError.providerCode from string to union type
- Remove unused UnitOfWork service from PrismaModule
This commit is contained in:
barsa 2026-02-25 14:33:19 +09:00
parent 98beed85c7
commit 49d6d21974
9 changed files with 209 additions and 26 deletions

View File

@ -55,7 +55,7 @@ export abstract class BaseRepository<TEntity, TCreateInput, TUpdateInput, TWhere
async findMany( async findMany(
where?: TWhere, where?: TWhere,
options?: { skip?: number; take?: number; orderBy?: unknown } options?: { skip?: number; take?: number; orderBy?: Record<string, "asc" | "desc"> }
): Promise<TEntity[]> { ): Promise<TEntity[]> {
return this.d.findMany({ return this.d.findMany({
...(where !== undefined && { where }), ...(where !== undefined && { where }),

View File

@ -2,7 +2,6 @@ import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service.js"; import { PrismaService } from "./prisma.service.js";
import { TransactionService } from "./services/transaction.service.js"; import { TransactionService } from "./services/transaction.service.js";
import { DistributedTransactionService } from "./services/distributed-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 { IdMappingRepository } from "./repositories/id-mapping.repository.js";
import { AuditLogRepository } from "./repositories/audit-log.repository.js"; import { AuditLogRepository } from "./repositories/audit-log.repository.js";
import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repository.js"; import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repository.js";
@ -13,7 +12,6 @@ import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repo
PrismaService, PrismaService,
TransactionService, TransactionService,
DistributedTransactionService, DistributedTransactionService,
UnitOfWork,
IdMappingRepository, IdMappingRepository,
AuditLogRepository, AuditLogRepository,
SimVoiceOptionsRepository, SimVoiceOptionsRepository,
@ -22,7 +20,6 @@ import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repo
PrismaService, PrismaService,
TransactionService, TransactionService,
DistributedTransactionService, DistributedTransactionService,
UnitOfWork,
IdMappingRepository, IdMappingRepository,
AuditLogRepository, AuditLogRepository,
SimVoiceOptionsRepository, SimVoiceOptionsRepository,

View File

@ -34,3 +34,47 @@ export class FreebitTimeoutError extends BaseProviderError {
super("Freebit request timed out", providerMessage, cause); 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);
}
}

View File

@ -67,3 +67,47 @@ export class SalesforceRateLimitError extends BaseProviderError {
super("Salesforce rate limit exceeded", providerMessage, cause); 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);
}
}

View File

@ -1,6 +1,10 @@
import { HttpStatus } from "@nestjs/common"; import { HttpStatus } from "@nestjs/common";
import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; import {
import { WhmcsProviderError } from "@customer-portal/domain/common"; ErrorCode,
type ErrorCodeType,
WhmcsProviderError,
type WhmcsProviderErrorCode,
} from "@customer-portal/domain/common";
import { BaseProviderError } from "./base-provider.error.js"; import { BaseProviderError } from "./base-provider.error.js";
export class WhmcsApiError extends BaseProviderError { export class WhmcsApiError extends BaseProviderError {
@ -16,7 +20,7 @@ export class WhmcsApiError extends BaseProviderError {
export class WhmcsNotFoundError extends BaseProviderError { export class WhmcsNotFoundError extends BaseProviderError {
readonly provider = "whmcs" as const; readonly provider = "whmcs" as const;
readonly providerCode: string; readonly providerCode: WhmcsProviderErrorCode;
readonly domainErrorCode = ErrorCode.NOT_FOUND; readonly domainErrorCode = ErrorCode.NOT_FOUND;
readonly httpStatus = HttpStatus.NOT_FOUND; readonly httpStatus = HttpStatus.NOT_FOUND;

View File

@ -8,6 +8,10 @@ import {
FreebitApiError, FreebitApiError,
FreebitAccountNotFoundError, FreebitAccountNotFoundError,
FreebitTimeoutError, FreebitTimeoutError,
FreebitAuthError,
FreebitRateLimitError,
FreebitValidationError,
FreebitNetworkError,
} from "@bff/integrations/common/errors/index.js"; } from "@bff/integrations/common/errors/index.js";
/** /**
@ -36,6 +40,22 @@ export class FreebitErrorHandlerService {
handleRequestError(error: unknown, context: string): never { handleRequestError(error: unknown, context: string): never {
const message = extractErrorMessage(error); 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 // Check for timeout
if (this.isTimeoutError(message)) { if (this.isTimeoutError(message)) {
throw new FreebitTimeoutError(message, error); throw new FreebitTimeoutError(message, error);
@ -43,17 +63,7 @@ export class FreebitErrorHandlerService {
// Check for network errors // Check for network errors
if (this.isNetworkError(message)) { if (this.isNetworkError(message)) {
throw new FreebitApiError("Freebit network error", message, error); throw new FreebitNetworkError(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);
} }
// Re-throw if already a BaseProviderError or DomainHttpException // Re-throw if already a BaseProviderError or DomainHttpException
@ -72,18 +82,47 @@ export class FreebitErrorHandlerService {
* Map FreebitError to typed provider error and throw * Map FreebitError to typed provider error and throw
*/ */
private throwTypedFreebitError(error: FreebitError): never { private throwTypedFreebitError(error: FreebitError): never {
const resultCode = String(error.resultCode || "");
const statusCode = String(error.statusCode || "");
const message = error.message.toLowerCase(); 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 // Not found errors
if (message.includes("account not found") || message.includes("no such account")) { if (message.includes("account not found") || message.includes("no such account")) {
throw new FreebitAccountNotFoundError(error.message, error); 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 // Timeout
if (this.isTimeoutError(error.message)) { if (this.isTimeoutError(error.message)) {
throw new FreebitTimeoutError(error.message, error); 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 // Default: generic Freebit API error
throw new FreebitApiError("Freebit API error", error.message, error); throw new FreebitApiError("Freebit API error", error.message, error);
} }

View File

@ -9,6 +9,10 @@ import {
SalesforceTimeoutError, SalesforceTimeoutError,
SalesforceNetworkError, SalesforceNetworkError,
SalesforceRateLimitError, SalesforceRateLimitError,
SalesforceValidationError,
SalesforceDuplicateError,
SalesforceAccessError,
SalesforceStorageLimitError,
} from "@bff/integrations/common/errors/index.js"; } from "@bff/integrations/common/errors/index.js";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
@ -61,11 +65,41 @@ export class SalesforceErrorHandlerService {
throw new SalesforceQueryError(message); 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 // Rate limiting
if (sfErrorCode === "REQUEST_LIMIT_EXCEEDED" || sfErrorCode === "TOO_MANY_REQUESTS") { if (sfErrorCode === "REQUEST_LIMIT_EXCEEDED" || sfErrorCode === "TOO_MANY_REQUESTS") {
throw new SalesforceRateLimitError(message); throw new SalesforceRateLimitError(message);
} }
// Storage limit
if (sfErrorCode === "STORAGE_LIMIT_EXCEEDED") {
throw new SalesforceStorageLimitError(message);
}
// Default // Default
throw new SalesforceApiError(`Salesforce ${context} failed`, message); throw new SalesforceApiError(`Salesforce ${context} failed`, message);
} }

View File

@ -38,17 +38,19 @@ import { formatIsoRelative } from "@/shared/utils";
export function SupportCasesView() { export function SupportCasesView() {
const router = useRouter(); const router = useRouter();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<SupportCaseFilter["status"] | "all">("all");
const [priorityFilter, setPriorityFilter] = useState<string>("all"); const [priorityFilter, setPriorityFilter] = useState<SupportCaseFilter["priority"] | "all">(
"all"
);
const deferredSearchTerm = useDeferredValue(searchTerm); const deferredSearchTerm = useDeferredValue(searchTerm);
const queryFilters = useMemo(() => { const queryFilters = useMemo(() => {
const nextFilters: SupportCaseFilter = {}; const nextFilters: SupportCaseFilter = {};
if (statusFilter !== "all") { if (statusFilter !== "all") {
nextFilters.status = statusFilter as SupportCaseFilter["status"]; nextFilters.status = statusFilter;
} }
if (priorityFilter !== "all") { if (priorityFilter !== "all") {
nextFilters.priority = priorityFilter as SupportCaseFilter["priority"]; nextFilters.priority = priorityFilter;
} }
if (deferredSearchTerm.trim()) { if (deferredSearchTerm.trim()) {
nextFilters.search = deferredSearchTerm.trim(); nextFilters.search = deferredSearchTerm.trim();
@ -145,15 +147,15 @@ export function SupportCasesView() {
searchValue={searchTerm} searchValue={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
searchPlaceholder="Search by case number or subject..." searchPlaceholder="Search by case number or subject..."
filterValue={statusFilter} filterValue={statusFilter ?? "all"}
onFilterChange={setStatusFilter} onFilterChange={v => setStatusFilter(v as SupportCaseFilter["status"] | "all")}
filterOptions={statusFilterOptions} filterOptions={statusFilterOptions}
filterLabel="Filter by status" filterLabel="Filter by status"
> >
{/* Priority filter as additional child */} {/* Priority filter as additional child */}
<FilterDropdown <FilterDropdown
value={priorityFilter} value={priorityFilter ?? "all"}
onChange={setPriorityFilter} onChange={v => setPriorityFilter(v as SupportCaseFilter["priority"] | "all")}
options={priorityFilterOptions} options={priorityFilterOptions}
label="Filter by priority" label="Filter by priority"
width="w-40" width="w-40"

View File

@ -39,6 +39,12 @@ export const SalesforceProviderError = {
QUERY_ERROR: "SF_QUERY_ERROR", QUERY_ERROR: "SF_QUERY_ERROR",
RECORD_NOT_FOUND: "SF_RECORD_NOT_FOUND", 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 // Network/Infrastructure
TIMEOUT: "SF_TIMEOUT", TIMEOUT: "SF_TIMEOUT",
NETWORK_ERROR: "SF_NETWORK_ERROR", NETWORK_ERROR: "SF_NETWORK_ERROR",
@ -52,8 +58,21 @@ export type SalesforceProviderErrorCode =
(typeof SalesforceProviderError)[keyof typeof SalesforceProviderError]; (typeof SalesforceProviderError)[keyof typeof SalesforceProviderError];
export const FreebitProviderError = { export const FreebitProviderError = {
// Not found
ACCOUNT_NOT_FOUND: "FREEBIT_ACCOUNT_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", TIMEOUT: "FREEBIT_TIMEOUT",
NETWORK_ERROR: "FREEBIT_NETWORK_ERROR",
RATE_LIMITED: "FREEBIT_RATE_LIMITED",
// Generic
API_ERROR: "FREEBIT_API_ERROR", API_ERROR: "FREEBIT_API_ERROR",
} as const; } as const;