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

View File

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

View File

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

View File

@ -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<string, unknown>
): WhmcsApiResponse<T> {
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<T>;
result,
message: typeof message === "string" ? message : undefined,
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
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,

View File

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

View File

@ -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<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
const passwordResetTtlMs = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_TTL", 900000);
const passwordResetLimit = this.configService.get<number>(
"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 refreshTtlMs = this.configService.get<number>("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<void> {
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");
}
}

View File

@ -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<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 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<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const request = context.switchToHttp().getRequest<RequestWithRateLimit>();
const outcome = await this.authRateLimitService.consumeLoginAttempt(request);
(request as any).__authRateLimit = outcome;
request.__authRateLimit = outcome;
return true;
}

View File

@ -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<string, string | undefined> };
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<boolean> {
const request = context
.switchToHttp()
.getRequest<
RequestWithCookies & { method: string; url: string; route?: { path?: string } }
>();
const request = context.switchToHttp().getRequest<RequestWithRoute>();
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;
}
}

View File

@ -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";
/**

View File

@ -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<void> {
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<void> {
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(

View File

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

View File

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

View File

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

35
pnpm-lock.yaml generated
View File

@ -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: {}

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