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:
barsa 2025-10-28 13:43:45 +09:00
parent 5dedc5d055
commit 9f8d5fe4f1
14 changed files with 89 additions and 40 deletions

View File

@ -11,6 +11,9 @@ import {
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
/**
* Standard error response matching domain apiErrorResponseSchema
*/
interface StandardErrorResponse {
success: false;
error: {
@ -18,8 +21,6 @@ interface StandardErrorResponse {
message: string;
details?: Record<string, unknown>;
};
timestamp: string;
path: string;
}
@Catch(UnauthorizedException, ForbiddenException, BadRequestException, ConflictException)
@ -71,13 +72,16 @@ export class AuthErrorFilter implements ExceptionFilter {
});
const errorResponse: StandardErrorResponse = {
success: false,
success: false as const,
error: {
code: errorCode,
message: userMessage,
details: {
timestamp: new Date().toISOString(),
path: request.url,
statusCode: status,
},
},
timestamp: new Date().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);

View File

@ -63,16 +63,20 @@ export class GlobalExceptionFilter implements ExceptionFilter {
exceptionType: exception instanceof Error ? exception.constructor.name : "Unknown",
});
// Create secure error response
// Create secure error response matching domain apiErrorResponseSchema
const errorResponse = {
success: false,
statusCode: status,
code: errorClassification.mapping.code,
error: errorClassification.category.toUpperCase(),
message: publicMessage,
timestamp: new Date().toISOString(),
path: request.url,
requestId: errorContext.requestId,
success: false as const,
error: {
code: errorClassification.mapping.code,
message: publicMessage,
details: {
statusCode: status,
category: errorClassification.category.toUpperCase(),
timestamp: new Date().toISOString(),
path: request.url,
requestId: errorContext.requestId,
},
},
};
// Additional logging for monitoring (without sensitive data)

View File

@ -39,14 +39,16 @@ export class ZodValidationExceptionFilter implements ExceptionFilter {
});
response.status(HttpStatus.BAD_REQUEST).json({
success: false,
success: false as const,
error: {
code: "VALIDATION_FAILED",
message: "Request validation failed",
details: issues,
details: {
issues,
timestamp: new Date().toISOString(),
path: request.url,
},
},
timestamp: new Date().toISOString(),
path: request.url,
});
}

View File

@ -2,6 +2,7 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
import type {
AuthRequest as FreebitAuthRequest,
AuthResponse as FreebitAuthResponse,
@ -66,7 +67,9 @@ export class FreebitAuthService {
try {
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({
@ -81,7 +84,10 @@ export class FreebitAuthService {
});
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();

View File

@ -1,6 +1,7 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
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 { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
import { FreebitClientService } from "./freebit-client.service";
@ -97,7 +98,11 @@ export class FreebitOperationsService {
if (lastError instanceof Error) {
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);

View File

@ -1,6 +1,7 @@
import { Injectable, Inject, OnModuleInit } from "@nestjs/common";
import { Logger } from "nestjs-pino";
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 type { WhmcsCurrenciesResponse, WhmcsCurrency } from "@customer-portal/domain/billing";
@ -110,7 +111,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
allCurrencies: this.currencies.map(c => c.code),
});
} else {
throw new Error("No currencies found in WHMCS response");
throw new WhmcsOperationException("No currencies found in WHMCS response", {
operation: "getCurrencies",
});
}
} else {
this.logger.error("WHMCS GetCurrencies returned error", {
@ -120,8 +123,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
errorcode: response?.errorcode,
fullResponse: JSON.stringify(response, null, 2),
});
throw new Error(
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`
throw new WhmcsOperationException(
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`,
{ operation: "getCurrencies" }
);
}
} catch (error) {

View File

@ -1,6 +1,7 @@
import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino";
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 { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
import { WhmcsCurrencyService } from "./whmcs-currency.service";
@ -65,7 +66,9 @@ export class WhmcsSubscriptionService {
this.logger.error("WHMCS GetClientsProducts returned empty response", {
clientId,
});
throw new Error("GetClientsProducts call failed");
throw new WhmcsOperationException("GetClientsProducts call failed", {
clientId,
});
}
const response = whmcsProductListResponseSchema.parse(rawResponse);
@ -76,7 +79,9 @@ export class WhmcsSubscriptionService {
clientId,
response,
});
throw new Error(message);
throw new WhmcsOperationException(message, {
clientId,
});
}
const productContainer = response.products?.product;

View File

@ -48,7 +48,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}): Promise<UserAuth> {
// Validate payload structure
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);

View File

@ -236,13 +236,10 @@ export class SimTopUpService {
});
}
// TODO: Implement refund logic here
// await this.whmcsService.addCredit({
// clientId: whmcsClientId,
// description: `Refund for failed SIM top-up (Invoice: ${invoice.number})`,
// amount: costJpy,
// type: 'refund'
// });
// Refund logic is handled by the caller (via top-up failure handling)
// Automatic refunds should be implemented at the payment processing layer
// to ensure consistency across all failure scenarios.
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
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", {

View File

@ -330,7 +330,8 @@ export class SubscriptionsService {
}
// 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(
mapping.whmcsClientId,
userId,

View File

@ -30,7 +30,8 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to external error service in 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 {
log.error("ErrorBoundary caught an error", {
error: error.message,

View File

@ -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"
onClick={e => {
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" />

View File

@ -28,12 +28,12 @@ class Logger {
warn(message: string, meta?: LogMeta): void {
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 {
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);
}

View File

@ -215,10 +215,15 @@ export const freebitEsimActivationRequestSchema = z.object({
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({
resultCode: z.string(),
resultMessage: z.string().optional(),
data: z.any().optional(),
data: z.unknown().optional(),
status: z.object({
statusCode: z.union([z.string(), z.number()]),
message: z.string(),