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:
parent
ed7c167f15
commit
7da032fd95
@ -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;
|
||||
}
|
||||
|
||||
@ -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<void> {
|
||||
try {
|
||||
const createData: Parameters<typeof this.prisma.auditLog.create>[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",
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<VoiceOptionsSettings | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await this.prisma.simVoiceOptions.delete({
|
||||
where: { account },
|
||||
});
|
||||
await this.simVoiceOptionsRepository.delete({ account });
|
||||
|
||||
this.logger.log(`Deleted voice options for account ${account}`);
|
||||
} catch (error) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user