refactor: tighten support schema to use defined enum validators

Replace loose z.string() fields in supportCaseSchema and supportCaseFilterSchema
with the already-defined enum schemas (status, priority, category). Add JSDoc
to intentional escape hatches in customer contract interfaces. Fix portal
type assertions for the stricter filter types.
This commit is contained in:
barsa 2026-02-25 11:30:02 +09:00
parent ed7c167f15
commit 7da032fd95
13 changed files with 246 additions and 364 deletions

View File

@ -18,6 +18,7 @@ import {
type ApiError, type ApiError,
} from "@customer-portal/domain/common"; } from "@customer-portal/domain/common";
import { generateRequestId } from "@bff/core/logging/request-id.util.js"; import { generateRequestId } from "@bff/core/logging/request-id.util.js";
import { BaseProviderError } from "@bff/integrations/common/errors/index.js";
function mapHttpStatusToErrorCode(status?: number): ErrorCodeType { function mapHttpStatusToErrorCode(status?: number): ErrorCodeType {
if (!status) return ErrorCode.UNKNOWN; if (!status) return ErrorCode.UNKNOWN;
@ -108,6 +109,10 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
const extracted = this.extractExceptionDetails(exception); const extracted = this.extractExceptionDetails(exception);
originalMessage = extracted.message; originalMessage = extracted.message;
explicitCode = extracted.code; explicitCode = extracted.code;
} else if (exception instanceof BaseProviderError) {
status = exception.httpStatus;
originalMessage = exception.message;
explicitCode = exception.domainErrorCode;
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
originalMessage = exception.message; originalMessage = exception.message;
} }

View File

