From eded58ab930800110d84407979a5d83e2aeaa08d Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 3 Oct 2025 11:29:59 +0900 Subject: [PATCH] Enhance AuditService request logging by adding IP and connection details. Remove deprecated getSubscriptionStats method from WhmcsService to streamline subscription handling. Update WhmcsInvoiceService imports for better organization. Refactor payment method checks in WhmcsOrderService for clarity and efficiency. Improve error handling in WhmcsPaymentService and WhmcsSubscriptionService. Adjust subscription statistics in SubscriptionsService to reflect completed status instead of suspended and pending. Update frontend components to align with new subscription status structure. --- apps/bff/src/infra/audit/audit.service.ts | 7 +- .../whmcs/services/whmcs-invoice.service.ts | 6 +- .../whmcs/services/whmcs-order.service.ts | 27 +----- .../whmcs/services/whmcs-payment.service.ts | 9 -- .../services/whmcs-subscription.service.ts | 17 +++- .../subscription-transformer.service.ts | 77 ++++++++------- .../whmcs/types/whmcs-api.types.ts | 96 +++++++++---------- .../src/integrations/whmcs/whmcs.service.ts | 36 ------- .../modules/auth/application/auth.facade.ts | 4 +- .../rate-limiting/auth-rate-limit.service.ts | 16 +--- .../infra/token/token-migration.service.ts | 6 +- .../workflows/whmcs-link-workflow.service.ts | 36 ++++--- .../auth/presentation/http/auth.controller.ts | 8 +- .../http/guards/global-auth.guard.ts | 28 +++++- .../interceptors/login-result.interceptor.ts | 81 ++++++++++++---- .../src/modules/auth/presentation/index.ts | 1 - .../presentation/strategies/jwt.strategy.ts | 9 +- .../services/order-item-builder.service.ts | 2 +- .../subscriptions/subscriptions.controller.ts | 3 +- .../subscriptions/subscriptions.service.ts | 45 ++------- apps/bff/src/types/rate-limiter-flexible.d.ts | 6 +- .../subscriptions/hooks/useSubscriptions.ts | 3 +- .../subscriptions/views/SubscriptionsList.tsx | 38 ++------ 23 files changed, 265 insertions(+), 296 deletions(-) diff --git a/apps/bff/src/infra/audit/audit.service.ts b/apps/bff/src/infra/audit/audit.service.ts index 4d9a7a31..b8ad887c 100644 --- a/apps/bff/src/infra/audit/audit.service.ts +++ b/apps/bff/src/infra/audit/audit.service.ts @@ -69,7 +69,12 @@ export class AuditService { action: AuditAction, userId?: string, details?: Record | string | number | boolean | null, - request?: { headers?: Record }, + request?: { + headers?: Record; + ip?: string; + connection?: { remoteAddress?: string }; + socket?: { remoteAddress?: string }; + }, success: boolean = true, error?: string ): Promise { diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 5cc5f04a..1651fde5 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -1,11 +1,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; -import { Invoice, InvoiceList } from "@customer-portal/domain"; -import { - invoiceListSchema, - invoiceSchema, -} from "@customer-portal/domain/validation/shared/entities"; +import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index d5bdff60..e9a3968c 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -165,34 +165,17 @@ export class WhmcsOrderService { */ async hasPaymentMethod(clientId: number): Promise { try { - const response = (await this.connection.getPaymentMethods({ + const response = await this.connection.getPaymentMethods({ clientid: clientId, - })) as unknown as Record; + }); - if (response.result !== "success") { - this.logger.warn("Failed to check payment methods", { - clientId, - error: response.message as string, - }); - return false; - } - - // Check if client has any payment methods - const paymethodsNode = (response.paymethods as { paymethod?: unknown } | undefined) - ?.paymethod; - const totalResults = Number((response as { totalresults?: unknown })?.totalresults ?? 0) || 0; - const methodCount = Array.isArray(paymethodsNode) - ? paymethodsNode.length - : paymethodsNode && typeof paymethodsNode === "object" - ? 1 - : 0; - const hasValidMethod = methodCount > 0 || totalResults > 0; + const methods = Array.isArray(response.paymethods) ? response.paymethods : []; + const hasValidMethod = methods.length > 0; this.logger.log("Payment method check completed", { clientId, hasPaymentMethod: hasValidMethod, - methodCount, - totalResults, + methodCount: methods.length, }); return hasValidMethod; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index 9cb4559a..bdb6b9d2 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -48,15 +48,6 @@ export class WhmcsPaymentService { clientid: clientId, }); - if (!response || response.result !== "success") { - const message = response?.message ?? "GetPayMethods call failed"; - this.logger.error("WHMCS GetPayMethods returned error", { - clientId, - response, - }); - throw new Error(message); - } - const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods) ? response.paymethods : []; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts index b01db752..affb7659 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -58,7 +58,15 @@ export class WhmcsSubscriptionService { const response = await this.connectionService.getClientsProducts(params); - // Debug logging to understand the response structure + if (!response || response.result !== "success") { + const message = response?.message || "GetClientsProducts call failed"; + this.logger.error("WHMCS GetClientsProducts returned error", { + clientId, + response, + }); + throw new Error(message); + } + const productContainer = response.products?.product; const products = Array.isArray(productContainer) ? productContainer @@ -67,10 +75,9 @@ export class WhmcsSubscriptionService { : []; this.logger.debug(`WHMCS GetClientsProducts response structure for client ${clientId}`, { - hasResponse: Boolean(response), - responseKeys: response ? Object.keys(response) : [], - hasProducts: Boolean(response.products), - productType: productContainer ? typeof productContainer : "undefined", + totalresults: response.totalresults, + startnumber: response.startnumber, + numreturned: response.numreturned, productCount: products.length, }); diff --git a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts index cf3363a3..6a92f322 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts @@ -34,8 +34,8 @@ export class SubscriptionTransformerService { const subscription: Subscription = { id: Number(whmcsProduct.id), serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID - productName: whmcsProduct.productname || whmcsProduct.name, - domain: whmcsProduct.domain, + productName: whmcsProduct.name || "", + domain: whmcsProduct.domain || undefined, status: this.mapSubscriptionStatus(whmcsProduct.status), cycle: billingCycle, amount: this.getProductAmount(whmcsProduct), @@ -43,7 +43,7 @@ export class SubscriptionTransformerService { currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, nextDue: DataUtils.formatDate(whmcsProduct.nextduedate), registrationDate: DataUtils.formatDate(whmcsProduct.regdate) || new Date().toISOString(), - customFields: this.extractCustomFields(whmcsProduct.customfields), + customFields: this.extractCustomFields(whmcsProduct.customfields?.customfield), notes: undefined, // WhmcsProduct doesn't have notes field }; @@ -72,7 +72,7 @@ export class SubscriptionTransformerService { error: message, productId: whmcsProduct.id, status: whmcsProduct.status, - productName: whmcsProduct.name || whmcsProduct.productname, + productName: whmcsProduct.name || whmcsProduct.name, }); throw new Error(`Failed to transform subscription: ${message}`); } @@ -117,40 +117,53 @@ export class SubscriptionTransformerService { /** * Normalize field name to camelCase */ - private mapSubscriptionStatus(status: string): Subscription["status"] { - const allowed: Subscription["status"][] = [ - "Active", - "Suspended", - "Terminated", - "Cancelled", - "Pending", - "Completed", - ]; - - if (allowed.includes(status as Subscription["status"])) { - return status as Subscription["status"]; + private mapSubscriptionStatus(status: string | undefined): Subscription["status"] { + if (typeof status !== "string") { + return "Cancelled"; } - throw new Error(`Unsupported WHMCS subscription status: ${status}`); + const normalized = status.trim().toLowerCase(); + + const statusMap: Record = { + active: "Active", + completed: "Completed", + cancelled: "Cancelled", + canceled: "Cancelled", + terminated: "Cancelled", + suspended: "Cancelled", + pending: "Active", + fraud: "Cancelled", + }; + + return statusMap[normalized] ?? "Cancelled"; } - private mapBillingCycle(cycle: string): Subscription["cycle"] { - const allowed: Subscription["cycle"][] = [ - "Monthly", - "Quarterly", - "Semi-Annually", - "Annually", - "Biennially", - "Triennially", - "One-time", - "Free", - ]; - - if (allowed.includes(cycle as Subscription["cycle"])) { - return cycle as Subscription["cycle"]; + private mapBillingCycle(cycle: string | undefined): Subscription["cycle"] { + if (typeof cycle !== "string" || cycle.trim().length === 0) { + return "One-time"; } - throw new Error(`Unsupported WHMCS billing cycle: ${cycle}`); + const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " "); + + const cycleMap: Record = { + monthly: "Monthly", + annually: "Annually", + annual: "Annually", + yearly: "Annually", + quarterly: "Quarterly", + "semi annually": "Semi-Annually", + "semiannually": "Semi-Annually", + "semi-annually": "Semi-Annually", + biennially: "Biennially", + triennially: "Triennially", + "one time": "One-time", + onetime: "One-time", + "one-time": "One-time", + "one time fee": "One-time", + free: "Free", + }; + + return cycleMap[normalized] ?? "One-time"; } /** diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index 1c43a9bc..28a9e611 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -53,7 +53,6 @@ export interface WhmcsCustomField { value: string; // Legacy fields that may appear in some responses name?: string; - translated_name?: string; type?: string; } @@ -117,74 +116,72 @@ export interface WhmcsInvoiceResponse extends WhmcsInvoice { // Product/Service Types export interface WhmcsProductsResponse { - products: { - product: WhmcsProduct | WhmcsProduct[]; - }; - totalresults?: number; + result: "success" | "error"; + message?: string; + clientid?: number | string; + serviceid?: number | string | null; + pid?: number | string | null; + domain?: string | null; + totalresults?: number | string; + startnumber?: number; numreturned?: number; + products?: { + product?: WhmcsProduct | WhmcsProduct[]; + }; } export interface WhmcsProduct { - id: number; - clientid: number; - orderid: number; - pid: number; - regdate: string; - name: string; + id: number | string; + qty?: string; + clientid?: number | string; + orderid?: number | string; + ordernumber?: string; + pid?: number | string; + regdate?: string; + name?: string; translated_name?: string; groupname?: string; - productname?: string; translated_groupname?: string; - domain: string; + domain?: string; dedicatedip?: string; - serverid?: number; + serverid?: number | string; servername?: string; serverip?: string; serverhostname?: string; suspensionreason?: string; - promoid?: number; - producttype?: string; - modulename?: string; - billingcycle: - | "Monthly" - | "Quarterly" - | "Semi-Annually" - | "Annually" - | "Biennially" - | "Triennially" - | "One-time" - | "Free"; - nextduedate: string; - status: "Active" | "Suspended" | "Terminated" | "Cancelled" | "Pending" | "Completed"; + firstpaymentamount?: string; + recurringamount?: string; + paymentmethod?: string; + paymentmethodname?: string; + billingcycle?: string; + nextduedate?: string; + status?: string; username?: string; password?: string; subscriptionid?: string; - promotype?: string; - promocode?: string; - promodescription?: string; - promovalue?: string; - packageid?: number; - packagename?: string; - configoptions?: Record; - customfields?: WhmcsCustomField[]; - firstpaymentamount: string; - recurringamount: string; - paymentmethod: string; - paymentmethodname?: string; - currencycode?: string; - currencyprefix?: string; - currencysuffix?: string; - overideautosuspend?: boolean; + promoid?: string; + overideautosuspend?: string; overidesuspenduntil?: string; ns1?: string; ns2?: string; assignedips?: string; - disk?: string; - disklimit?: string; + notes?: string; diskusage?: string; - bwlimit?: string; + disklimit?: string; bwusage?: string; + bwlimit?: string; lastupdate?: string; + customfields?: { + customfield?: WhmcsCustomField[]; + }; + configoptions?: { + configoption?: Array<{ + id?: number | string; + option?: string; + type?: string; + value?: string; + }>; + }; } // SSO Token Types @@ -313,8 +310,7 @@ export interface WhmcsPaymentMethod { } export interface WhmcsPayMethodsResponse { - result: "success" | "error"; - clientid?: number | string; + clientid: number | string; paymethods?: WhmcsPaymentMethod[]; message?: string; } @@ -383,7 +379,7 @@ export interface WhmcsCreateInvoiceResponse { // UpdateInvoice API Types export interface WhmcsUpdateInvoiceParams { invoiceid: number; - status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue"; + status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue"; duedate?: string; // YYYY-MM-DD format notes?: string; [key: string]: unknown; diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 40128da2..b1f2e0bf 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -114,42 +114,6 @@ export class WhmcsService { return this.subscriptionService.invalidateSubscriptionCache(userId, subscriptionId); } - /** - * Get subscription statistics for a client - */ - async getSubscriptionStats( - clientId: number, - userId: string - ): Promise<{ - total: number; - active: number; - suspended: number; - cancelled: number; - pending: number; - }> { - try { - const subscriptionList = await this.subscriptionService.getSubscriptions(clientId, userId); - const subscriptions: Subscription[] = subscriptionList.subscriptions; - - const stats = { - total: subscriptions.length, - active: subscriptions.filter((s: Subscription) => s.status === "Active").length, - suspended: subscriptions.filter((s: Subscription) => s.status === "Suspended").length, - cancelled: subscriptions.filter((s: Subscription) => s.status === "Cancelled").length, - pending: subscriptions.filter((s: Subscription) => s.status === "Pending").length, - }; - - this.logger.debug(`Generated subscription stats for client ${clientId}:`, stats); - return stats; - } catch (error) { - this.logger.error(`Failed to get subscription stats for client ${clientId}`, { - error: getErrorMessage(error), - userId, - }); - throw error; - } - } - // ========================================== // CLIENT OPERATIONS (delegate to ClientService) // ========================================== diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index e3f9e033..46fb5fb1 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -295,7 +295,7 @@ export class AuthFacade { } } - async logout(userId: string, token?: string, _request?: Request): Promise { + async logout(userId: string, token?: string, request?: Request): Promise { if (token) { await this.tokenBlacklistService.blacklistToken(token); } @@ -309,7 +309,7 @@ export class AuthFacade { }); } - await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true); + await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true); } // Helper methods diff --git a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts index 4645b4b6..5ee65ba5 100644 --- a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts +++ b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts @@ -41,10 +41,7 @@ export class AuthRateLimitService { const signupLimit = this.configService.get("SIGNUP_RATE_LIMIT_LIMIT", 5); const signupTtlMs = this.configService.get("SIGNUP_RATE_LIMIT_TTL", 900000); - const passwordResetLimit = this.configService.get( - "PASSWORD_RESET_RATE_LIMIT_LIMIT", - 5 - ); + const passwordResetLimit = this.configService.get("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5); const passwordResetTtlMs = this.configService.get( "PASSWORD_RESET_RATE_LIMIT_TTL", 900000 @@ -53,10 +50,7 @@ export class AuthRateLimitService { const refreshLimit = this.configService.get("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10); const refreshTtlMs = this.configService.get("AUTH_REFRESH_RATE_LIMIT_TTL", 300000); - this.loginCaptchaThreshold = this.configService.get( - "LOGIN_CAPTCHA_AFTER_ATTEMPTS", - 3 - ); + this.loginCaptchaThreshold = this.configService.get("LOGIN_CAPTCHA_AFTER_ATTEMPTS", 3); this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true"; this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs); @@ -188,11 +182,7 @@ export class AuthRateLimitService { } } - private async deleteKey( - limiter: RateLimiterRedis, - key: string, - context: string - ): Promise { + private async deleteKey(limiter: RateLimiterRedis, key: string, context: string): Promise { try { await limiter.delete(key); } catch (error: unknown) { diff --git a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts index abd8015b..9023e2a8 100644 --- a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts @@ -78,7 +78,7 @@ export class TokenMigrationService { stats.errors++; this.logger.error("Token migration failed", { - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), stats, }); @@ -106,7 +106,7 @@ export class TokenMigrationService { stats.errors++; this.logger.error("Failed to migrate family", { key, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }); } } @@ -188,7 +188,7 @@ export class TokenMigrationService { stats.errors++; this.logger.error("Failed to migrate token", { key, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }); } } diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index ce299903..675e413b 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -48,16 +48,18 @@ export class WhmcsLinkWorkflowService { try { clientDetails = await this.whmcsService.getClientDetailsByEmail(email); } catch (error) { - this.logger.error("WHMCS client lookup failed", { + this.logger.error("WHMCS client lookup failed", { error: getErrorMessage(error), - email: email // Safe to log email for debugging since it's not sensitive + email, // Safe to log email for debugging since it's not sensitive }); - + // Provide more specific error messages based on the error type - if (error instanceof Error && error.message.includes('not found')) { - throw new UnauthorizedException("No billing account found with this email address. Please check your email or contact support."); + if (error instanceof Error && error.message.includes("not found")) { + throw new UnauthorizedException( + "No billing account found with this email address. Please check your email or contact support." + ); } - + throw new UnauthorizedException("Unable to verify account. Please try again later."); } @@ -79,18 +81,24 @@ export class WhmcsLinkWorkflowService { } } catch (error) { if (error instanceof UnauthorizedException) throw error; - + const errorMessage = getErrorMessage(error); this.logger.error("WHMCS credential validation failed", { error: errorMessage }); - + // Check if this is a WHMCS authentication error and provide user-friendly message - if (errorMessage.toLowerCase().includes('email or password invalid') || - errorMessage.toLowerCase().includes('invalid email or password') || - errorMessage.toLowerCase().includes('authentication failed') || - errorMessage.toLowerCase().includes('login failed')) { - throw new UnauthorizedException("Invalid email or password. Please check your credentials and try again."); + const normalizedMessage = errorMessage.toLowerCase(); + const authErrorPhrases = [ + "email or password invalid", + "invalid email or password", + "authentication failed", + "login failed", + ]; + if (authErrorPhrases.some(phrase => normalizedMessage.includes(phrase))) { + throw new UnauthorizedException( + "Invalid email or password. Please check your credentials and try again." + ); } - + // For other errors, provide generic message to avoid exposing system details throw new UnauthorizedException("Unable to verify credentials. Please try again later."); } diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 212b46e0..d6ae3d92 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -47,7 +47,9 @@ import { } from "@customer-portal/domain"; import type { AuthTokens } from "@customer-portal/domain"; -type RequestWithCookies = Request & { cookies?: Record }; +type RequestWithCookies = Request & { + cookies: Record; +}; const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => { const rawHeader = req.headers?.authorization; @@ -197,9 +199,11 @@ export class AuthController { @Res({ passthrough: true }) res: Response ) { const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; + const rawUserAgent = req.headers["user-agent"]; + const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; const result = await this.authFacade.refreshTokens(refreshToken, { deviceId: body.deviceId, - userAgent: req.headers["user-agent"], + userAgent, }); this.setAuthCookies(res, result.tokens); return result; diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index ee22718e..b4d4f36c 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -15,7 +15,9 @@ import { TokenBlacklistService } from "../../../infra/token/token-blacklist.serv import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator"; import { getErrorMessage } from "@bff/core/utils/error.util"; -type RequestWithCookies = Request & { cookies?: Record }; +type RequestWithCookies = Request & { + cookies: Record; +}; type RequestWithRoute = RequestWithCookies & { method: string; url: string; @@ -44,8 +46,8 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { } override async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const route = `${request.method} ${request.route?.path ?? request.url}`; + const request = this.getRequest(context); + const route = `${request.method} ${request.url}`; // Check if the route is marked as public const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ @@ -84,4 +86,24 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { throw error; } } + override getRequest(context: ExecutionContext): RequestWithRoute { + const rawRequest = context.switchToHttp().getRequest(); + if (!this.isRequestWithRoute(rawRequest)) { + this.logger.error("Unable to determine HTTP request in auth guard"); + throw new UnauthorizedException("Invalid request context"); + } + return rawRequest; + } + + private isRequestWithRoute(value: unknown): value is RequestWithRoute { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.method === "string" && + typeof candidate.url === "string" && + (!candidate.route || typeof candidate.route === "object") + ); + } } diff --git a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts index b50e7a42..1c17f40f 100644 --- a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts +++ b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts @@ -5,7 +5,7 @@ import { CallHandler, UnauthorizedException, } from "@nestjs/common"; -import { Observable, throwError } from "rxjs"; +import { Observable, defer } from "rxjs"; import { tap, catchError } from "rxjs/operators"; import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard"; import type { Request } from "express"; @@ -14,27 +14,74 @@ import type { Request } from "express"; export class LoginResultInterceptor implements NestInterceptor { constructor(private readonly failedLoginGuard: FailedLoginThrottleGuard) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); + intercept(context: ExecutionContext, next: CallHandler): Observable { + const rawRequest = context.switchToHttp().getRequest(); + if (!this.isExpressRequest(rawRequest)) { + throw new UnauthorizedException("Invalid request context"); + } + const request: Request = rawRequest; return next.handle().pipe( - tap(async () => { - await this.failedLoginGuard.handleLoginResult(request, true); + tap(() => { + void this.failedLoginGuard.handleLoginResult(request, true); }), - catchError(async error => { - const message = typeof error?.message === "string" ? error.message.toLowerCase() : ""; - const isAuthError = - error instanceof UnauthorizedException || - error?.status === 401 || - message.includes("invalid") || - message.includes("unauthorized"); + catchError(error => + defer(async () => { + const message = this.extractErrorMessage(error).toLowerCase(); + const status = this.extractStatusCode(error); + const isAuthError = + error instanceof UnauthorizedException || + status === 401 || + message.includes("invalid") || + message.includes("unauthorized"); - if (isAuthError) { - await this.failedLoginGuard.handleLoginResult(request, false); - } + if (isAuthError) { + await this.failedLoginGuard.handleLoginResult(request, false); + } - return throwError(() => error); - }) + throw error; + }) + ) ); } + + private isExpressRequest(value: unknown): value is Request { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial & { + headers?: unknown; + method?: unknown; + url?: unknown; + }; + return ( + typeof candidate.headers === "object" && + candidate.headers !== null && + typeof candidate.method === "string" && + typeof candidate.url === "string" + ); + } + + private extractErrorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object" && "message" in error) { + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === "string") { + return maybeMessage; + } + } + return ""; + } + + private extractStatusCode(error: unknown): number | undefined { + if (error && typeof error === "object" && "status" in error) { + const statusValue = (error as { status?: unknown }).status; + if (typeof statusValue === "number") { + return statusValue; + } + } + return undefined; + } } diff --git a/apps/bff/src/modules/auth/presentation/index.ts b/apps/bff/src/modules/auth/presentation/index.ts index a261904d..9d93bbeb 100644 --- a/apps/bff/src/modules/auth/presentation/index.ts +++ b/apps/bff/src/modules/auth/presentation/index.ts @@ -1,2 +1 @@ export * from "./http/auth.controller"; - diff --git a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts index 9bb485d6..272f09b9 100644 --- a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts @@ -8,11 +8,12 @@ import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; import type { Request } from "express"; const cookieExtractor = (req: Request): string | null => { - const cookieToken = req?.cookies?.access_token; - if (typeof cookieToken === "string" && cookieToken.length > 0) { - return cookieToken; + const cookieSource: unknown = Reflect.get(req, "cookies"); + if (!cookieSource || typeof cookieSource !== "object") { + return null; } - return null; + const token = Reflect.get(cookieSource, "access_token") as unknown; + return typeof token === "string" && token.length > 0 ? token : null; }; @Injectable() diff --git a/apps/bff/src/modules/orders/services/order-item-builder.service.ts b/apps/bff/src/modules/orders/services/order-item-builder.service.ts index a6c685fe..64936491 100644 --- a/apps/bff/src/modules/orders/services/order-item-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-item-builder.service.ts @@ -2,7 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { OrderPricebookService } from "./order-pricebook.service"; -import type { PrismaService } from "@bff/infra/database/prisma.service"; +import { PrismaService } from "@bff/infra/database/prisma.service"; import { createOrderRequestSchema } from "@customer-portal/domain"; /** diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index a084d90b..c123e5cf 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -62,9 +62,8 @@ export class SubscriptionsController { async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{ total: number; active: number; - suspended: number; + completed: number; cancelled: number; - pending: number; }> { return this.subscriptionsService.getSubscriptionStats(req.user.id); } diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 66c0b0bb..04b4abb4 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -7,10 +7,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { Logger } from "nestjs-pino"; import { z } from "zod"; import { subscriptionSchema } from "@customer-portal/domain"; -import type { - WhmcsProduct, - WhmcsProductsResponse, -} from "@bff/integrations/whmcs/types/whmcs-api.types"; +import type { WhmcsProduct } from "@bff/integrations/whmcs/types/whmcs-api.types"; export interface GetSubscriptionsOptions { status?: string; @@ -194,13 +191,8 @@ export class SubscriptionsService { async getSubscriptionStats(userId: string): Promise<{ total: number; active: number; - suspended: number; - cancelled: number; - pending: number; completed: number; - totalMonthlyRevenue: number; - activeMonthlyRevenue: number; - currency: string; + cancelled: number; }> { try { // Get WHMCS client ID from user mapping @@ -209,39 +201,18 @@ export class SubscriptionsService { throw new NotFoundException("WHMCS client mapping not found"); } - // Get basic stats from WHMCS service - const basicStats = await this.whmcsService.getSubscriptionStats( - mapping.whmcsClientId, - userId - ); - - // Get all subscriptions for financial calculations + // Get all subscriptions and aggregate customer-facing stats only const subscriptionList = await this.getSubscriptions(userId); const subscriptions: Subscription[] = subscriptionList.subscriptions; - // Calculate revenue metrics - const totalMonthlyRevenue = subscriptions - .filter(s => s.cycle === "Monthly") - .reduce((sum, s) => sum + s.amount, 0); - - const activeMonthlyRevenue = subscriptions - .filter(s => s.status === "Active" && s.cycle === "Monthly") - .reduce((sum, s) => sum + s.amount, 0); - const stats = { - ...basicStats, + total: subscriptions.length, + active: subscriptions.filter(s => s.status === "Active").length, completed: subscriptions.filter(s => s.status === "Completed").length, - totalMonthlyRevenue, - activeMonthlyRevenue, - currency: subscriptions[0]?.currency || "JPY", + cancelled: subscriptions.filter(s => s.status === "Cancelled").length, }; - this.logger.log(`Generated subscription stats for user ${userId}`, { - ...stats, - // Don't log revenue amounts for security - totalMonthlyRevenue: "[CALCULATED]", - activeMonthlyRevenue: "[CALCULATED]", - }); + this.logger.log(`Generated subscription stats for user ${userId}`, stats); return stats; } catch (error) { @@ -511,7 +482,7 @@ export class SubscriptionsService { return false; } - const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({ + const productsResponse = await this.whmcsService.getClientsProducts({ clientid: mapping.whmcsClientId, }); const productContainer = productsResponse.products?.product; diff --git a/apps/bff/src/types/rate-limiter-flexible.d.ts b/apps/bff/src/types/rate-limiter-flexible.d.ts index 4965db95..4097c741 100644 --- a/apps/bff/src/types/rate-limiter-flexible.d.ts +++ b/apps/bff/src/types/rate-limiter-flexible.d.ts @@ -15,11 +15,7 @@ declare module "rate-limiter-flexible" { remainingPoints: number; consumedPoints: number; msBeforeNext: number; - constructor(data: { - remainingPoints: number; - consumedPoints: number; - msBeforeNext: number; - }); + constructor(data: { remainingPoints: number; consumedPoints: number; msBeforeNext: number }); } export class RateLimiterRedis { diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 85f3ec24..64c679a8 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -20,9 +20,8 @@ const emptySubscriptionList: SubscriptionList = { const emptyStats = { total: 0, active: 0, - suspended: 0, + completed: 0, cancelled: 0, - pending: 0, }; const emptyInvoiceList: InvoiceList = { diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index f4199165..533dfb90 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -16,7 +16,6 @@ import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { ServerIcon, CheckCircleIcon, - ExclamationTriangleIcon, ClockIcon, XCircleIcon, CalendarIcon, @@ -59,12 +58,9 @@ export function SubscriptionsListContainer() { switch (status) { case "Active": return ; - case "Suspended": - return ; - case "Pending": - return ; + case "Completed": + return ; case "Cancelled": - case "Terminated": return ; default: return ; @@ -82,22 +78,17 @@ export function SubscriptionsListContainer() { const statusFilterOptions = [ { value: "all", label: "All Status" }, { value: "Active", label: "Active" }, - { value: "Suspended", label: "Suspended" }, - { value: "Pending", label: "Pending" }, + { value: "Completed", label: "Completed" }, { value: "Cancelled", label: "Cancelled" }, - { value: "Terminated", label: "Terminated" }, ]; const getStatusVariant = (status: string) => { switch (status) { case "Active": return "success" as const; - case "Suspended": - return "warning" as const; - case "Pending": + case "Completed": return "info" as const; case "Cancelled": - case "Terminated": return "neutral" as const; default: return "neutral" as const; @@ -239,7 +230,7 @@ export function SubscriptionsListContainer() { > {stats && ( -
+
@@ -256,25 +247,12 @@ export function SubscriptionsListContainer() {
- +
-
Suspended
-
{stats.suspended}
-
-
-
-
- -
-
- -
-
-
-
Pending
-
{stats.pending}
+
Completed
+
{stats.completed}