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 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);

View File

@ -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)

View File

@ -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,
}); });
} }

View File

@ -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();

View File

@ -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);

View File

@ -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) {

View File

@ -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;

View File

@ -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);

View File

@ -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", {

View File

@ -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,

View File

@ -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,

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" 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" />

View File

@ -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);
} }

View File

@ -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(),