Refactor various services to improve code organization and error handling. Streamline provider declarations in Salesforce module, enhance WHMCS service with new client product retrieval method, and update pagination logic in AuthAdminController. Clean up unused imports and improve type handling across multiple modules, ensuring better maintainability and consistency.

This commit is contained in:
barsa 2025-09-25 13:32:16 +09:00
parent 47a3de6919
commit 640a4e1094
13 changed files with 154 additions and 85 deletions

View File

@ -4,11 +4,7 @@ import { SalesforceConnection } from "./services/salesforce-connection.service";
import { SalesforceAccountService } from "./services/salesforce-account.service"; import { SalesforceAccountService } from "./services/salesforce-account.service";
@Module({ @Module({
providers: [ providers: [SalesforceConnection, SalesforceAccountService, SalesforceService],
SalesforceConnection,
SalesforceAccountService,
SalesforceService,
],
exports: [SalesforceService, SalesforceConnection], exports: [SalesforceService, SalesforceConnection],
}) })
export class SalesforceModule {} export class SalesforceModule {}

View File

@ -23,6 +23,8 @@ import {
WhmcsAddClientParams, WhmcsAddClientParams,
WhmcsClientResponse, WhmcsClientResponse,
WhmcsCatalogProductsResponse, WhmcsCatalogProductsResponse,
WhmcsGetClientsProductsParams,
WhmcsProductsResponse,
} from "./types/whmcs-api.types"; } from "./types/whmcs-api.types";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
@ -337,6 +339,12 @@ export class WhmcsService {
return this.connectionService.getSystemInfo(); return this.connectionService.getSystemInfo();
} }
async getClientsProducts(
params: WhmcsGetClientsProductsParams
): Promise<WhmcsProductsResponse> {
return this.connectionService.getClientsProducts(params);
}
// ========================================== // ==========================================
// INVOICE CREATION AND PAYMENT OPERATIONS // INVOICE CREATION AND PAYMENT OPERATIONS
// ========================================== // ==========================================

View File

@ -33,8 +33,6 @@ export class AuthAdminController {
) { ) {
const pageNum = parseInt(page, 10); const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10); const limitNum = parseInt(limit, 10);
const skip = (pageNum - 1) * limitNum;
if (Number.isNaN(pageNum) || Number.isNaN(limitNum) || pageNum < 1 || limitNum < 1) { if (Number.isNaN(pageNum) || Number.isNaN(limitNum) || pageNum < 1 || limitNum < 1) {
throw new BadRequestException("Invalid pagination parameters"); throw new BadRequestException("Invalid pagination parameters");
} }

View File

