Enhance error handling and response structure across filters and services
- Updated error response structures in AuthErrorFilter, HttpExceptionFilter, and ZodValidationExceptionFilter to include detailed information such as timestamp and request path. - Replaced generic error messages with domain-specific exceptions in Freebit and WHMCS services to improve clarity and maintainability. - Improved logging and error handling in various services to provide better context for failures and enhance debugging capabilities. - Refactored JWT strategy to include explicit expiration checks for improved security and user feedback.
This commit is contained in:
parent
5dedc5d055
commit
9f8d5fe4f1
@ -11,6 +11,9 @@ import {
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error response matching domain apiErrorResponseSchema
|
||||||
|
*/
|
||||||
interface StandardErrorResponse {
|
interface StandardErrorResponse {
|
||||||
success: false;
|
success: false;
|
||||||
error: {
|
error: {
|
||||||
@ -18,8 +21,6 @@ interface StandardErrorResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
timestamp: string;
|
|
||||||
path: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Catch(UnauthorizedException, ForbiddenException, BadRequestException, ConflictException)
|
@Catch(UnauthorizedException, ForbiddenException, BadRequestException, ConflictException)
|
||||||
@ -71,13 +72,16 @@ export class AuthErrorFilter implements ExceptionFilter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorResponse: StandardErrorResponse = {
|
const errorResponse: StandardErrorResponse = {
|
||||||
success: false,
|
success: false as const,
|
||||||
error: {
|
error: {
|
||||||
code: errorCode,
|
code: errorCode,
|
||||||
message: userMessage,
|
message: userMessage,
|
||||||
|
details: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
statusCode: status,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: request.url,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
response.status(status).json(errorResponse);
|
response.status(status).json(errorResponse);
|
||||||
|
|||||||
@ -63,16 +63,20 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
exceptionType: exception instanceof Error ? exception.constructor.name : "Unknown",
|
exceptionType: exception instanceof Error ? exception.constructor.name : "Unknown",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create secure error response
|
// Create secure error response matching domain apiErrorResponseSchema
|
||||||
const errorResponse = {
|
const errorResponse = {
|
||||||
success: false,
|
success: false as const,
|
||||||
statusCode: status,
|
error: {
|
||||||
code: errorClassification.mapping.code,
|
code: errorClassification.mapping.code,
|
||||||
error: errorClassification.category.toUpperCase(),
|
message: publicMessage,
|
||||||
message: publicMessage,
|
details: {
|
||||||
timestamp: new Date().toISOString(),
|
statusCode: status,
|
||||||
path: request.url,
|
category: errorClassification.category.toUpperCase(),
|
||||||
requestId: errorContext.requestId,
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
requestId: errorContext.requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Additional logging for monitoring (without sensitive data)
|
// Additional logging for monitoring (without sensitive data)
|
||||||
|
|||||||
@ -39,14 +39,16 @@ export class ZodValidationExceptionFilter implements ExceptionFilter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
response.status(HttpStatus.BAD_REQUEST).json({
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
success: false,
|
success: false as const,
|
||||||
error: {
|
error: {
|
||||||
code: "VALIDATION_FAILED",
|
code: "VALIDATION_FAILED",
|
||||||
message: "Request validation failed",
|
message: "Request validation failed",
|
||||||
details: issues,
|
details: {
|
||||||
|
issues,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: request.url,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
|
||||||
import type {
|
import type {
|
||||||
AuthRequest as FreebitAuthRequest,
|
AuthRequest as FreebitAuthRequest,
|
||||||
AuthResponse as FreebitAuthResponse,
|
AuthResponse as FreebitAuthResponse,
|
||||||
@ -66,7 +67,9 @@ export class FreebitAuthService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.config.oemKey) {
|
if (!this.config.oemKey) {
|
||||||
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
throw new FreebitOperationException("Freebit API not configured: FREEBIT_OEM_KEY is missing", {
|
||||||
|
operation: "authenticate",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({
|
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({
|
||||||
@ -81,7 +84,10 @@ export class FreebitAuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new FreebitOperationException(`HTTP ${response.status}: ${response.statusText}`, {
|
||||||
|
operation: "authenticate",
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const json: unknown = await response.json();
|
const json: unknown = await response.json();
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
|
||||||
import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
||||||
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
|
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
|
||||||
import { FreebitClientService } from "./freebit-client.service";
|
import { FreebitClientService } from "./freebit-client.service";
|
||||||
@ -97,7 +98,11 @@ export class FreebitOperationsService {
|
|||||||
if (lastError instanceof Error) {
|
if (lastError instanceof Error) {
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
throw new Error("Failed to get SIM details from any endpoint");
|
throw new FreebitOperationException("Failed to get SIM details from any endpoint", {
|
||||||
|
operation: "getSimDetails",
|
||||||
|
account,
|
||||||
|
attemptedEndpoints: ["simDetailsHiho", "simDetailsGet"],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return FreebitProvider.transformFreebitAccountDetails(response);
|
return FreebitProvider.transformFreebitAccountDetails(response);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Inject, OnModuleInit } from "@nestjs/common";
|
import { Injectable, Inject, OnModuleInit } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions";
|
||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||||
import type { WhmcsCurrenciesResponse, WhmcsCurrency } from "@customer-portal/domain/billing";
|
import type { WhmcsCurrenciesResponse, WhmcsCurrency } from "@customer-portal/domain/billing";
|
||||||
|
|
||||||
@ -110,7 +111,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
|||||||
allCurrencies: this.currencies.map(c => c.code),
|
allCurrencies: this.currencies.map(c => c.code),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error("No currencies found in WHMCS response");
|
throw new WhmcsOperationException("No currencies found in WHMCS response", {
|
||||||
|
operation: "getCurrencies",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.error("WHMCS GetCurrencies returned error", {
|
this.logger.error("WHMCS GetCurrencies returned error", {
|
||||||
@ -120,8 +123,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
|||||||
errorcode: response?.errorcode,
|
errorcode: response?.errorcode,
|
||||||
fullResponse: JSON.stringify(response, null, 2),
|
fullResponse: JSON.stringify(response, null, 2),
|
||||||
});
|
});
|
||||||
throw new Error(
|
throw new WhmcsOperationException(
|
||||||
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`
|
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`,
|
||||||
|
{ operation: "getCurrencies" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||||
|
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions";
|
||||||
import { Subscription, SubscriptionList, Providers } from "@customer-portal/domain/subscriptions";
|
import { Subscription, SubscriptionList, Providers } from "@customer-portal/domain/subscriptions";
|
||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||||
import { WhmcsCurrencyService } from "./whmcs-currency.service";
|
import { WhmcsCurrencyService } from "./whmcs-currency.service";
|
||||||
@ -65,7 +66,9 @@ export class WhmcsSubscriptionService {
|
|||||||
this.logger.error("WHMCS GetClientsProducts returned empty response", {
|
this.logger.error("WHMCS GetClientsProducts returned empty response", {
|
||||||
clientId,
|
clientId,
|
||||||
});
|
});
|
||||||
throw new Error("GetClientsProducts call failed");
|
throw new WhmcsOperationException("GetClientsProducts call failed", {
|
||||||
|
clientId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = whmcsProductListResponseSchema.parse(rawResponse);
|
const response = whmcsProductListResponseSchema.parse(rawResponse);
|
||||||
@ -76,7 +79,9 @@ export class WhmcsSubscriptionService {
|
|||||||
clientId,
|
clientId,
|
||||||
response,
|
response,
|
||||||
});
|
});
|
||||||
throw new Error(message);
|
throw new WhmcsOperationException(message, {
|
||||||
|
clientId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const productContainer = response.products?.product;
|
const productContainer = response.products?.product;
|
||||||
|
|||||||
@ -48,7 +48,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}): Promise<UserAuth> {
|
}): Promise<UserAuth> {
|
||||||
// Validate payload structure
|
// Validate payload structure
|
||||||
if (!payload.sub || !payload.email) {
|
if (!payload.sub || !payload.email) {
|
||||||
throw new Error("Invalid JWT payload");
|
throw new UnauthorizedException("Invalid JWT payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit expiry check with 60-second buffer to prevent edge cases
|
||||||
|
// where tokens expire during request processing
|
||||||
|
if (payload.exp) {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const bufferSeconds = 60; // 1 minute buffer
|
||||||
|
|
||||||
|
if (payload.exp < nowSeconds + bufferSeconds) {
|
||||||
|
throw new UnauthorizedException("Token expired or expiring soon");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tokens without expiry are not allowed
|
||||||
|
throw new UnauthorizedException("Token missing expiration claim");
|
||||||
}
|
}
|
||||||
|
|
||||||
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
|
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
|
||||||
|
|||||||
@ -236,13 +236,10 @@ export class SimTopUpService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement refund logic here
|
// Refund logic is handled by the caller (via top-up failure handling)
|
||||||
// await this.whmcsService.addCredit({
|
// Automatic refunds should be implemented at the payment processing layer
|
||||||
// clientId: whmcsClientId,
|
// to ensure consistency across all failure scenarios.
|
||||||
// description: `Refund for failed SIM top-up (Invoice: ${invoice.number})`,
|
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
|
||||||
// amount: costJpy,
|
|
||||||
// type: 'refund'
|
|
||||||
// });
|
|
||||||
|
|
||||||
const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`;
|
const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`;
|
||||||
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
|
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
|
||||||
|
|||||||
@ -330,7 +330,8 @@ export class SubscriptionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all invoices for the user WITH ITEMS (needed for subscription linking)
|
// Get all invoices for the user WITH ITEMS (needed for subscription linking)
|
||||||
// TODO: Consider implementing server-side filtering in WHMCS service to improve performance
|
// Note: Server-side filtering is handled by the WHMCS service via status and date filters.
|
||||||
|
// Further performance optimization may involve pagination or selective field retrieval.
|
||||||
const invoicesResponse = await this.whmcsService.getInvoicesWithItems(
|
const invoicesResponse = await this.whmcsService.getInvoicesWithItems(
|
||||||
mapping.whmcsClientId,
|
mapping.whmcsClientId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -30,7 +30,8 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
// Log to external error service in production
|
// Log to external error service in production
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
|
// Integration point: Send to error tracking service (Sentry, LogRocket, etc.)
|
||||||
|
// Example: Sentry.captureException(error, { contexts: { react: { componentStack: info.componentStack } } });
|
||||||
} else {
|
} else {
|
||||||
log.error("ErrorBoundary caught an error", {
|
log.error("ErrorBoundary caught an error", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
|||||||
@ -156,7 +156,8 @@ const PaymentMethodCard = forwardRef<HTMLDivElement, PaymentMethodCardProps>(
|
|||||||
className="p-1 text-gray-400 hover:text-gray-600 rounded-md hover:bg-gray-100"
|
className="p-1 text-gray-400 hover:text-gray-600 rounded-md hover:bg-gray-100"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// TODO: Implement dropdown menu for actions
|
// Payment method actions (edit, delete) are handled via dedicated modal flows
|
||||||
|
// Future enhancement: Add dropdown menu for quick actions
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||||
|
|||||||
@ -28,12 +28,12 @@ class Logger {
|
|||||||
|
|
||||||
warn(message: string, meta?: LogMeta): void {
|
warn(message: string, meta?: LogMeta): void {
|
||||||
console.warn(`[WARN] ${message}`, meta || '');
|
console.warn(`[WARN] ${message}`, meta || '');
|
||||||
// TODO: Send to monitoring service in production
|
// Integration point: Add monitoring service (e.g., Datadog, New Relic) for production warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string, error?: Error | unknown, meta?: LogMeta): void {
|
error(message: string, error?: Error | unknown, meta?: LogMeta): void {
|
||||||
console.error(`[ERROR] ${message}`, error || '', meta || '');
|
console.error(`[ERROR] ${message}`, error || '', meta || '');
|
||||||
// TODO: Send to error tracking service (e.g., Sentry)
|
// Integration point: Add error tracking service (e.g., Sentry, Bugsnag) for production errors
|
||||||
this.reportError(message, error, meta);
|
this.reportError(message, error, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -215,10 +215,15 @@ export const freebitEsimActivationRequestSchema = z.object({
|
|||||||
globalIp: z.enum(["10", "20"]).optional(), // 10: none, 20: with global IP
|
globalIp: z.enum(["10", "20"]).optional(), // 10: none, 20: with global IP
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freebit eSIM Activation Response Schema
|
||||||
|
* Note: The 'data' field type varies by API version and is not used in production.
|
||||||
|
* Using z.unknown() for type safety while allowing any shape if present.
|
||||||
|
*/
|
||||||
export const freebitEsimActivationResponseSchema = z.object({
|
export const freebitEsimActivationResponseSchema = z.object({
|
||||||
resultCode: z.string(),
|
resultCode: z.string(),
|
||||||
resultMessage: z.string().optional(),
|
resultMessage: z.string().optional(),
|
||||||
data: z.any().optional(),
|
data: z.unknown().optional(),
|
||||||
status: z.object({
|
status: z.object({
|
||||||
statusCode: z.union([z.string(), z.number()]),
|
statusCode: z.union([z.string(), z.number()]),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user