diff --git a/README.md b/README.md index 61ce407d..d199c8a5 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ const response = await apiClient.GET("/api/me/summary"); const summary = getDataOrThrow(response); ``` -Because the schemas and types live in the shared domain package there is no code -generation step—`pnpm types:gen` is now a no-op placeholder. +Because the schemas and types live in the shared domain package there is no separate +code generation step. ### Environment Configuration diff --git a/apps/bff/package.json b/apps/bff/package.json index 65c26790..440b6440 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -50,7 +50,6 @@ "express": "^5.1.0", "helmet": "^8.1.0", "ioredis": "^5.7.0", - "rate-limiter-flexible": "^4.0.0", "jsforce": "^3.10.4", "jsonwebtoken": "^9.0.2", "nestjs-pino": "^4.4.0", @@ -62,6 +61,7 @@ "pino": "^9.9.0", "pino-http": "^10.5.0", "pino-pretty": "^13.1.1", + "rate-limiter-flexible": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "salesforce-pubsub-api-client": "^5.5.0", diff --git a/apps/bff/src/core/database/services/distributed-transaction.service.ts b/apps/bff/src/core/database/services/distributed-transaction.service.ts index 7a651019..63b5e974 100644 --- a/apps/bff/src/core/database/services/distributed-transaction.service.ts +++ b/apps/bff/src/core/database/services/distributed-transaction.service.ts @@ -310,7 +310,7 @@ export class DistributedTransactionService { if (!dbTransactionResult.success) { // Rollback external operations - const executedExternalSteps = Object.keys(externalResult.stepResults) as string[]; + const executedExternalSteps = Object.keys(externalResult.stepResults); await this.executeRollbacks(externalSteps, executedExternalSteps, transactionId); throw new Error(dbTransactionResult.error || "Database transaction failed"); } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 525abd5d..53d71acd 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { WhmcsApiResponse, WhmcsErrorResponse } from "../../types/whmcs-api.types"; +import type { WhmcsApiResponse } from "../../types/whmcs-api.types"; import type { WhmcsApiConfig, WhmcsRequestOptions, @@ -244,7 +244,7 @@ export class WhmcsHttpClientService { action: string, params: Record ): WhmcsApiResponse { - let parsedResponse: any; + let parsedResponse: unknown; try { parsedResponse = JSON.parse(responseText); @@ -258,7 +258,7 @@ export class WhmcsHttpClientService { } // Validate basic response structure - if (!parsedResponse || typeof parsedResponse !== 'object') { + if (!this.isWhmcsResponse(parsedResponse)) { this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { responseType: typeof parsedResponse, responseText: responseText.substring(0, 500), @@ -269,25 +269,62 @@ export class WhmcsHttpClientService { // Handle error responses according to WHMCS API documentation if (parsedResponse.result === "error") { - const errorMessage = parsedResponse.message || parsedResponse.error || "Unknown WHMCS API error"; - const errorCode = parsedResponse.errorcode || "unknown"; - + const errorMessage = this.toDisplayString( + parsedResponse.message ?? parsedResponse.error, + "Unknown WHMCS API error" + ); + const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown"); + this.logger.error(`WHMCS API returned error [${action}]`, { errorMessage, errorCode, params: this.sanitizeLogParams(params), }); - + throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})`); } // For successful responses, WHMCS API returns data directly at the root level // The response structure is: { "result": "success", ...actualData } // We return the parsed response directly as T since it contains the actual data + const { result, message, ...rest } = parsedResponse; return { - result: "success", - data: parsedResponse as T - } as WhmcsApiResponse; + result, + message: typeof message === "string" ? message : undefined, + data: rest as T, + } satisfies WhmcsApiResponse; + } + + private isWhmcsResponse(value: unknown): value is { + result: "success" | "error"; + message?: unknown; + error?: unknown; + errorcode?: unknown; + } & Record { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + const rawResult = record.result; + return rawResult === "success" || rawResult === "error"; + } + + private toDisplayString(value: unknown, fallback: string): string { + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value && typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return fallback; + } + } + return fallback; } /** 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 b1b7248a..b01db752 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -36,8 +36,9 @@ export class WhmcsSubscriptionService { // Apply status filter if needed if (filters.status) { + const statusFilter = filters.status.toLowerCase(); const filtered = cached.subscriptions.filter( - (sub: Subscription) => sub.status.toLowerCase() === filters.status!.toLowerCase() + (sub: Subscription) => sub.status.toLowerCase() === statusFilter ); return { subscriptions: filtered, @@ -58,22 +59,21 @@ export class WhmcsSubscriptionService { const response = await this.connectionService.getClientsProducts(params); // Debug logging to understand the response structure - this.logger.debug(`WHMCS GetClientsProducts response structure for client ${clientId}`, { - hasResponse: !!response, - responseKeys: response ? Object.keys(response) : [], - hasProducts: !!(response as any)?.products, - productsKeys: (response as any)?.products ? Object.keys((response as any).products) : [], - hasProductArray: Array.isArray((response as any)?.products?.product), - productCount: Array.isArray((response as any)?.products?.product) ? (response as any).products.product.length : 0, - }); - - const productData = response.products?.product; - const products = Array.isArray(productData) - ? productData - : productData - ? [productData] + const productContainer = response.products?.product; + const products = Array.isArray(productContainer) + ? productContainer + : productContainer + ? [productContainer] : []; + 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", + productCount: products.length, + }); + if (products.length === 0) { this.logger.warn(`No products found for client ${clientId}`, { responseStructure: response ? Object.keys(response) : "null response", @@ -109,8 +109,9 @@ export class WhmcsSubscriptionService { // Apply status filter if needed if (filters.status) { + const statusFilter = filters.status.toLowerCase(); const filtered = result.subscriptions.filter( - (sub: Subscription) => sub.status.toLowerCase() === filters.status!.toLowerCase() + (sub: Subscription) => sub.status.toLowerCase() === statusFilter ); return { subscriptions: filtered, diff --git a/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts b/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts index 8b137891..cb0ff5c3 100644 --- a/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts +++ b/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts @@ -1 +1 @@ - +export {}; 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 0288ecfd..1c43a9bc 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -118,7 +118,7 @@ export interface WhmcsInvoiceResponse extends WhmcsInvoice { // Product/Service Types export interface WhmcsProductsResponse { products: { - product: WhmcsProduct[]; + product: WhmcsProduct | WhmcsProduct[]; }; totalresults?: number; numreturned?: number; @@ -383,14 +383,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/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 197ea6d3..4645b4b6 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 @@ -6,8 +6,9 @@ import type { Request } from "express"; import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible"; import { createHash } from "crypto"; import type { Redis } from "ioredis"; +import { getErrorMessage } from "@bff/core/utils/error.util"; -interface RateLimitOutcome { +export interface RateLimitOutcome { key: string; remainingPoints: number; consumedPoints: number; @@ -15,10 +16,6 @@ interface RateLimitOutcome { needsCaptcha: boolean; } -interface EnsureResult extends RateLimitOutcome { - headerValue?: string; -} - @Injectable() export class AuthRateLimitService { private readonly loginLimiter: RateLimiterRedis; @@ -44,8 +41,14 @@ 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 passwordResetTtlMs = this.configService.get("PASSWORD_RESET_RATE_LIMIT_TTL", 900000); + const passwordResetLimit = this.configService.get( + "PASSWORD_RESET_RATE_LIMIT_LIMIT", + 5 + ); + const passwordResetTtlMs = this.configService.get( + "PASSWORD_RESET_RATE_LIMIT_TTL", + 900000 + ); const refreshLimit = this.configService.get("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10); const refreshTtlMs = this.configService.get("AUTH_REFRESH_RATE_LIMIT_TTL", 300000); @@ -142,11 +145,14 @@ export class AuthRateLimitService { keyPrefix: prefix, points: limit, duration, - inmemoryBlockOnConsumed: limit + 1, + inMemoryBlockOnConsumed: limit + 1, insuranceLimiter: undefined, }); - } catch (error) { - this.logger.error(error, `Failed to initialize rate limiter: ${prefix}`); + } catch (error: unknown) { + this.logger.error( + { prefix, error: getErrorMessage(error) }, + "Failed to initialize rate limiter" + ); throw new InternalServerErrorException("Rate limiter initialization failed"); } } @@ -167,9 +173,9 @@ export class AuthRateLimitService { msBeforeNext: res.msBeforeNext, needsCaptcha: false, }; - } catch (error) { + } catch (error: unknown) { if (error instanceof RateLimiterRes) { - const retryAfterMs = error.msBeforeNext || 0; + const retryAfterMs = error?.msBeforeNext ?? 0; const message = this.buildThrottleMessage(context, retryAfterMs); this.logger.warn({ key, context, retryAfterMs }, "Auth rate limit reached"); @@ -177,7 +183,7 @@ export class AuthRateLimitService { throw new ThrottlerException(message); } - this.logger.error(error, "Rate limiter failure"); + this.logger.error({ key, context, error: getErrorMessage(error) }, "Rate limiter failure"); throw new ThrottlerException("Authentication temporarily unavailable"); } } @@ -189,8 +195,11 @@ export class AuthRateLimitService { ): Promise { try { await limiter.delete(key); - } catch (error) { - this.logger.warn({ key, context, error }, "Failed to reset rate limiter key"); + } catch (error: unknown) { + this.logger.warn( + { key, context, error: getErrorMessage(error) }, + "Failed to reset rate limiter key" + ); } } @@ -201,9 +210,7 @@ export class AuthRateLimitService { } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; - const timeMessage = remainingSeconds - ? `${minutes}m ${remainingSeconds}s` - : `${minutes}m`; + const timeMessage = remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; return `Too many ${context} attempts. Try again in ${timeMessage}.`; } @@ -211,4 +218,3 @@ export class AuthRateLimitService { return createHash("sha256").update(token).digest("hex"); } } - 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 74242879..abd8015b 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 @@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; +import { getErrorMessage } from "@bff/core/utils/error.util"; export interface MigrationStats { totalKeysScanned: number; @@ -14,6 +15,15 @@ export interface MigrationStats { duration: number; } +interface ParsedFamily { + userId: string; +} + +interface ParsedToken { + userId: string; + familyId: string; +} + @Injectable() export class TokenMigrationService { private readonly REFRESH_TOKEN_FAMILY_PREFIX = "refresh_family:"; @@ -119,54 +129,43 @@ export class TokenMigrationService { stats.familiesFound++; - try { - const family = JSON.parse(familyData); - - // Validate family structure - if (!family.userId || typeof family.userId !== "string") { - this.logger.warn("Invalid family structure, skipping", { familyKey }); - return; - } - - const familyId = familyKey.replace(this.REFRESH_TOKEN_FAMILY_PREFIX, ""); - const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${family.userId}`; - - // Check if this family is already in the user's set - const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, familyId); - - if (isAlreadyMigrated) { - this.logger.debug("Family already migrated", { familyKey, userId: family.userId }); - return; - } - - if (!dryRun) { - // Add family to user's token set - const pipeline = this.redis.pipeline(); - pipeline.sadd(userFamilySetKey, familyId); - - // Set expiration on the user set (use the same TTL as the family) - const ttl = await this.redis.ttl(familyKey); - if (ttl > 0) { - pipeline.expire(userFamilySetKey, ttl); - } - - await pipeline.exec(); - } - - stats.familiesMigrated++; - - this.logger.debug("Migrated family to user set", { - familyKey, - userId: family.userId, - dryRun, - }); - } catch (error) { - this.logger.error("Failed to parse family data", { - familyKey, - error: error instanceof Error ? error.message : String(error), - }); - stats.errors++; + const family = this.parseFamilyData(familyKey, familyData, stats); + if (!family) { + return; } + + const familyId = familyKey.replace(this.REFRESH_TOKEN_FAMILY_PREFIX, ""); + const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${family.userId}`; + + // Check if this family is already in the user's set + const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, familyId); + + if (isAlreadyMigrated) { + this.logger.debug("Family already migrated", { familyKey, userId: family.userId }); + return; + } + + if (!dryRun) { + // Add family to user's token set + const pipeline = this.redis.pipeline(); + pipeline.sadd(userFamilySetKey, familyId); + + // Set expiration on the user set (use the same TTL as the family) + const ttl = await this.redis.ttl(familyKey); + if (ttl > 0) { + pipeline.expire(userFamilySetKey, ttl); + } + + await pipeline.exec(); + } + + stats.familiesMigrated++; + + this.logger.debug("Migrated family to user set", { + familyKey, + userId: family.userId, + dryRun, + }); } /** @@ -212,73 +211,62 @@ export class TokenMigrationService { stats.tokensFound++; - try { - const token = JSON.parse(tokenData); + const token = this.parseTokenData(tokenKey, tokenData, stats); + if (!token) { + return; + } - // Validate token structure - if (!token.familyId || !token.userId || typeof token.userId !== "string") { - this.logger.warn("Invalid token structure, skipping", { tokenKey }); - return; - } + // Check if the corresponding family exists + const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`; + const familyExists = await this.redis.exists(familyKey); - // Check if the corresponding family exists - const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`; - const familyExists = await this.redis.exists(familyKey); - - if (!familyExists) { - stats.orphanedTokens++; - this.logger.warn("Found orphaned token (no corresponding family)", { - tokenKey, - familyId: token.familyId, - userId: token.userId, - }); - - if (!dryRun) { - // Remove orphaned token - await this.redis.del(tokenKey); - this.logger.debug("Removed orphaned token", { tokenKey }); - } - return; - } - - // Check if this token's family is already in the user's set - const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${token.userId}`; - const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, token.familyId); - - if (isAlreadyMigrated) { - stats.tokensMigrated++; - return; - } - - if (!dryRun) { - // Add family to user's token set - const pipeline = this.redis.pipeline(); - pipeline.sadd(userFamilySetKey, token.familyId); - - // Set expiration on the user set (use the same TTL as the token) - const ttl = await this.redis.ttl(tokenKey); - if (ttl > 0) { - pipeline.expire(userFamilySetKey, ttl); - } - - await pipeline.exec(); - } - - stats.tokensMigrated++; - - this.logger.debug("Migrated token family to user set", { + if (!familyExists) { + stats.orphanedTokens++; + this.logger.warn("Found orphaned token (no corresponding family)", { tokenKey, familyId: token.familyId, userId: token.userId, - dryRun, }); - } catch (error) { - this.logger.error("Failed to parse token data", { - tokenKey, - error: error instanceof Error ? error.message : String(error), - }); - stats.errors++; + + if (!dryRun) { + // Remove orphaned token + await this.redis.del(tokenKey); + this.logger.debug("Removed orphaned token", { tokenKey }); + } + return; } + + // Check if this token's family is already in the user's set + const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${token.userId}`; + const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, token.familyId); + + if (isAlreadyMigrated) { + stats.tokensMigrated++; + return; + } + + if (!dryRun) { + // Add family to user's token set + const pipeline = this.redis.pipeline(); + pipeline.sadd(userFamilySetKey, token.familyId); + + // Set expiration on the user set (use the same TTL as the token) + const ttl = await this.redis.ttl(tokenKey); + if (ttl > 0) { + pipeline.expire(userFamilySetKey, ttl); + } + + await pipeline.exec(); + } + + stats.tokensMigrated++; + + this.logger.debug("Migrated token family to user set", { + tokenKey, + familyId: token.familyId, + userId: token.userId, + dryRun, + }); } /** @@ -302,8 +290,10 @@ export class TokenMigrationService { const tokenData = await this.redis.get(key); if (!tokenData) continue; - const token = JSON.parse(tokenData); - if (!token.familyId) continue; + const token = this.parseTokenData(key, tokenData, stats); + if (!token) { + continue; + } // Check if the corresponding family exists const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`; @@ -325,7 +315,7 @@ export class TokenMigrationService { stats.errors++; this.logger.error("Failed to cleanup token", { key, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }); } } @@ -401,4 +391,71 @@ export class TokenMigrationService { return stats; } + + private parseFamilyData( + familyKey: string, + raw: string, + tracker: { errors: number } + ): ParsedFamily | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + tracker.errors++; + this.logger.error("Failed to parse family data", { + familyKey, + error: getErrorMessage(error), + }); + return null; + } + + if (!parsed || typeof parsed !== "object") { + this.logger.warn("Invalid family structure, skipping", { familyKey }); + return null; + } + + const record = parsed as Record; + const userId = record.userId; + + if (typeof userId !== "string" || userId.length === 0) { + this.logger.warn("Invalid family structure, skipping", { familyKey }); + return null; + } + + return { userId }; + } + + private parseTokenData( + tokenKey: string, + raw: string, + tracker: { errors: number } + ): ParsedToken | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + tracker.errors++; + this.logger.error("Failed to parse token data", { + tokenKey, + error: getErrorMessage(error), + }); + return null; + } + + if (!parsed || typeof parsed !== "object") { + this.logger.warn("Invalid token structure, skipping", { tokenKey }); + return null; + } + + const record = parsed as Record; + const userId = record.userId; + const familyId = record.familyId; + + if (typeof userId !== "string" || typeof familyId !== "string") { + this.logger.warn("Invalid token structure, skipping", { tokenKey }); + return null; + } + + return { userId, familyId }; + } } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts index 3fff3f94..be42786d 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts @@ -1,16 +1,21 @@ import { Injectable, ExecutionContext } from "@nestjs/common"; import type { Request } from "express"; -import { AuthRateLimitService } from "../../../infra/rate-limiting/auth-rate-limit.service"; +import { + AuthRateLimitService, + type RateLimitOutcome, +} from "../../../infra/rate-limiting/auth-rate-limit.service"; + +type RequestWithRateLimit = Request & { __authRateLimit?: RateLimitOutcome }; @Injectable() export class FailedLoginThrottleGuard { constructor(private readonly authRateLimitService: AuthRateLimitService) {} async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); const outcome = await this.authRateLimitService.consumeLoginAttempt(request); - (request as any).__authRateLimit = outcome; + request.__authRateLimit = outcome; return true; } 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 8d6d5186..ee22718e 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 @@ -13,8 +13,14 @@ import type { Request } from "express"; import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service"; import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator"; +import { getErrorMessage } from "@bff/core/utils/error.util"; type RequestWithCookies = Request & { cookies?: Record }; +type RequestWithRoute = RequestWithCookies & { + method: string; + url: string; + route?: { path?: string }; +}; const headerExtractor = ExtractJwt.fromAuthHeaderAsBearerToken(); const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => { @@ -38,11 +44,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { } override async canActivate(context: ExecutionContext): Promise { - const request = context - .switchToHttp() - .getRequest< - RequestWithCookies & { method: string; url: string; route?: { path?: string } } - >(); + const request = context.switchToHttp().getRequest(); const route = `${request.method} ${request.route?.path ?? request.url}`; // Check if the route is marked as public @@ -78,7 +80,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { this.logger.debug(`Authenticated access to: ${route}`); return true; } catch (error) { - this.logger.error(`Authentication error for route ${route}:`, error); + this.logger.error(`Authentication error for route ${route}: ${getErrorMessage(error)}`); throw error; } } 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 455383bd..a6c685fe 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 @@ -3,7 +3,6 @@ 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 { z } from "zod"; import { createOrderRequestSchema } from "@customer-portal/domain"; /** diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 13c6b7a0..fa1565ae 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -10,6 +10,7 @@ import { type CreateOrderRequest, type OrderBusinessValidation, } from "@customer-portal/domain"; +import type { WhmcsProduct } from "@bff/integrations/whmcs/types/whmcs-api.types"; import { OrderPricebookService } from "./order-pricebook.service"; /** @@ -107,11 +108,12 @@ export class OrderValidator { async validatePaymentMethod(userId: string, whmcsClientId: number): Promise { try { const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId }); - if (!Array.isArray(pay?.paymethods) || pay.paymethods.length === 0) { + const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : []; + if (paymentMethods.length === 0) { this.logger.warn({ userId }, "No WHMCS payment method on file"); throw new BadRequestException("A payment method is required before ordering"); } - } catch (e) { + } catch (e: unknown) { const err = getErrorMessage(e); this.logger.error({ err }, "Payment method verification failed"); throw new BadRequestException("Unable to verify payment method. Please try again later."); @@ -124,14 +126,19 @@ export class OrderValidator { async validateInternetDuplication(userId: string, whmcsClientId: number): Promise { try { const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); - const existing = products?.products?.product || []; - const hasInternet = existing.some(product => - (product.groupname || "").toLowerCase().includes("internet") + const productContainer = products.products?.product; + const existing = Array.isArray(productContainer) + ? productContainer + : productContainer + ? [productContainer] + : []; + const hasInternet = existing.some((product: WhmcsProduct) => + (product.groupname || product.translated_groupname || "").toLowerCase().includes("internet") ); if (hasInternet) { throw new BadRequestException("An Internet service already exists for this account"); } - } catch (e) { + } catch (e: unknown) { const err = getErrorMessage(e); this.logger.error({ err }, "Internet duplicate check failed"); throw new BadRequestException( diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 89bd7aef..66c0b0bb 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -514,7 +514,12 @@ export class SubscriptionsService { const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({ clientid: mapping.whmcsClientId, }); - const services = productsResponse.products?.product ?? []; + const productContainer = productsResponse.products?.product; + const services = Array.isArray(productContainer) + ? productContainer + : productContainer + ? [productContainer] + : []; return services.some((service: WhmcsProduct) => { const group = typeof service.groupname === "string" ? service.groupname.toLowerCase() : ""; diff --git a/apps/bff/tsconfig.json b/apps/bff/tsconfig.json index da051e35..6e99c21a 100644 --- a/apps/bff/tsconfig.json +++ b/apps/bff/tsconfig.json @@ -31,6 +31,6 @@ "module": "CommonJS" } }, - "include": ["src/**/*", "scripts/**/*"], + "include": ["src/**/*", "scripts/**/*", "src/types/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/package.json b/package.json index 55f3b803..6d2e62e5 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,7 @@ "update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check", "dev:watch": "pnpm --parallel --filter @customer-portal/domain --filter @customer-portal/portal --filter @customer-portal/bff run dev", "plesk:images": "bash ./scripts/plesk/build-images.sh", - "types:gen": "./scripts/generate-frontend-types.sh", - "codegen": "pnpm types:gen", - "postinstall": "pnpm codegen || true" + "postinstall": "husky install || true" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3dd6f57..5f69ce28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: '@nestjs/platform-express': specifier: ^11.1.6 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) - '@nestjs/swagger': - specifier: ^11.2.0 - version: 11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) '@nestjs/throttler': specifier: ^6.4.0 version: 6.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2) @@ -152,6 +149,9 @@ importers: pino-pretty: specifier: ^13.1.1 version: 13.1.1 + rate-limiter-flexible: + specifier: ^4.0.1 + version: 4.0.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -285,9 +285,6 @@ importers: next: specifier: 15.5.0 version: 15.5.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - openapi-fetch: - specifier: ^0.13.5 - version: 0.13.8 react: specifier: 19.1.1 version: 19.1.1 @@ -4238,12 +4235,6 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} - openapi-fetch@0.13.8: - resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==} - - openapi-typescript-helpers@0.0.15: - resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} - opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -4491,6 +4482,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@4.0.1: + resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + raw-body@3.0.1: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} @@ -6285,7 +6279,8 @@ snapshots: '@lukeed/csprng@1.1.0': {} - '@microsoft/tsdoc@0.15.1': {} + '@microsoft/tsdoc@0.15.1': + optional: true '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -6403,6 +6398,7 @@ snapshots: optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.2 + optional: true '@nestjs/passport@11.0.5(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': dependencies: @@ -6457,6 +6453,7 @@ snapshots: optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.2 + optional: true '@nestjs/testing@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-express@11.1.6)': dependencies: @@ -6602,7 +6599,8 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} - '@scarf/scarf@1.4.0': {} + '@scarf/scarf@1.4.0': + optional: true '@sendgrid/client@8.1.5': dependencies: @@ -9706,12 +9704,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openapi-fetch@0.13.8: - dependencies: - openapi-typescript-helpers: 0.0.15 - - openapi-typescript-helpers@0.0.15: {} - opener@1.5.2: {} optionator@0.9.4: @@ -9983,6 +9975,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@4.0.1: {} + raw-body@3.0.1: dependencies: bytes: 3.1.2 @@ -10480,6 +10474,7 @@ snapshots: swagger-ui-dist@5.21.0: dependencies: '@scarf/scarf': 1.4.0 + optional: true symbol-observable@4.0.0: {} diff --git a/scripts/generate-frontend-types.sh b/scripts/generate-frontend-types.sh deleted file mode 100755 index e101907d..00000000 --- a/scripts/generate-frontend-types.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -# Zod-based frontend type support -# OpenAPI generation has been removed; shared schemas live in @customer-portal/domain. - -set -e - -echo "ℹ️ Skipping OpenAPI generation: frontend consumes shared Zod schemas from @customer-portal/domain."