Refactor BFF services and update dependencies. Remove deprecated type generation scripts and streamline post-install processes in package.json. Enhance WHMCS integration by improving error handling and response validation. Update README to reflect changes in type generation and service structure. Adjust various services for better maintainability and clarity in data handling.

This commit is contained in:
barsa 2025-10-02 18:47:30 +09:00
parent cdec21e012
commit 0c3aa9ff4b
18 changed files with 315 additions and 218 deletions

View File

@ -177,8 +177,8 @@ const response = await apiClient.GET<DashboardSummary>("/api/me/summary");
const summary = getDataOrThrow(response); const summary = getDataOrThrow(response);
``` ```
Because the schemas and types live in the shared domain package there is no code Because the schemas and types live in the shared domain package there is no separate
generation step`pnpm types:gen` is now a no-op placeholder. code generation step.
### Environment Configuration ### Environment Configuration

View File

@ -50,7 +50,6 @@
"express": "^5.1.0", "express": "^5.1.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"rate-limiter-flexible": "^4.0.0",
"jsforce": "^3.10.4", "jsforce": "^3.10.4",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nestjs-pino": "^4.4.0", "nestjs-pino": "^4.4.0",
@ -62,6 +61,7 @@
"pino": "^9.9.0", "pino": "^9.9.0",
"pino-http": "^10.5.0", "pino-http": "^10.5.0",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"rate-limiter-flexible": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"salesforce-pubsub-api-client": "^5.5.0", "salesforce-pubsub-api-client": "^5.5.0",

View File