@ -11,7 +11,6 @@ import { ZodValidationPipe } from "@bff/core/validation";
// Import Zod schemas from domain // Import Zod schemas from domain
import { import {
signupRequestSchema, signupRequestSchema,
loginRequestSchema,
passwordResetRequestSchema, passwordResetRequestSchema,
passwordResetSchema, passwordResetSchema,
setPasswordRequestSchema, setPasswordRequestSchema,
@ -22,7 +21,6 @@ import {
ssoLinkRequestSchema, ssoLinkRequestSchema,
checkPasswordNeededRequestSchema, checkPasswordNeededRequestSchema,
type SignupRequestInput, type SignupRequestInput,
type LoginRequestInput,
type PasswordResetRequestInput, type PasswordResetRequestInput,
type PasswordResetInput, type PasswordResetInput,
type SetPasswordRequestInput, type SetPasswordRequestInput,

View File

@ -1,10 +1,4 @@
import { import { Injectable, UnauthorizedException, BadRequestException, Inject } from "@nestjs/common";
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
Inject,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
@ -18,8 +12,6 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
import { import {
authResponseSchema,
type AuthResponse,
type SignupRequestInput, type SignupRequestInput,
type ValidateSignupRequestInput, type ValidateSignupRequestInput,
type LinkWhmcsRequestInput, type LinkWhmcsRequestInput,

View File

@ -188,13 +188,21 @@ export class AuthTokenService {
throw new UnauthorizedException("Invalid refresh token"); throw new UnauthorizedException("Invalid refresh token");
} }
const tokenData = JSON.parse(storedToken); const tokenRecord = this.parseRefreshTokenRecord(storedToken);
if (!tokenData.valid) { if (!tokenRecord) {
this.logger.warn("Stored refresh token payload was invalid JSON", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
throw new UnauthorizedException("Invalid refresh token");
}
if (!tokenRecord.valid) {
this.logger.warn("Refresh token marked as invalid", { this.logger.warn("Refresh token marked as invalid", {
tokenHash: refreshTokenHash.slice(0, 8), tokenHash: refreshTokenHash.slice(0, 8),
}); });
// Invalidate entire token family on reuse attempt // Invalidate entire token family on reuse attempt
await this.invalidateTokenFamily(tokenData.familyId); await this.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException("Invalid refresh token"); throw new UnauthorizedException("Invalid refresh token");
} }
@ -228,13 +236,13 @@ export class AuthTokenService {
if (this.redis.status !== "ready") { if (this.redis.status !== "ready") {
this.logger.warn("Redis unavailable during token refresh; issuing fallback token pair"); this.logger.warn("Redis unavailable during token refresh; issuing fallback token pair");
const fallbackPayload = this.jwtService.decode(refreshToken) as const fallbackDecoded = this.jwtService.decode(refreshToken);
| RefreshTokenPayload const fallbackUserId =
| null; fallbackDecoded && typeof fallbackDecoded === "object"
? (fallbackDecoded as { userId?: unknown }).userId
: undefined;
const fallbackUserId = fallbackPayload?.userId; if (typeof fallbackUserId === "string") {
if (fallbackUserId) {
const fallbackUser = await this.usersService const fallbackUser = await this.usersService
.findByIdInternal(fallbackUserId) .findByIdInternal(fallbackUserId)
.catch(() => null); .catch(() => null);
@ -265,9 +273,12 @@ export class AuthTokenService {
const storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); const storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
if (storedToken) { if (storedToken) {
const tokenData = JSON.parse(storedToken); const tokenRecord = this.parseRefreshTokenRecord(storedToken);
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${tokenData.familyId}`);
if (tokenRecord) {
await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${tokenRecord.familyId}`);
}
this.logger.debug("Revoked refresh token", { tokenHash: refreshTokenHash.slice(0, 8) }); this.logger.debug("Revoked refresh token", { tokenHash: refreshTokenHash.slice(0, 8) });
} }
@ -289,8 +300,8 @@ export class AuthTokenService {
for (const key of keys) { for (const key of keys) {
const data = await this.redis.get(key); const data = await this.redis.get(key);
if (data) { if (data) {
const family = JSON.parse(data); const family = this.parseRefreshTokenFamilyRecord(data);
if (family.userId === userId) { if (family && family.userId === userId) {
await this.redis.del(key); await this.redis.del(key);
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`);
} }
@ -309,15 +320,17 @@ export class AuthTokenService {
try { try {
const familyData = await this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); const familyData = await this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`);
if (familyData) { if (familyData) {
const family = JSON.parse(familyData); const family = this.parseRefreshTokenFamilyRecord(familyData);
await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`);
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`);
this.logger.warn("Invalidated token family due to security concern", { if (family) {
familyId: familyId.slice(0, 8), await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`);
userId: family.userId,
}); this.logger.warn("Invalidated token family due to security concern", {
familyId: familyId.slice(0, 8),
userId: family.userId,
});
}
} }
} catch (error) { } catch (error) {
this.logger.error("Failed to invalidate token family", { this.logger.error("Failed to invalidate token family", {
@ -334,6 +347,55 @@ export class AuthTokenService {
return createHash("sha256").update(token).digest("hex"); return createHash("sha256").update(token).digest("hex");
} }
private parseRefreshTokenRecord(value: string): StoredRefreshToken | null {
try {
const parsed = JSON.parse(value) as Partial<StoredRefreshToken>;
if (
parsed &&
typeof parsed === "object" &&
typeof parsed.familyId === "string" &&
typeof parsed.userId === "string" &&
typeof parsed.valid === "boolean"
) {
return {
familyId: parsed.familyId,
userId: parsed.userId,
valid: parsed.valid,
};
}
} catch (error) {
this.logger.warn("Failed to parse refresh token record", {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
private parseRefreshTokenFamilyRecord(value: string): StoredRefreshTokenFamily | null {
try {
const parsed = JSON.parse(value) as Partial<StoredRefreshTokenFamily>;
if (
parsed &&
typeof parsed === "object" &&
typeof parsed.userId === "string" &&
typeof parsed.tokenHash === "string"
) {
return {
userId: parsed.userId,
tokenHash: parsed.tokenHash,
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
userAgent: typeof parsed.userAgent === "string" ? parsed.userAgent : undefined,
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : undefined,
};
}
} catch (error) {
this.logger.warn("Failed to parse refresh token family record", {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
private parseExpiryToMs(expiry: string): number { private parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1); const unit = expiry.slice(-1);
const value = parseInt(expiry.slice(0, -1)); const value = parseInt(expiry.slice(0, -1));

View File

@ -2,7 +2,11 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; 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 { getSalesforceFieldMap } from "@bff/core/config/field-map"; import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { assertSalesforceId, sanitizeSoqlLiteral } from "@bff/integrations/salesforce/utils/soql.util"; import {
assertSalesforceId,
sanitizeSoqlLiteral,
} from "@bff/integrations/salesforce/utils/soql.util";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { import type {
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
SalesforceQueryResult, SalesforceQueryResult,
@ -31,8 +35,12 @@ export class BaseCatalogService {
try { try {
const res = (await this.sf.query(soql)) as SalesforceQueryResult<TRecord>; const res = (await this.sf.query(soql)) as SalesforceQueryResult<TRecord>;
return res.records ?? []; return res.records ?? [];
} catch (error) { } catch (error: unknown) {
this.logger.error({ error, soql, context }, `Query failed: ${context}`); this.logger.error(`Query failed: ${context}`, {
error: getErrorMessage(error),
soql,
context,
});
return []; return [];
} }
} }

View File

@ -50,7 +50,6 @@ export class SimCatalogService extends BaseCatalogService {
} }
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> { async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("SIM", "Activation", []); const soql = this.buildProductQuery("SIM", "Activation", []);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,

View File

@ -299,22 +299,24 @@ export class MappingsService {
async getMappingStats(): Promise<MappingStats> { async getMappingStats(): Promise<MappingStats> {
try { try {
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([ const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
this.prisma.idMapping.count(), this.prisma.idMapping.count(),
this.prisma.idMapping.count({ where: { whmcsClientId: { not: null } } }), this.prisma.idMapping.count({ where: { whmcsClientId: { not: null } } }),
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
this.prisma.idMapping.count({ where: { whmcsClientId: { not: null }, sfAccountId: { not: null } } }), this.prisma.idMapping.count({
]); where: { whmcsClientId: { not: null }, sfAccountId: { not: null } },
}),
]);
const orphanedMappings = whmcsCount - completeCount; const orphanedMappings = whmcsCount - completeCount;
const stats: MappingStats = { const stats: MappingStats = {
totalMappings: totalCount, totalMappings: totalCount,
whmcsMappings: whmcsCount, whmcsMappings: whmcsCount,
salesforceMappings: sfCount, salesforceMappings: sfCount,
completeMappings: completeCount, completeMappings: completeCount,
orphanedMappings: orphanedMappings < 0 ? 0 : orphanedMappings, orphanedMappings: orphanedMappings < 0 ? 0 : orphanedMappings,
}; };
this.logger.debug("Generated mapping statistics", stats); this.logger.debug("Generated mapping statistics", stats);
return stats; return stats;
} catch (error) { } catch (error) {

View File

@ -28,7 +28,7 @@ export interface OrderFulfillmentStep {
export interface OrderFulfillmentContext { export interface OrderFulfillmentContext {
sfOrderId: string; sfOrderId: string;
idempotencyKey: string; idempotencyKey: string;
validation: OrderFulfillmentValidationResult; validation: OrderFulfillmentValidationResult | null;
orderDetails?: OrderDetailsResponse; orderDetails?: OrderDetailsResponse;
mappingResult?: OrderItemMappingResult; mappingResult?: OrderItemMappingResult;
whmcsResult?: WhmcsOrderResult; whmcsResult?: WhmcsOrderResult;
@ -63,8 +63,10 @@ export class OrderFulfillmentOrchestrator {
const context: OrderFulfillmentContext = { const context: OrderFulfillmentContext = {
sfOrderId, sfOrderId,
idempotencyKey, idempotencyKey,
validation: {} as OrderFulfillmentValidationResult, validation: null,
steps: this.initializeSteps(payload.orderType as string), steps: this.initializeSteps(
typeof payload.orderType === "string" ? payload.orderType : "Unknown"
),
}; };
this.logger.log("Starting fulfillment orchestration", { this.logger.log("Starting fulfillment orchestration", {
@ -81,6 +83,10 @@ export class OrderFulfillmentOrchestrator {
); );
}); });
if (!context.validation) {
throw new Error("Fulfillment validation did not complete successfully");
}
// If already provisioned, return early // If already provisioned, return early
if (context.validation.isAlreadyProvisioned) { if (context.validation.isAlreadyProvisioned) {
this.markStepCompleted(context, "validation"); this.markStepCompleted(context, "validation");
@ -136,6 +142,10 @@ export class OrderFulfillmentOrchestrator {
// Step 5: Create order in WHMCS // Step 5: Create order in WHMCS
await this.executeStep(context, "whmcs_create", async () => { await this.executeStep(context, "whmcs_create", async () => {
if (!context.validation) {
throw new Error("Validation context is missing");
}
const orderNotes = this.orderWhmcsMapper.createOrderNotes( const orderNotes = this.orderWhmcsMapper.createOrderNotes(
sfOrderId, sfOrderId,
`Provisioned from Salesforce Order ${sfOrderId}` `Provisioned from Salesforce Order ${sfOrderId}`
@ -157,7 +167,7 @@ export class OrderFulfillmentOrchestrator {
context.whmcsResult = { context.whmcsResult = {
orderId: createResult.orderId, orderId: createResult.orderId,
serviceIds: [], serviceIds: [],
} as unknown as { orderId: number; serviceIds: number[] }; };
}); });
// Step 6: Accept/provision order in WHMCS // Step 6: Accept/provision order in WHMCS
@ -373,7 +383,7 @@ export class OrderFulfillmentOrchestrator {
const isSuccess = context.steps.every((s: OrderFulfillmentStep) => s.status === "completed"); const isSuccess = context.steps.every((s: OrderFulfillmentStep) => s.status === "completed");
const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed"); const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed");
if (context.validation.isAlreadyProvisioned) { if (context.validation?.isAlreadyProvisioned) {
return { return {
success: true, success: true,
status: "Already Fulfilled", status: "Already Fulfilled",

View File

@ -20,6 +20,7 @@ import {
getOrderItemProduct2Select, getOrderItemProduct2Select,
} from "@bff/core/config/field-map"; } from "@bff/core/config/field-map";
import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/utils/soql.util"; import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/utils/soql.util";
import { getErrorMessage } from "@bff/core/utils/error.util";
const fieldMap = getSalesforceFieldMap(); const fieldMap = getSalesforceFieldMap();
@ -262,8 +263,11 @@ export class OrderOrchestrator {
}, },
})), })),
}); });
} catch (error) { } catch (error: unknown) {
this.logger.error({ error, orderId }, "Failed to fetch order with items"); this.logger.error("Failed to fetch order with items", {
error: getErrorMessage(error),
orderId,
});
throw error; throw error;
} }
} }

View File

@ -1,11 +1,6 @@
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain";
Subscription,
SubscriptionList,
InvoiceList,
invoiceListSchema,
} from "@customer-portal/domain";
import type { Invoice, InvoiceItem } from "@customer-portal/domain"; import type { Invoice, InvoiceItem } from "@customer-portal/domain";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
@ -15,6 +10,7 @@ import {
subscriptionSchema, subscriptionSchema,
type SubscriptionSchema, type SubscriptionSchema,
} from "@customer-portal/domain/validation/shared/entities"; } from "@customer-portal/domain/validation/shared/entities";
import type { WhmcsProduct, WhmcsProductsResponse } from "@bff/integrations/whmcs/types/whmcs-api.types";
export interface GetSubscriptionsOptions { export interface GetSubscriptionsOptions {
status?: string; status?: string;
@ -515,20 +511,20 @@ export class SubscriptionsService {
return false; return false;
} }
const products = await this.whmcsService.getClientsProducts({ const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({
clientid: mapping.whmcsClientId, clientid: mapping.whmcsClientId,
}); });
const services = products?.products?.product ?? []; const services = productsResponse.products?.product ?? [];
return services.some( return services.some((service: WhmcsProduct) => {
service => const group = typeof service.groupname === "string" ? service.groupname.toLowerCase() : "";
typeof service.groupname === "string" && const status = typeof service.status === "string" ? service.status.toLowerCase() : "";
service.groupname.toLowerCase().includes("sim") && return group.includes("sim") && status === "active";
typeof service.status === "string" && });
service.status.toLowerCase() === "active" } catch (error: unknown) {
); this.logger.warn(`Failed to check existing SIM for user ${userId}`, {
} catch (error) { error: getErrorMessage(error),
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error); });
return false; return false;
} }
} }

View File

@ -1,10 +1,6 @@
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@bff/core/utils/validation.util"; import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@bff/core/utils/validation.util";
import { import type { UpdateAddressRequest } from "@customer-portal/domain";
mapPrismaUserToSharedUser,
mapPrismaUserToEnhancedBase,
} from "@bff/infra/utils/user-mapper.util";
import type { UpdateAddressRequest, SalesforceContactRecord } from "@customer-portal/domain";
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common"; import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service"; import { PrismaService } from "@bff/infra/database/prisma.service";