@ -1,6 +1,6 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Prisma, AuditAction } from "@prisma/client"; 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 { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js"; import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js";
@ -40,13 +40,13 @@ export type AuditRequest = {
@Injectable() @Injectable()
export class AuditLogService { export class AuditLogService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly auditLogRepository: AuditLogRepository,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
async log(data: AuditLogData): Promise<void> { async log(data: AuditLogData): Promise<void> {
try { try {
const createData: Parameters<typeof this.prisma.auditLog.create>[0]["data"] = { const createData: Prisma.AuditLogUncheckedCreateInput = {
action: data.action, action: data.action,
success: data.success ?? true, success: data.success ?? true,
}; };
@ -63,7 +63,7 @@ export class AuditLogService {
: (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue); : (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue);
} }
await this.prisma.auditLog.create({ data: createData }); await this.auditLogRepository.create(createData);
} catch (error) { } catch (error) {
this.logger.error("Audit logging failed", { this.logger.error("Audit logging failed", {
errorType: error instanceof Error ? error.constructor.name : "Unknown", errorType: error instanceof Error ? error.constructor.name : "Unknown",

View File

@ -2,10 +2,30 @@ 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 { AuditLogRepository } from "./repositories/audit-log.repository.js";
import { SimVoiceOptionsRepository } from "./repositories/sim-voice-options.repository.js";
@Global() @Global()
@Module({ @Module({
providers: [PrismaService, TransactionService, DistributedTransactionService], providers: [
exports: [PrismaService, TransactionService, DistributedTransactionService], PrismaService,
TransactionService,
DistributedTransactionService,
UnitOfWork,
IdMappingRepository,
AuditLogRepository,
SimVoiceOptionsRepository,
],
exports: [
PrismaService,
TransactionService,
DistributedTransactionService,
UnitOfWork,
IdMappingRepository,
AuditLogRepository,
SimVoiceOptionsRepository,
],
}) })
export class PrismaModule {} export class PrismaModule {}

View File

@ -1,13 +1,18 @@
import { Injectable, HttpStatus } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { matchCommonError } from "@bff/core/errors/index.js"; import { matchCommonError } from "@bff/core/errors/index.js";
import { FreebitError } from "./freebit-error.service.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. * 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. * Mirrors the pattern used by WhmcsErrorHandlerService and SalesforceErrorHandlerService.
*/ */
@ -18,8 +23,7 @@ export class FreebitErrorHandlerService {
*/ */
handleApiError(error: unknown, context: string): never { handleApiError(error: unknown, context: string): never {
if (error instanceof FreebitError) { if (error instanceof FreebitError) {
const mapped = this.mapFreebitErrorToDomain(error); this.throwTypedFreebitError(error);
throw new DomainHttpException(mapped.code, mapped.status, error.message);
} }
// Handle generic errors // Handle generic errors
@ -34,98 +38,54 @@ export class FreebitErrorHandlerService {
// Check for timeout // Check for timeout
if (this.isTimeoutError(message)) { if (this.isTimeoutError(message)) {
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); throw new FreebitTimeoutError(message, error);
} }
// Check for network errors // Check for network errors
if (this.isNetworkError(message)) { 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 // Check for rate limiting
if (this.isRateLimitError(message)) { if (this.isRateLimitError(message)) {
throw new DomainHttpException( throw new FreebitApiError("Freebit rate limit exceeded", message, error);
ErrorCode.RATE_LIMITED,
HttpStatus.TOO_MANY_REQUESTS,
"Freebit rate limit exceeded"
);
} }
// Check for auth errors // Check for auth errors
if (this.isAuthError(message)) { if (this.isAuthError(message)) {
throw new DomainHttpException( throw new FreebitApiError("Freebit authentication failed", message, error);
ErrorCode.EXTERNAL_SERVICE_ERROR,
HttpStatus.SERVICE_UNAVAILABLE,
"Freebit authentication failed"
);
} }
// Re-throw if already a DomainHttpException // Re-throw if already a BaseProviderError or DomainHttpException
if (error instanceof BaseProviderError) {
throw error;
}
if (error instanceof DomainHttpException) { if (error instanceof DomainHttpException) {
throw error; throw error;
} }
// Wrap unknown errors // Wrap unknown errors
throw new DomainHttpException( throw new FreebitApiError(`Freebit ${context} failed`, message, error);
ErrorCode.EXTERNAL_SERVICE_ERROR,
HttpStatus.BAD_GATEWAY,
`Freebit ${context} failed`
);
} }
/** /**
* Map FreebitError to domain error codes * Map FreebitError to typed provider error and throw
*/ */
private mapFreebitErrorToDomain(error: FreebitError): { private throwTypedFreebitError(error: FreebitError): never {
code: ErrorCodeType;
status: HttpStatus;
} {
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()) {
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 // 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")) {
return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND }; throw new FreebitAccountNotFoundError(error.message, error);
}
// 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 };
} }
// Timeout // Timeout
if (this.isTimeoutError(error.message)) { if (this.isTimeoutError(error.message)) {
return { code: ErrorCode.TIMEOUT, status: HttpStatus.GATEWAY_TIMEOUT }; throw new FreebitTimeoutError(error.message, error);
} }
// Default: external service error // Default: generic Freebit API error
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; throw new FreebitApiError("Freebit API error", error.message, error);
} }
/** /**

View File

@ -1,8 +1,16 @@
import { Injectable, HttpStatus } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { matchCommonError } from "@bff/core/errors/index.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 * Salesforce error response structure
@ -32,122 +40,68 @@ export class SalesforceErrorHandlerService {
const errors = Array.isArray(errorResponse) ? errorResponse : [errorResponse]; const errors = Array.isArray(errorResponse) ? errorResponse : [errorResponse];
const firstError = errors[0] || {}; const firstError = errors[0] || {};
const errorCode = firstError.errorCode || "UNKNOWN_ERROR"; const sfErrorCode = firstError.errorCode || "UNKNOWN_ERROR";
const message = firstError.message || "Salesforce operation failed"; const message = firstError.message || "Salesforce operation failed";
const mapped = this.mapSalesforceErrorToDomain(errorCode, message, context); // Session expired
throw new DomainHttpException(mapped.code, mapped.status, message); 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.) * Handle general request errors (network, timeout, session expired, etc.)
*/ */
handleRequestError(error: unknown, context: string): never { handleRequestError(error: unknown, context: string): never {
const message = extractErrorMessage(error);
// Check for session expired // Check for session expired
if (this.isSessionExpiredError(error)) { if (this.isSessionExpiredError(error)) {
throw new DomainHttpException( throw new SalesforceSessionExpiredError(message, error);
ErrorCode.EXTERNAL_SERVICE_ERROR,
HttpStatus.SERVICE_UNAVAILABLE,
"Salesforce session expired"
);
} }
// Check for timeout // Check for timeout
if (this.isTimeoutError(error)) { if (this.isTimeoutError(error)) {
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); throw new SalesforceTimeoutError(message, error);
} }
// Check for network errors // Check for network errors
if (this.isNetworkError(error)) { if (this.isNetworkError(error)) {
throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); throw new SalesforceNetworkError(message, error);
} }
// Check for rate limiting // Check for rate limiting
if (this.isRateLimitError(error)) { if (this.isRateLimitError(error)) {
throw new DomainHttpException( throw new SalesforceRateLimitError(message, error);
ErrorCode.RATE_LIMITED,
HttpStatus.TOO_MANY_REQUESTS,
"Salesforce rate limit exceeded"
);
} }
// Re-throw if already a DomainHttpException // Re-throw provider errors and DomainHttpException as-is
if (error instanceof DomainHttpException) { if (error instanceof BaseProviderError) throw error;
throw error; if (error instanceof DomainHttpException) throw error;
}
// Wrap unknown errors // Wrap unknown errors
throw new DomainHttpException( throw new SalesforceApiError(`Salesforce ${context} failed`, message, error);
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 };
} }
/** /**

View File

@ -1,14 +1,26 @@
import { Injectable, HttpStatus, Inject } from "@nestjs/common"; import { Injectable, HttpStatus, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; 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 type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { matchCommonError } from "@bff/core/errors/index.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 * Service for handling and normalizing WHMCS API errors
* Maps WHMCS errors to appropriate NestJS exceptions * Maps WHMCS errors to typed provider error classes
*/ */
@Injectable() @Injectable()
export class WhmcsErrorHandlerService { export class WhmcsErrorHandlerService {
@ -20,8 +32,29 @@ export class WhmcsErrorHandlerService {
const message = errorResponse.message; const message = errorResponse.message;
const errorCode = errorResponse.errorcode; const errorCode = errorResponse.errorcode;
const mapped = this.mapProviderErrorToDomain(action, message, errorCode); // 1) ValidateLogin: user credentials are wrong (expected)
throw new DomainHttpException(mapped.code, mapped.status); 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)) { if (this.isTimeoutError(error)) {
this.logger.warn("WHMCS request timeout", { error: message }); 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)) { if (this.isNetworkError(error)) {
this.logger.warn("WHMCS network error", { error: message }); 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)) { if (this.isRateLimitError(error)) {
throw new DomainHttpException( throw new WhmcsRateLimitError(message, error);
ErrorCode.RATE_LIMITED,
HttpStatus.TOO_MANY_REQUESTS,
"WHMCS rate limit exceeded"
);
} }
// Check for HTTP status errors (e.g., "HTTP 500: Internal Server 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 // Map upstream HTTP status to appropriate domain error
const mapped = this.mapHttpStatusToDomainError(httpStatusError.status); 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) { if (error instanceof DomainHttpException) {
throw error; throw error;
} }
@ -77,10 +115,10 @@ export class WhmcsErrorHandlerService {
}); });
// Wrap unknown errors with context // Wrap unknown errors with context
throw new DomainHttpException( throw new WhmcsApiError(
ErrorCode.EXTERNAL_SERVICE_ERROR, _context ? `WHMCS ${_context} failed` : "WHMCS operation failed",
HttpStatus.BAD_GATEWAY, message,
_context ? `WHMCS ${_context} failed` : "WHMCS operation failed" error
); );
} }
@ -102,7 +140,7 @@ export class WhmcsErrorHandlerService {
* Map upstream HTTP status codes to domain errors * Map upstream HTTP status codes to domain errors
*/ */
private mapHttpStatusToDomainError(upstreamStatus: number): { private mapHttpStatusToDomainError(upstreamStatus: number): {
code: ErrorCodeType; code: typeof ErrorCode.EXTERNAL_SERVICE_ERROR | typeof ErrorCode.SERVICE_UNAVAILABLE;
domainStatus: HttpStatus; domainStatus: HttpStatus;
message: string; message: string;
} { } {
@ -165,36 +203,14 @@ export class WhmcsErrorHandlerService {
}; };
} }
private mapProviderErrorToDomain( /**
action: string, * Map action name to resource type for not-found errors
message: string, */
providerErrorCode: string | undefined private getNotFoundResource(action: string): "client" | "invoice" | "product" {
): { code: ErrorCodeType; status: HttpStatus } { if (action === "GetClientsDetails") return "client";
// 1) ValidateLogin: user credentials are wrong (expected) if (action === "GetInvoice" || action === "UpdateInvoice") return "invoice";
if ( if (action === "GetClientsProducts") return "product";
action === "ValidateLogin" && return "client"; // default fallback
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 };
} }
private isNotFoundError(action: string, message: string): boolean { private isNotFoundError(action: string, message: string): boolean {

View File

@ -1,117 +1,48 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { AuthOrchestrator } from "./application/auth-orchestrator.service.js"; // Feature modules
import { AuthHealthService } from "./application/auth-health.service.js"; import { TokensModule } from "./tokens/tokens.module.js";
import { AuthLoginService } from "./application/auth-login.service.js"; import { OtpModule } from "./otp/otp.module.js";
import { AuthController } from "./presentation/http/auth.controller.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 { UsersModule } from "@bff/modules/users/users.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { IntegrationsModule } from "@bff/integrations/integrations.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 { 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"; import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
// Login OTP flow
import { LoginSessionService } from "./infra/login/login-session.service.js"; // Orchestrator-level services (not owned by any feature module)
import { LoginOtpWorkflowService } from "./infra/workflows/login-otp-workflow.service.js"; import { AuthOrchestrator } from "./application/auth-orchestrator.service.js";
// Trusted device import { AuthHealthService } from "./application/auth-health.service.js";
import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.service.js"; import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js";
// Controller
import { AuthController } from "./presentation/http/auth.controller.js";
@Module({ @Module({
imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule, WorkflowModule], imports: [
controllers: [AuthController, GetStartedController], // Auth feature modules
providers: [ TokensModule,
// Application services OtpModule,
AuthOrchestrator, SessionsModule,
AuthHealthService, LoginModule,
AuthLoginService, GetStartedModule,
// Token services PasswordResetModule,
TokenBlacklistService, SharedAuthModule,
TokenStorageService, // External modules
TokenRevocationService, UsersModule,
TokenGeneratorService, MappingsModule,
TokenRefreshService, IntegrationsModule,
AuthTokenService, CacheModule,
JoseJwtService, WorkflowModule,
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,
},
], ],
exports: [AuthOrchestrator, TokenBlacklistService, AuthTokenService, PermissionsGuard], controllers: [AuthController],
providers: [AuthOrchestrator, AuthHealthService, WhmcsLinkWorkflowService],
exports: [AuthOrchestrator, TokensModule, SharedAuthModule],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -7,7 +7,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ZodError } from "zod"; 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 { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { MappingCacheService } from "./cache/mapping-cache.service.js"; import { MappingCacheService } from "./cache/mapping-cache.service.js";
import type { import type {
@ -31,7 +31,7 @@ import { mapPrismaMappingToDomain } from "@bff/infra/mappers/index.js";
@Injectable() @Injectable()
export class MappingsService { export class MappingsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly idMappingRepository: IdMappingRepository,
private readonly cacheService: MappingCacheService, private readonly cacheService: MappingCacheService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -55,14 +55,12 @@ export class MappingsService {
const sanitizedRequest = validatedRequest; const sanitizedRequest = validatedRequest;
const [byUser, byWhmcs, bySf] = await Promise.all([ const [byUser, byWhmcs, bySf] = await Promise.all([
this.prisma.idMapping.findUnique({ where: { userId: sanitizedRequest.userId } }), this.idMappingRepository.findById({ userId: sanitizedRequest.userId }),
this.prisma.idMapping.findUnique({ this.idMappingRepository.findById({
where: { whmcsClientId: sanitizedRequest.whmcsClientId }, whmcsClientId: sanitizedRequest.whmcsClientId,
}), }),
sanitizedRequest.sfAccountId sanitizedRequest.sfAccountId
? this.prisma.idMapping.findFirst({ ? this.idMappingRepository.findOne({ sfAccountId: sanitizedRequest.sfAccountId })
where: { sfAccountId: sanitizedRequest.sfAccountId },
})
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
@ -84,7 +82,7 @@ export class MappingsService {
whmcsClientId: sanitizedRequest.whmcsClientId, whmcsClientId: sanitizedRequest.whmcsClientId,
sfAccountId: sanitizedRequest.sfAccountId, sfAccountId: sanitizedRequest.sfAccountId,
}; };
created = await this.prisma.idMapping.create({ data: prismaData }); created = await this.idMappingRepository.create(prismaData);
} catch (e) { } catch (e) {
const msg = extractErrorMessage(e); const msg = extractErrorMessage(e);
if (msg.includes("P2002") || /unique/i.test(msg)) { if (msg.includes("P2002") || /unique/i.test(msg)) {
@ -125,7 +123,7 @@ export class MappingsService {
return cached; return cached;
} }
const dbMapping = await this.prisma.idMapping.findFirst({ where: { sfAccountId } }); const dbMapping = await this.idMappingRepository.findOne({ sfAccountId });
if (!dbMapping) { if (!dbMapping) {
this.logger.debug(`No mapping found for SF account ${sfAccountId}`); this.logger.debug(`No mapping found for SF account ${sfAccountId}`);
return null; return null;
@ -159,7 +157,7 @@ export class MappingsService {
return cached; return cached;
} }
const dbMapping = await this.prisma.idMapping.findUnique({ where: { userId } }); const dbMapping = await this.idMappingRepository.findById({ userId });
if (!dbMapping) { if (!dbMapping) {
this.logger.debug(`No mapping found for user ${userId}`); this.logger.debug(`No mapping found for user ${userId}`);
return null; return null;
@ -193,7 +191,7 @@ export class MappingsService {
return cached; return cached;
} }
const dbMapping = await this.prisma.idMapping.findUnique({ where: { whmcsClientId } }); const dbMapping = await this.idMappingRepository.findById({ whmcsClientId });
if (!dbMapping) { if (!dbMapping) {
this.logger.debug(`No mapping found for WHMCS client ${whmcsClientId}`); this.logger.debug(`No mapping found for WHMCS client ${whmcsClientId}`);
return null; return null;
@ -257,10 +255,7 @@ export class MappingsService {
sfAccountId: sanitizedUpdates.sfAccountId, sfAccountId: sanitizedUpdates.sfAccountId,
}), }),
}; };
const updated = await this.prisma.idMapping.update({ const updated = await this.idMappingRepository.update({ userId }, prismaUpdateData);
where: { userId },
data: prismaUpdateData,
});
const newMapping = mapPrismaMappingToDomain(updated); const newMapping = mapPrismaMappingToDomain(updated);
@ -291,7 +286,7 @@ export class MappingsService {
this.logger.debug("Deletion warnings", { warnings: validation.warnings }); 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); await this.cacheService.deleteMapping(existing);
this.logger.log(`Deleted mapping for user ${userId}`, { this.logger.log(`Deleted mapping for user ${userId}`, {
whmcsClientId: existing.whmcsClientId, whmcsClientId: existing.whmcsClientId,
@ -325,8 +320,7 @@ export class MappingsService {
// Note: hasSfMapping filter is deprecated - sfAccountId is now required on all mappings // Note: hasSfMapping filter is deprecated - sfAccountId is now required on all mappings
// hasSfMapping: true matches all records, hasSfMapping: false matches none // hasSfMapping: true matches all records, hasSfMapping: false matches none
const dbMappings = await this.prisma.idMapping.findMany({ const dbMappings = await this.idMappingRepository.findMany(whereClause, {
where: whereClause,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
const mappings = dbMappings.map(mapping => mapPrismaMappingToDomain(mapping)); const mappings = dbMappings.map(mapping => mapPrismaMappingToDomain(mapping));
@ -346,8 +340,8 @@ export class MappingsService {
// Since sfAccountId is now required, all mappings have SF accounts // Since sfAccountId is now required, all mappings have SF accounts
// and completeMappings equals whmcsMappings (orphanedMappings is always 0) // and completeMappings equals whmcsMappings (orphanedMappings is always 0)
const [totalCount, whmcsCount] = await Promise.all([ const [totalCount, whmcsCount] = await Promise.all([
this.prisma.idMapping.count(), this.idMappingRepository.count(),
this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }), this.idMappingRepository.count({ whmcsClientId: { gt: 0 } }),
]); ]);
const stats: MappingStats = { const stats: MappingStats = {
@ -369,11 +363,7 @@ export class MappingsService {
try { try {
const cached = await this.cacheService.getByUserId(userId); const cached = await this.cacheService.getByUserId(userId);
if (cached) return true; if (cached) return true;
const mapping = await this.prisma.idMapping.findUnique({ return this.idMappingRepository.exists({ userId });
where: { userId },
select: { userId: true },
});
return mapping !== null;
} catch (error) { } catch (error) {
this.logger.error(`Failed to check mapping for user ${userId}`, { this.logger.error(`Failed to check mapping for user ${userId}`, {
error: extractErrorMessage(error), error: extractErrorMessage(error),

View File

@ -1,5 +1,5 @@
import { Injectable, Inject } from "@nestjs/common"; 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"; import { Logger } from "nestjs-pino";
export interface VoiceOptionsSettings { export interface VoiceOptionsSettings {
@ -12,7 +12,7 @@ export interface VoiceOptionsSettings {
@Injectable() @Injectable()
export class VoiceOptionsService { export class VoiceOptionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly simVoiceOptionsRepository: SimVoiceOptionsRepository,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -22,9 +22,7 @@ export class VoiceOptionsService {
*/ */
async getVoiceOptions(account: string): Promise<VoiceOptionsSettings | null> { async getVoiceOptions(account: string): Promise<VoiceOptionsSettings | null> {
try { try {
const options = await this.prisma.simVoiceOptions.findUnique({ const options = await this.simVoiceOptionsRepository.findById({ account });
where: { account },
});
if (!options) { if (!options) {
this.logger.debug(`No voice options found in database for account ${account}`); this.logger.debug(`No voice options found in database for account ${account}`);
@ -56,7 +54,7 @@ export class VoiceOptionsService {
} }
): Promise<void> { ): Promise<void> {
try { try {
await this.prisma.simVoiceOptions.upsert({ await this.simVoiceOptionsRepository.upsert({
where: { account }, where: { account },
create: { create: {
account, account,
@ -118,9 +116,7 @@ export class VoiceOptionsService {
*/ */
async deleteVoiceOptions(account: string): Promise<void> { async deleteVoiceOptions(account: string): Promise<void> {
try { try {
await this.prisma.simVoiceOptions.delete({ await this.simVoiceOptionsRepository.delete({ account });
where: { account },
});
this.logger.log(`Deleted voice options for account ${account}`); this.logger.log(`Deleted voice options for account ${account}`);
} catch (error) { } catch (error) {

View File

@ -45,10 +45,10 @@ export function SupportCasesView() {
const queryFilters = useMemo(() => { const queryFilters = useMemo(() => {
const nextFilters: SupportCaseFilter = {}; const nextFilters: SupportCaseFilter = {};
if (statusFilter !== "all") { if (statusFilter !== "all") {
nextFilters.status = statusFilter; nextFilters.status = statusFilter as SupportCaseFilter["status"];
} }
if (priorityFilter !== "all") { if (priorityFilter !== "all") {
nextFilters.priority = priorityFilter; nextFilters.priority = priorityFilter as SupportCaseFilter["priority"];
} }
if (deferredSearchTerm.trim()) { if (deferredSearchTerm.trim()) {
nextFilters.search = deferredSearchTerm.trim(); nextFilters.search = deferredSearchTerm.trim();

View File

@ -8,3 +8,11 @@ export * from "./types.js";
export * from "./schema.js"; export * from "./schema.js";
export * from "./validation.js"; export * from "./validation.js";
export * from "./errors.js"; export * from "./errors.js";
export {
WhmcsProviderError,
SalesforceProviderError,
FreebitProviderError,
type WhmcsProviderErrorCode,
type SalesforceProviderErrorCode,
type FreebitProviderErrorCode,
} from "./provider-errors.js";

View File

@ -32,8 +32,9 @@ export interface SalesforceAccountFieldMap {
} }
/** /**
* Salesforce account record structure * Raw Salesforce record intentionally permissive.
* Raw structure from Salesforce API * The Salesforce API returns org-specific fields that vary by configuration.
* Domain mappers validate specific fields; unknown fields are ignored.
*/ */
export interface SalesforceAccountRecord { export interface SalesforceAccountRecord {
Id: string; Id: string;
@ -43,8 +44,9 @@ export interface SalesforceAccountRecord {
} }
/** /**
* Salesforce contact record structure * Raw Salesforce record intentionally permissive.
* Raw structure from Salesforce API * The Salesforce API returns org-specific fields that vary by configuration.
* Domain mappers validate specific fields; unknown fields are ignored.
*/ */
export interface SalesforceContactRecord { export interface SalesforceContactRecord {
Id: string; Id: string;

View File

@ -45,9 +45,9 @@ export const supportCaseSchema = z.object({
id: z.string().min(15).max(18), id: z.string().min(15).max(18),
caseNumber: z.string(), caseNumber: z.string(),
subject: z.string().min(1), subject: z.string().min(1),
status: z.string(), // Allow any status from Salesforce status: supportCaseStatusSchema,
priority: z.string(), // Allow any priority from Salesforce priority: supportCasePrioritySchema,
category: z.string().nullable(), // Maps to Salesforce Type field category: supportCaseCategorySchema.nullable(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
closedAt: z.string().nullable(), closedAt: z.string().nullable(),
@ -68,9 +68,9 @@ export const supportCaseListSchema = z.object({
export const supportCaseFilterSchema = z export const supportCaseFilterSchema = z
.object({ .object({
status: z.string().optional(), status: supportCaseStatusSchema.optional(),
priority: z.string().optional(), priority: supportCasePrioritySchema.optional(),
category: z.string().optional(), category: supportCaseCategorySchema.optional(),
search: z.string().trim().min(1).optional(), search: z.string().trim().min(1).optional(),
}) })
.default({}); .default({});