@ -310,7 +310,7 @@ export class DistributedTransactionService {
if (!dbTransactionResult.success) { if (!dbTransactionResult.success) {
// Rollback external operations // Rollback external operations
const executedExternalSteps = Object.keys(externalResult.stepResults) as string[]; const executedExternalSteps = Object.keys(externalResult.stepResults);
await this.executeRollbacks(externalSteps, executedExternalSteps, transactionId); await this.executeRollbacks(externalSteps, executedExternalSteps, transactionId);
throw new Error(dbTransactionResult.error || "Database transaction failed"); throw new Error(dbTransactionResult.error || "Database transaction failed");
} }

View File

@ -1,7 +1,7 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } 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 type { WhmcsApiResponse, WhmcsErrorResponse } from "../../types/whmcs-api.types"; import type { WhmcsApiResponse } from "../../types/whmcs-api.types";
import type { import type {
WhmcsApiConfig, WhmcsApiConfig,
WhmcsRequestOptions, WhmcsRequestOptions,
@ -244,7 +244,7 @@ export class WhmcsHttpClientService {
action: string, action: string,
params: Record<string, unknown> params: Record<string, unknown>
): WhmcsApiResponse<T> { ): WhmcsApiResponse<T> {
let parsedResponse: any; let parsedResponse: unknown;
try { try {
parsedResponse = JSON.parse(responseText); parsedResponse = JSON.parse(responseText);
@ -258,7 +258,7 @@ export class WhmcsHttpClientService {
} }
// Validate basic response structure // Validate basic response structure
if (!parsedResponse || typeof parsedResponse !== 'object') { if (!this.isWhmcsResponse(parsedResponse)) {
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
responseType: typeof parsedResponse, responseType: typeof parsedResponse,
responseText: responseText.substring(0, 500), responseText: responseText.substring(0, 500),
@ -269,25 +269,62 @@ export class WhmcsHttpClientService {
// Handle error responses according to WHMCS API documentation // Handle error responses according to WHMCS API documentation
if (parsedResponse.result === "error") { if (parsedResponse.result === "error") {
const errorMessage = parsedResponse.message || parsedResponse.error || "Unknown WHMCS API error"; const errorMessage = this.toDisplayString(
const errorCode = parsedResponse.errorcode || "unknown"; parsedResponse.message ?? parsedResponse.error,
"Unknown WHMCS API error"
);
const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown");
this.logger.error(`WHMCS API returned error [${action}]`, { this.logger.error(`WHMCS API returned error [${action}]`, {
errorMessage, errorMessage,
errorCode, errorCode,
params: this.sanitizeLogParams(params), params: this.sanitizeLogParams(params),
}); });
throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})`); throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})`);
} }
// For successful responses, WHMCS API returns data directly at the root level // For successful responses, WHMCS API returns data directly at the root level
// The response structure is: { "result": "success", ...actualData } // The response structure is: { "result": "success", ...actualData }
// We return the parsed response directly as T since it contains the actual data // We return the parsed response directly as T since it contains the actual data
const { result, message, ...rest } = parsedResponse;
return { return {
result: "success", result,
data: parsedResponse as T message: typeof message === "string" ? message : undefined,
} as WhmcsApiResponse<T>; data: rest as T,
} satisfies WhmcsApiResponse<T>;
}
private isWhmcsResponse(value: unknown): value is {
result: "success" | "error";
message?: unknown;
error?: unknown;
errorcode?: unknown;
} & Record<string, unknown> {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
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;
} }
/** /**

View File

@ -36,8 +36,9 @@ export class WhmcsSubscriptionService {
// Apply status filter if needed // Apply status filter if needed
if (filters.status) { if (filters.status) {
const statusFilter = filters.status.toLowerCase();
const filtered = cached.subscriptions.filter( const filtered = cached.subscriptions.filter(
(sub: Subscription) => sub.status.toLowerCase() === filters.status!.toLowerCase() (sub: Subscription) => sub.status.toLowerCase() === statusFilter
); );
return { return {
subscriptions: filtered, subscriptions: filtered,
@ -58,22 +59,21 @@ export class WhmcsSubscriptionService {
const response = await this.connectionService.getClientsProducts(params); const response = await this.connectionService.getClientsProducts(params);
// Debug logging to understand the response structure // Debug logging to understand the response structure
this.logger.debug(`WHMCS GetClientsProducts response structure for client ${clientId}`, { const productContainer = response.products?.product;
hasResponse: !!response, const products = Array.isArray(productContainer)
responseKeys: response ? Object.keys(response) : [], ? productContainer
hasProducts: !!(response as any)?.products, : productContainer
productsKeys: (response as any)?.products ? Object.keys((response as any).products) : [], ? [productContainer]
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]
: []; : [];
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) { if (products.length === 0) {
this.logger.warn(`No products found for client ${clientId}`, { this.logger.warn(`No products found for client ${clientId}`, {
responseStructure: response ? Object.keys(response) : "null response", responseStructure: response ? Object.keys(response) : "null response",
@ -109,8 +109,9 @@ export class WhmcsSubscriptionService {
// Apply status filter if needed // Apply status filter if needed
if (filters.status) { if (filters.status) {
const statusFilter = filters.status.toLowerCase();
const filtered = result.subscriptions.filter( const filtered = result.subscriptions.filter(
(sub: Subscription) => sub.status.toLowerCase() === filters.status!.toLowerCase() (sub: Subscription) => sub.status.toLowerCase() === statusFilter
); );
return { return {
subscriptions: filtered, subscriptions: filtered,

View File

@ -118,7 +118,7 @@ export interface WhmcsInvoiceResponse extends WhmcsInvoice {
// Product/Service Types // Product/Service Types
export interface WhmcsProductsResponse { export interface WhmcsProductsResponse {
products: { products: {
product: WhmcsProduct[]; product: WhmcsProduct | WhmcsProduct[];
}; };
totalresults?: number; totalresults?: number;
numreturned?: number; numreturned?: number;
@ -383,14 +383,7 @@ export interface WhmcsCreateInvoiceResponse {
// UpdateInvoice API Types // UpdateInvoice API Types
export interface WhmcsUpdateInvoiceParams { export interface WhmcsUpdateInvoiceParams {
invoiceid: number; invoiceid: number;
status?: status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue";
| "Draft"
| "Paid"
| "Unpaid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Overdue";
duedate?: string; // YYYY-MM-DD format duedate?: string; // YYYY-MM-DD format
notes?: string; notes?: string;
[key: string]: unknown; [key: string]: unknown;

View File

@ -6,8 +6,9 @@ import type { Request } from "express";
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible"; import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import { createHash } from "crypto"; import { createHash } from "crypto";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { getErrorMessage } from "@bff/core/utils/error.util";
interface RateLimitOutcome { export interface RateLimitOutcome {
key: string; key: string;
remainingPoints: number; remainingPoints: number;
consumedPoints: number; consumedPoints: number;
@ -15,10 +16,6 @@ interface RateLimitOutcome {
needsCaptcha: boolean; needsCaptcha: boolean;
} }
interface EnsureResult extends RateLimitOutcome {
headerValue?: string;
}
@Injectable() @Injectable()
export class AuthRateLimitService { export class AuthRateLimitService {
private readonly loginLimiter: RateLimiterRedis; private readonly loginLimiter: RateLimiterRedis;
@ -44,8 +41,14 @@ export class AuthRateLimitService {
const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5); const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000); const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5); const passwordResetLimit = this.configService.get<number>(
const passwordResetTtlMs = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_TTL", 900000); "PASSWORD_RESET_RATE_LIMIT_LIMIT",
5
);
const passwordResetTtlMs = this.configService.get<number>(
"PASSWORD_RESET_RATE_LIMIT_TTL",
900000
);
const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10); const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10);
const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000); const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000);
@ -142,11 +145,14 @@ export class AuthRateLimitService {
keyPrefix: prefix, keyPrefix: prefix,
points: limit, points: limit,
duration, duration,
inmemoryBlockOnConsumed: limit + 1, inMemoryBlockOnConsumed: limit + 1,
insuranceLimiter: undefined, insuranceLimiter: undefined,
}); });
} catch (error) { } catch (error: unknown) {
this.logger.error(error, `Failed to initialize rate limiter: ${prefix}`); this.logger.error(
{ prefix, error: getErrorMessage(error) },
"Failed to initialize rate limiter"
);
throw new InternalServerErrorException("Rate limiter initialization failed"); throw new InternalServerErrorException("Rate limiter initialization failed");
} }
} }
@ -167,9 +173,9 @@ export class AuthRateLimitService {
msBeforeNext: res.msBeforeNext, msBeforeNext: res.msBeforeNext,
needsCaptcha: false, needsCaptcha: false,
}; };
} catch (error) { } catch (error: unknown) {
if (error instanceof RateLimiterRes) { if (error instanceof RateLimiterRes) {
const retryAfterMs = error.msBeforeNext || 0; const retryAfterMs = error?.msBeforeNext ?? 0;
const message = this.buildThrottleMessage(context, retryAfterMs); const message = this.buildThrottleMessage(context, retryAfterMs);
this.logger.warn({ key, context, retryAfterMs }, "Auth rate limit reached"); this.logger.warn({ key, context, retryAfterMs }, "Auth rate limit reached");
@ -177,7 +183,7 @@ export class AuthRateLimitService {
throw new ThrottlerException(message); 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"); throw new ThrottlerException("Authentication temporarily unavailable");
} }
} }
@ -189,8 +195,11 @@ export class AuthRateLimitService {
): Promise<void> { ): Promise<void> {
try { try {
await limiter.delete(key); await limiter.delete(key);
} catch (error) { } catch (error: unknown) {
this.logger.warn({ key, context, error }, "Failed to reset rate limiter key"); 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 minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
const timeMessage = remainingSeconds const timeMessage = remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
? `${minutes}m ${remainingSeconds}s`
: `${minutes}m`;
return `Too many ${context} attempts. Try again in ${timeMessage}.`; return `Too many ${context} attempts. Try again in ${timeMessage}.`;
} }
@ -211,4 +218,3 @@ export class AuthRateLimitService {
return createHash("sha256").update(token).digest("hex"); return createHash("sha256").update(token).digest("hex");
} }
} }

View File

@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { getErrorMessage } from "@bff/core/utils/error.util";
export interface MigrationStats { export interface MigrationStats {
totalKeysScanned: number; totalKeysScanned: number;
@ -14,6 +15,15 @@ export interface MigrationStats {
duration: number; duration: number;
} }
interface ParsedFamily {
userId: string;
}
interface ParsedToken {
userId: string;
familyId: string;
}
@Injectable() @Injectable()
export class TokenMigrationService { export class TokenMigrationService {
private readonly REFRESH_TOKEN_FAMILY_PREFIX = "refresh_family:"; private readonly REFRESH_TOKEN_FAMILY_PREFIX = "refresh_family:";
@ -119,54 +129,43 @@ export class TokenMigrationService {
stats.familiesFound++; stats.familiesFound++;
try { const family = this.parseFamilyData(familyKey, familyData, stats);
const family = JSON.parse(familyData); if (!family) {
return;
// 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 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++; stats.tokensFound++;
try { const token = this.parseTokenData(tokenKey, tokenData, stats);
const token = JSON.parse(tokenData); if (!token) {
return;
}
// Validate token structure // Check if the corresponding family exists
if (!token.familyId || !token.userId || typeof token.userId !== "string") { const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`;
this.logger.warn("Invalid token structure, skipping", { tokenKey }); const familyExists = await this.redis.exists(familyKey);
return;
}
// Check if the corresponding family exists if (!familyExists) {
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`; stats.orphanedTokens++;
const familyExists = await this.redis.exists(familyKey); this.logger.warn("Found orphaned token (no corresponding family)", {
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", {
tokenKey, tokenKey,
familyId: token.familyId, familyId: token.familyId,
userId: token.userId, userId: token.userId,
dryRun,
}); });
} catch (error) {
this.logger.error("Failed to parse token data", { if (!dryRun) {
tokenKey, // Remove orphaned token
error: error instanceof Error ? error.message : String(error), await this.redis.del(tokenKey);
}); this.logger.debug("Removed orphaned token", { tokenKey });
stats.errors++; }
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); const tokenData = await this.redis.get(key);
if (!tokenData) continue; if (!tokenData) continue;
const token = JSON.parse(tokenData); const token = this.parseTokenData(key, tokenData, stats);
if (!token.familyId) continue; if (!token) {
continue;
}
// Check if the corresponding family exists // Check if the corresponding family exists
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`; const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`;
@ -325,7 +315,7 @@ export class TokenMigrationService {
stats.errors++; stats.errors++;
this.logger.error("Failed to cleanup token", { this.logger.error("Failed to cleanup token", {
key, key,
error: error instanceof Error ? error.message : String(error), error: getErrorMessage(error),
}); });
} }
} }
@ -401,4 +391,71 @@ export class TokenMigrationService {
return stats; 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<string, unknown>;
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<string, unknown>;
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 };
}
} }

View File

@ -1,16 +1,21 @@
import { Injectable, ExecutionContext } from "@nestjs/common"; import { Injectable, ExecutionContext } from "@nestjs/common";
import type { Request } from "express"; 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() @Injectable()
export class FailedLoginThrottleGuard { export class FailedLoginThrottleGuard {
constructor(private readonly authRateLimitService: AuthRateLimitService) {} constructor(private readonly authRateLimitService: AuthRateLimitService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<RequestWithRateLimit>();
const outcome = await this.authRateLimitService.consumeLoginAttempt(request); const outcome = await this.authRateLimitService.consumeLoginAttempt(request);
(request as any).__authRateLimit = outcome; request.__authRateLimit = outcome;
return true; return true;
} }

View File

@ -13,8 +13,14 @@ import type { Request } from "express";
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service"; import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service";
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator"; import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator";
import { getErrorMessage } from "@bff/core/utils/error.util";
type RequestWithCookies = Request & { cookies?: Record<string, string | undefined> }; type RequestWithCookies = Request & { cookies?: Record<string, string | undefined> };
type RequestWithRoute = RequestWithCookies & {
method: string;
url: string;
route?: { path?: string };
};
const headerExtractor = ExtractJwt.fromAuthHeaderAsBearerToken(); const headerExtractor = ExtractJwt.fromAuthHeaderAsBearerToken();
const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => { const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => {
@ -38,11 +44,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
} }
override async canActivate(context: ExecutionContext): Promise<boolean> { override async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context const request = context.switchToHttp().getRequest<RequestWithRoute>();
.switchToHttp()
.getRequest<
RequestWithCookies & { method: string; url: string; route?: { path?: string } }
>();
const route = `${request.method} ${request.route?.path ?? request.url}`; const route = `${request.method} ${request.route?.path ?? request.url}`;
// Check if the route is marked as public // 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}`); this.logger.debug(`Authenticated access to: ${route}`);
return true; return true;
} catch (error) { } catch (error) {
this.logger.error(`Authentication error for route ${route}:`, error); this.logger.error(`Authentication error for route ${route}: ${getErrorMessage(error)}`);
throw error; throw error;
} }
} }

View File

@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { OrderPricebookService } from "./order-pricebook.service"; import { OrderPricebookService } from "./order-pricebook.service";
import type { PrismaService } from "@bff/infra/database/prisma.service"; import type { PrismaService } from "@bff/infra/database/prisma.service";
import { z } from "zod";
import { createOrderRequestSchema } from "@customer-portal/domain"; import { createOrderRequestSchema } from "@customer-portal/domain";
/** /**

View File

@ -10,6 +10,7 @@ import {
type CreateOrderRequest, type CreateOrderRequest,
type OrderBusinessValidation, type OrderBusinessValidation,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import type { WhmcsProduct } from "@bff/integrations/whmcs/types/whmcs-api.types";
import { OrderPricebookService } from "./order-pricebook.service"; import { OrderPricebookService } from "./order-pricebook.service";
/** /**
@ -107,11 +108,12 @@ export class OrderValidator {
async validatePaymentMethod(userId: string, whmcsClientId: number): Promise<void> { async validatePaymentMethod(userId: string, whmcsClientId: number): Promise<void> {
try { try {
const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId }); 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"); this.logger.warn({ userId }, "No WHMCS payment method on file");
throw new BadRequestException("A payment method is required before ordering"); throw new BadRequestException("A payment method is required before ordering");
} }
} catch (e) { } catch (e: unknown) {
const err = getErrorMessage(e); const err = getErrorMessage(e);
this.logger.error({ err }, "Payment method verification failed"); this.logger.error({ err }, "Payment method verification failed");
throw new BadRequestException("Unable to verify payment method. Please try again later."); 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<void> { async validateInternetDuplication(userId: string, whmcsClientId: number): Promise<void> {
try { try {
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
const existing = products?.products?.product || []; const productContainer = products.products?.product;
const hasInternet = existing.some(product => const existing = Array.isArray(productContainer)
(product.groupname || "").toLowerCase().includes("internet") ? productContainer
: productContainer
? [productContainer]
: [];
const hasInternet = existing.some((product: WhmcsProduct) =>
(product.groupname || product.translated_groupname || "").toLowerCase().includes("internet")
); );
if (hasInternet) { if (hasInternet) {
throw new BadRequestException("An Internet service already exists for this account"); throw new BadRequestException("An Internet service already exists for this account");
} }
} catch (e) { } catch (e: unknown) {
const err = getErrorMessage(e); const err = getErrorMessage(e);
this.logger.error({ err }, "Internet duplicate check failed"); this.logger.error({ err }, "Internet duplicate check failed");
throw new BadRequestException( throw new BadRequestException(

View File

@ -514,7 +514,12 @@ export class SubscriptionsService {
const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({ const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({
clientid: mapping.whmcsClientId, 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) => { return services.some((service: WhmcsProduct) => {
const group = typeof service.groupname === "string" ? service.groupname.toLowerCase() : ""; const group = typeof service.groupname === "string" ? service.groupname.toLowerCase() : "";

View File

@ -31,6 +31,6 @@
"module": "CommonJS" "module": "CommonJS"
} }
}, },
"include": ["src/**/*", "scripts/**/*"], "include": ["src/**/*", "scripts/**/*", "src/types/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@ -54,9 +54,7 @@
"update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check", "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", "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", "plesk:images": "bash ./scripts/plesk/build-images.sh",
"types:gen": "./scripts/generate-frontend-types.sh", "postinstall": "husky install || true"
"codegen": "pnpm types:gen",
"postinstall": "pnpm codegen || true"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",

35
pnpm-lock.yaml generated
View File

@ -83,9 +83,6 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.1.6 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) 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': '@nestjs/throttler':
specifier: ^6.4.0 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) 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: pino-pretty:
specifier: ^13.1.1 specifier: ^13.1.1
version: 13.1.1 version: 13.1.1
rate-limiter-flexible:
specifier: ^4.0.1
version: 4.0.1
reflect-metadata: reflect-metadata:
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2 version: 0.2.2
@ -285,9 +285,6 @@ importers:
next: next:
specifier: 15.5.0 specifier: 15.5.0
version: 15.5.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) 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: react:
specifier: 19.1.1 specifier: 19.1.1
version: 19.1.1 version: 19.1.1
@ -4238,12 +4235,6 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'} 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: opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true hasBin: true
@ -4491,6 +4482,9 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
rate-limiter-flexible@4.0.1:
resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==}
raw-body@3.0.1: raw-body@3.0.1:
resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -6285,7 +6279,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {} '@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': '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
optional: true optional: true
@ -6403,6 +6398,7 @@ snapshots:
optionalDependencies: optionalDependencies:
class-transformer: 0.5.1 class-transformer: 0.5.1
class-validator: 0.14.2 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)': '@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: dependencies:
@ -6457,6 +6453,7 @@ snapshots:
optionalDependencies: optionalDependencies:
class-transformer: 0.5.1 class-transformer: 0.5.1
class-validator: 0.14.2 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)': '@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: dependencies:
@ -6602,7 +6599,8 @@ snapshots:
'@rushstack/eslint-patch@1.12.0': {} '@rushstack/eslint-patch@1.12.0': {}
'@scarf/scarf@1.4.0': {} '@scarf/scarf@1.4.0':
optional: true
'@sendgrid/client@8.1.5': '@sendgrid/client@8.1.5':
dependencies: dependencies:
@ -9706,12 +9704,6 @@ snapshots:
is-docker: 2.2.1 is-docker: 2.2.1
is-wsl: 2.2.0 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: {} opener@1.5.2: {}
optionator@0.9.4: optionator@0.9.4:
@ -9983,6 +9975,8 @@ snapshots:
range-parser@1.2.1: {} range-parser@1.2.1: {}
rate-limiter-flexible@4.0.1: {}
raw-body@3.0.1: raw-body@3.0.1:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@ -10480,6 +10474,7 @@ snapshots:
swagger-ui-dist@5.21.0: swagger-ui-dist@5.21.0:
dependencies: dependencies:
'@scarf/scarf': 1.4.0 '@scarf/scarf': 1.4.0
optional: true
symbol-observable@4.0.0: {} symbol-observable@4.0.0: {}

View File

@ -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."