From 47a3de6919f52d3389e09219cddea5a426fb198e Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 25 Sep 2025 13:21:11 +0900 Subject: [PATCH] Refactor error handling in various services to improve consistency and maintainability. Update user role management across authentication and user services, ensuring proper type definitions. Enhance validation schemas and streamline import/export practices in the domain and validation modules. Remove deprecated files and clean up unused code in the Salesforce integration and catalog services. --- apps/bff/src/core/http/auth-error.filter.ts | 38 ++- .../src/core/http/http-exception.filter.ts | 10 +- apps/bff/src/core/utils/validation.util.ts | 4 +- apps/bff/src/core/validation/index.ts | 2 +- apps/bff/src/infra/utils/user-mapper.util.ts | 6 +- .../integrations/freebit/freebit.service.ts | 241 ++++++++------- .../freebit/interfaces/freebit.types.ts | 65 ++--- .../salesforce/salesforce.module.ts | 2 - .../salesforce/salesforce.service.ts | 30 +- .../services/salesforce-case.service.ts | 274 ------------------ .../utils/__tests__/soql.util.spec.ts | 39 --- .../salesforce/utils/soql.util.ts | 22 -- .../services/whmcs-connection.service.ts | 18 +- .../transformers/whmcs-data.transformer.ts | 4 +- apps/bff/src/main.ts | 40 +-- .../src/modules/auth/auth-zod.controller.ts | 4 +- apps/bff/src/modules/auth/auth.module.ts | 3 +- apps/bff/src/modules/auth/auth.service.ts | 10 +- apps/bff/src/modules/auth/auth.types.ts | 4 +- .../auth/guards/auth-throttle.guard.ts | 7 +- .../auth/services/token-blacklist.service.ts | 15 +- .../modules/auth/services/token.service.ts | 119 +++++--- .../modules/auth/strategies/jwt.strategy.ts | 41 ++- .../modules/auth/strategies/local.strategy.ts | 2 +- .../catalog/services/base-catalog.service.ts | 11 +- .../services/internet-catalog.service.ts | 4 +- .../utils/salesforce-product.mapper.ts | 90 ++++-- .../modules/id-mappings/mappings.service.ts | 28 +- .../services/order-item-builder.service.ts | 10 +- .../services/order-orchestrator.service.ts | 18 +- .../services/order-validator.service.ts | 25 +- apps/bff/src/modules/users/users.service.ts | 21 +- .../auth/components/SessionTimeoutWarning.tsx | 78 ++++- packages/domain/src/contracts/catalog.ts | 9 +- packages/domain/src/contracts/salesforce.ts | 29 +- packages/domain/src/entities/index.ts | 1 + packages/domain/src/entities/invoice.ts | 10 +- packages/domain/src/entities/subscription.ts | 8 + packages/domain/src/entities/user.ts | 6 + .../domain/src/validation/api/responses.ts | 11 - packages/domain/src/validation/forms/auth.ts | 40 +-- .../domain/src/validation/forms/profile.ts | 3 + packages/domain/src/validation/index.ts | 13 +- .../domain/src/validation/shared/entities.ts | 1 + .../domain/src/validation/shared/index.ts | 25 +- .../domain/src/validation/shared/order.ts | 5 + 46 files changed, 688 insertions(+), 758 deletions(-) delete mode 100644 apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts delete mode 100644 apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts delete mode 100644 apps/bff/src/integrations/salesforce/utils/soql.util.ts create mode 100644 packages/domain/src/entities/subscription.ts diff --git a/apps/bff/src/core/http/auth-error.filter.ts b/apps/bff/src/core/http/auth-error.filter.ts index 2eda5944..c0dc51ae 100644 --- a/apps/bff/src/core/http/auth-error.filter.ts +++ b/apps/bff/src/core/http/auth-error.filter.ts @@ -8,7 +8,7 @@ import { ConflictException, HttpStatus, } from "@nestjs/common"; -import { Response } from "express"; +import type { Request, Response } from "express"; import { Logger } from "nestjs-pino"; interface StandardErrorResponse { @@ -32,21 +32,41 @@ export class AuthErrorFilter implements ExceptionFilter { ) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - const request = ctx.getRequest(); + const request = ctx.getRequest(); - const status = exception.getStatus(); - const message = exception.message; + const status = exception.getStatus() as HttpStatus; + const responsePayload = exception.getResponse(); + const payloadMessage = + typeof responsePayload === "string" + ? responsePayload + : Array.isArray((responsePayload as { message?: unknown })?.message) + ? (responsePayload as { message: unknown[] }).message.find( + (value): value is string => typeof value === "string" + ) + : (responsePayload as { message?: unknown })?.message; + const exceptionMessage = typeof exception.message === "string" ? exception.message : undefined; + const messageText = + payloadMessage && typeof payloadMessage === "string" + ? payloadMessage + : (exceptionMessage ?? "Authentication error"); // Map specific auth errors to user-friendly messages - const userMessage = this.getUserFriendlyMessage(message, status); - const errorCode = this.getErrorCode(message, status); + const userMessage = this.getUserFriendlyMessage(messageText, status); + const errorCode = this.getErrorCode(messageText, status); // Log the error (without sensitive information) + const userAgentHeader = request.headers["user-agent"]; + const userAgent = + typeof userAgentHeader === "string" + ? userAgentHeader + : Array.isArray(userAgentHeader) + ? userAgentHeader[0] + : undefined; this.logger.warn("Authentication error", { path: request.url, method: request.method, errorCode, - userAgent: request.headers["user-agent"], + userAgent, ip: request.ip, }); @@ -63,7 +83,7 @@ export class AuthErrorFilter implements ExceptionFilter { response.status(status).json(errorResponse); } - private getUserFriendlyMessage(message: string, status: number): string { + private getUserFriendlyMessage(message: string, status: HttpStatus): string { // Production-safe error messages that don't expose sensitive information if (status === HttpStatus.UNAUTHORIZED) { if ( @@ -117,7 +137,7 @@ export class AuthErrorFilter implements ExceptionFilter { return "Authentication error. Please try again."; } - private getErrorCode(message: string, status: number): string { + private getErrorCode(message: string, status: HttpStatus): string { if (status === HttpStatus.UNAUTHORIZED) { if (message.includes("Invalid credentials") || message.includes("Invalid email or password")) return "INVALID_CREDENTIALS"; diff --git a/apps/bff/src/core/http/http-exception.filter.ts b/apps/bff/src/core/http/http-exception.filter.ts index 6f981060..e2719900 100644 --- a/apps/bff/src/core/http/http-exception.filter.ts +++ b/apps/bff/src/core/http/http-exception.filter.ts @@ -9,10 +9,14 @@ import { import { Request, Response } from "express"; import { getClientSafeErrorMessage } from "../utils/error.util"; import { Logger } from "nestjs-pino"; +import { ConfigService } from "@nestjs/config"; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { - constructor(@Inject(Logger) private readonly logger: Logger) {} + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly configService: ConfigService + ) {} catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); @@ -51,7 +55,9 @@ export class GlobalExceptionFilter implements ExceptionFilter { } const clientSafeMessage = - process.env.NODE_ENV === "production" ? getClientSafeErrorMessage(message) : message; + this.configService.get("NODE_ENV") === "production" + ? getClientSafeErrorMessage(message) + : message; const code = (error || "InternalServerError") .replace(/([a-z])([A-Z])/g, "$1_$2") diff --git a/apps/bff/src/core/utils/validation.util.ts b/apps/bff/src/core/utils/validation.util.ts index 45c6b138..4bf31ba5 100644 --- a/apps/bff/src/core/utils/validation.util.ts +++ b/apps/bff/src/core/utils/validation.util.ts @@ -11,7 +11,7 @@ export const uuidSchema = z.string().uuid(); export function normalizeAndValidateEmail(email: string): string { try { return emailSchema.parse(email); - } catch (error) { + } catch { throw new BadRequestException("Invalid email format"); } } @@ -19,7 +19,7 @@ export function normalizeAndValidateEmail(email: string): string { export function validateUuidV4OrThrow(id: string): string { try { return uuidSchema.parse(id); - } catch (error) { + } catch { throw new Error("Invalid user ID format"); } } diff --git a/apps/bff/src/core/validation/index.ts b/apps/bff/src/core/validation/index.ts index f11a7756..27a3bad7 100644 --- a/apps/bff/src/core/validation/index.ts +++ b/apps/bff/src/core/validation/index.ts @@ -19,7 +19,7 @@ export function ZodPipe(schema: ZodSchema) { export function ZodPipeClass(schema: ZodSchema) { @Injectable() class ZodPipeClass implements PipeTransform { - transform(value: any, metadata: ArgumentMetadata) { + transform(value: unknown, _metadata: ArgumentMetadata) { const result = schema.safeParse(value); if (!result.success) { throw new BadRequestException({ diff --git a/apps/bff/src/infra/utils/user-mapper.util.ts b/apps/bff/src/infra/utils/user-mapper.util.ts index cb2fec64..82f1ff5f 100644 --- a/apps/bff/src/infra/utils/user-mapper.util.ts +++ b/apps/bff/src/infra/utils/user-mapper.util.ts @@ -1,4 +1,4 @@ -import type { User, UserProfile } from "@customer-portal/domain"; +import type { AuthenticatedUser, User } from "@customer-portal/domain"; import type { User as PrismaUser } from "@prisma/client"; export function mapPrismaUserToSharedUser(user: PrismaUser): User { @@ -42,13 +42,15 @@ export function mapPrismaUserToEnhancedBase(user: PrismaUser): { }; } -export function mapPrismaUserToUserProfile(user: PrismaUser): UserProfile { +export function mapPrismaUserToUserProfile(user: PrismaUser): AuthenticatedUser { const shared = mapPrismaUserToSharedUser(user); + const normalizedRole = user.role?.toLowerCase() === "admin" ? "admin" : "user"; return { ...shared, avatar: undefined, preferences: {}, lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : undefined, + role: normalizedRole, }; } diff --git a/apps/bff/src/integrations/freebit/freebit.service.ts b/apps/bff/src/integrations/freebit/freebit.service.ts index 998ac3e2..80718118 100644 --- a/apps/bff/src/integrations/freebit/freebit.service.ts +++ b/apps/bff/src/integrations/freebit/freebit.service.ts @@ -6,6 +6,7 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; +import { getErrorMessage } from "@bff/core/utils/error.util"; import type { FreebititConfig, FreebititAuthRequest, @@ -35,6 +36,14 @@ import type { SimTopUpHistory, } from "./interfaces/freebit.types"; +interface FreebitResponseBase { + resultCode?: string | number; + status?: { + message?: string; + statusCode?: string | number; + }; +} + @Injectable() export class FreebititService { private readonly config: FreebititConfig; @@ -120,18 +129,19 @@ export class FreebititService { this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 }; this.logger.log("Successfully authenticated with Freebit API"); return data.authKey; - } catch (error: any) { - this.logger.error("Failed to authenticate with Freebit API", { error: error.message }); + } catch (error: unknown) { + const message = getErrorMessage(error); + this.logger.error("Failed to authenticate with Freebit API", { error: message }); throw new InternalServerErrorException("Failed to authenticate with Freebit API"); } } - private async makeAuthenticatedRequest( - endpoint: string, - data: Record - ): Promise { + private async makeAuthenticatedRequest< + TResponse extends FreebitResponseBase, + TPayload extends Record, + >(endpoint: string, payload: TPayload): Promise { const authKey = await this.getAuthKey(); - const requestData = { ...data, authKey }; + const requestData: Record = { ...payload, authKey }; try { const url = `${this.config.baseUrl}${endpoint}`; @@ -146,7 +156,9 @@ export class FreebititService { try { const text = await response.text(); bodySnippet = text ? text.slice(0, 500) : undefined; - } catch {} + } catch { + // ignore body parse errors when logging + } this.logger.error("Freebit API non-OK response", { endpoint, url, @@ -157,35 +169,32 @@ export class FreebititService { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const responseData = (await response.json()) as any; - if (responseData && responseData.resultCode && responseData.resultCode !== "100") { + const responseData = (await response.json()) as TResponse; + if (responseData.resultCode && responseData.resultCode !== "100") { throw new FreebititErrorImpl( - `API Error: ${responseData.status?.message || "Unknown error"}`, + `API Error: ${responseData.status?.message ?? "Unknown error"}`, responseData.resultCode, responseData.status?.statusCode, - responseData.status?.message + responseData.status?.message ?? "Unknown error" ); } this.logger.debug("Freebit API Request Success", { endpoint }); - return responseData as T; - } catch (error) { + return responseData; + } catch (error: unknown) { if (error instanceof FreebititErrorImpl) { throw error; } - this.logger.error(`Freebit API request failed: ${endpoint}`, { - error: (error as any).message, - }); - throw new InternalServerErrorException( - `Freebit API request failed: ${(error as any).message}` - ); + const message = getErrorMessage(error); + this.logger.error(`Freebit API request failed: ${endpoint}`, { error: message }); + throw new InternalServerErrorException(`Freebit API request failed: ${message}`); } } - private async makeAuthenticatedJsonRequest( - endpoint: string, - payload: Record - ): Promise { + private async makeAuthenticatedJsonRequest< + TResponse extends FreebitResponseBase, + TPayload extends Record, + >(endpoint: string, payload: TPayload): Promise { const url = `${this.config.baseUrl}${endpoint}`; try { const response = await fetch(url, { @@ -196,24 +205,23 @@ export class FreebititService { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const responseData = (await response.json()) as any; - if (responseData && responseData.resultCode && responseData.resultCode !== "100") { + const responseData = (await response.json()) as TResponse; + if (responseData.resultCode && responseData.resultCode !== "100") { throw new FreebititErrorImpl( - `API Error: ${responseData.status?.message || "Unknown error"}`, + `API Error: ${responseData.status?.message ?? "Unknown error"}`, responseData.resultCode, responseData.status?.statusCode, - responseData.status?.message + responseData.status?.message ?? "Unknown error" ); } this.logger.debug("Freebit JSON API Request Success", { endpoint }); - return responseData as T; - } catch (error) { + return responseData; + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Freebit JSON API request failed: ${endpoint}`, { - error: (error as any).message, + error: message, }); - throw new InternalServerErrorException( - `Freebit JSON API request failed: ${(error as any).message}` - ); + throw new InternalServerErrorException(`Freebit JSON API request failed: ${message}`); } } @@ -253,56 +261,65 @@ export class FreebititService { if (ep !== candidates[0]) { this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); } - response = await this.makeAuthenticatedRequest( - ep, - request as any - ); + response = await this.makeAuthenticatedRequest< + FreebititAccountDetailsResponse, + typeof request + >(ep, request); break; - } catch (err: any) { + } catch (err: unknown) { lastError = err; - if (typeof err?.message === "string" && err.message.includes("HTTP 404")) { + if (getErrorMessage(err).includes("HTTP 404")) { continue; // try next } } } if (!response) { - throw lastError || new Error("Failed to fetch account details"); + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("Failed to fetch account details"); } - const resp = response.responseDatas as any; - const simData = Array.isArray(resp) - ? resp.find(d => String(d.kind).toUpperCase() === "MVNO") || resp[0] - : resp; + const responseDatas = Array.isArray(response.responseDatas) + ? response.responseDatas + : [response.responseDatas]; + const simData = + responseDatas.find(detail => detail.kind.toUpperCase() === "MVNO") ?? responseDatas[0]; - const size = String(simData.size || "").toLowerCase(); + const size = String(simData.size ?? "").toLowerCase(); const isEsim = size === "esim" || !!simData.eid; - const planCode = String(simData.planCode || ""); - const status = this.mapSimStatus(String(simData.state || "")); + const planCode = String(simData.planCode ?? ""); + const status = this.mapSimStatus(String(simData.state ?? "")); - const remainingKb = Number(simData.quota) || 0; + const remainingKb = Number(simData.quota ?? 0); const details: SimDetails = { - account: String(simData.account || account), - msisdn: String(simData.account || account), + account: String(simData.account ?? account), + msisdn: String(simData.account ?? account), iccid: simData.iccid ? String(simData.iccid) : undefined, imsi: simData.imsi ? String(simData.imsi) : undefined, eid: simData.eid ? String(simData.eid) : undefined, planCode, status, simType: isEsim ? "esim" : "physical", - size: (size as any) || (isEsim ? "esim" : "nano"), - hasVoice: Number(simData.talk) === 10, - hasSms: Number(simData.sms) === 10, + size: size || (isEsim ? "esim" : "nano"), + hasVoice: Number(simData.talk ?? 0) === 10, + hasSms: Number(simData.sms ?? 0) === 10, remainingQuotaKb: remainingKb, remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100, startDate: simData.startDate ? String(simData.startDate) : undefined, ipv4: simData.ipv4, ipv6: simData.ipv6, - voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail) === 10, - callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting) === 10, - internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing) === 10, - networkType: simData.contractLine || undefined, + voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail ?? 0) === 10, + callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting ?? 0) === 10, + internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing ?? 0) === 10, + networkType: simData.contractLine ?? undefined, pendingOperations: simData.async - ? [{ operation: String(simData.async.func), scheduledDate: String(simData.async.date) }] + ? [ + { + operation: String(simData.async.func), + scheduledDate: String(simData.async.date), + }, + ] : undefined, }; @@ -313,21 +330,22 @@ export class FreebititService { }); return details; - } catch (error: any) { + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Failed to get SIM details for account ${account}`, { - error: error.message, + error: message, }); - throw error; + throw error as Error; } } async getSimUsage(account: string): Promise { try { const request: Omit = { account }; - const response = await this.makeAuthenticatedRequest( - "/mvno/getTrafficInfo/", - request as any - ); + const response = await this.makeAuthenticatedRequest< + FreebititTrafficInfoResponse, + typeof request + >("/mvno/getTrafficInfo/", request); const todayUsageKb = parseInt(response.traffic.today, 10) || 0; const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ @@ -374,11 +392,12 @@ export class FreebititService { const scheduled = !!options.scheduledAt; const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/"; - if (scheduled) { - (request as any).runTime = options.scheduledAt; - } + type TopUpPayload = typeof request & { runTime?: string }; + const payload: TopUpPayload = scheduled + ? { ...request, runTime: options.scheduledAt } + : request; - await this.makeAuthenticatedRequest(endpoint, request as any); + await this.makeAuthenticatedRequest(endpoint, payload); this.logger.log(`Successfully topped up SIM ${account}`, { account, endpoint, @@ -386,13 +405,14 @@ export class FreebititService { quotaKb, scheduled, }); - } catch (error: any) { + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Failed to top up SIM ${account}`, { - error: error.message, + error: message, account, quotaMb, }); - throw error; + throw error as Error; } } @@ -403,10 +423,10 @@ export class FreebititService { ): Promise { try { const request: Omit = { account, fromDate, toDate }; - const response = await this.makeAuthenticatedRequest( - "/mvno/getQuotaHistory/", - request as any - ); + const response = await this.makeAuthenticatedRequest< + FreebititQuotaHistoryResponse, + typeof request + >("/mvno/getQuotaHistory/", request); const history: SimTopUpHistory = { account, @@ -428,9 +448,10 @@ export class FreebititService { }); return history; - } catch (error: any) { + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Failed to get SIM top-up history for account ${account}`, { - error: error.message, + error: message, }); throw error; } @@ -449,10 +470,10 @@ export class FreebititService { runTime: options.scheduledAt, }; - const response = await this.makeAuthenticatedRequest( - "/mvno/changePlan/", - request as any - ); + const response = await this.makeAuthenticatedRequest< + FreebititPlanChangeResponse, + typeof request + >("/mvno/changePlan/", request); this.logger.log(`Successfully changed SIM plan for account ${account}`, { account, @@ -462,9 +483,10 @@ export class FreebititService { }); return { ipv4: response.ipv4, ipv6: response.ipv6 }; - } catch (error: any) { + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Failed to change SIM plan for account ${account}`, { - error: error.message, + error: message, account, newPlanCode, }); @@ -500,9 +522,9 @@ export class FreebititService { request.contractLine = features.networkType; } - await this.makeAuthenticatedRequest( + await this.makeAuthenticatedRequest( "/master/addSpec/", - request as any + request ); this.logger.log(`Updated SIM features for account ${account}`, { account, @@ -512,7 +534,7 @@ export class FreebititService { networkType: features.networkType, }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); this.logger.error(`Failed to update SIM features for account ${account}`, { error: message, account, @@ -527,17 +549,18 @@ export class FreebititService { account, runTime: scheduledAt, }; - await this.makeAuthenticatedRequest( + await this.makeAuthenticatedRequest( "/mvno/releasePlan/", - request as any + request ); this.logger.log(`Successfully cancelled SIM for account ${account}`, { account, runTime: scheduledAt, }); - } catch (error: any) { + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Failed to cancel SIM for account ${account}`, { - error: error.message, + error: message, account, }); throw error as Error; @@ -547,14 +570,15 @@ export class FreebititService { async reissueEsimProfile(account: string): Promise { try { const request: Omit = { account }; - await this.makeAuthenticatedRequest( + await this.makeAuthenticatedRequest( "/esim/reissueProfile/", - request as any + request ); this.logger.log(`Successfully requested eSIM reissue for account ${account}`); - } catch (error: any) { + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Failed to reissue eSIM profile for account ${account}`, { - error: error.message, + error: message, account, }); throw error as Error; @@ -576,9 +600,9 @@ export class FreebititService { planCode: options.planCode, }; - await this.makeAuthenticatedRequest( + await this.makeAuthenticatedRequest( "/mvno/esim/addAcnt/", - request as any + request ); this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, { @@ -587,9 +611,10 @@ export class FreebititService { oldProductNumber: options.oldProductNumber, oldEid: options.oldEid, }); - } catch (error: any) { + } catch (error: unknown) { + const message = getErrorMessage(error); this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { - error: error.message, + error: message, account, newEid, }); @@ -640,13 +665,13 @@ export class FreebititService { contractLine, shipDate, ...(mnp ? { mnp } : {}), - ...(identity ? identity : {}), - } as FreebititEsimAccountActivationRequest; + ...(identity ?? {}), + }; - await this.makeAuthenticatedJsonRequest( - "/mvno/esim/addAcct/", - payload as unknown as Record - ); + await this.makeAuthenticatedJsonRequest< + FreebititEsimAccountActivationResponse, + FreebititEsimAccountActivationRequest + >("/mvno/esim/addAcct/", payload); this.logger.log("Activated new eSIM account via PA05-41", { account, @@ -661,8 +686,8 @@ export class FreebititService { try { await this.getAuthKey(); return true; - } catch (error: any) { - this.logger.error("Freebit API health check failed", { error: error.message }); + } catch (error: unknown) { + this.logger.error("Freebit API health check failed", { error: getErrorMessage(error) }); return false; } } diff --git a/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts index 541ec245..bddbf1cc 100644 --- a/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts @@ -18,11 +18,32 @@ export interface FreebititAccountDetailsRequest { authKey: string; version?: string | number; // Docs recommend "2" requestDatas: Array<{ - kind: "MASTER" | "MVNO" | string; + kind: "MASTER" | "MVNO"; account?: string | number; }>; } +export interface FreebititAccountDetail { + kind: "MASTER" | "MVNO"; + account: string | number; + state: "active" | "suspended" | "temporary" | "waiting" | "obsolete"; + startDate?: string | number; + relationCode?: string; + resultCode?: string | number; + planCode?: string; + iccid?: string | number; + imsi?: string | number; + eid?: string; + contractLine?: string; + size?: "standard" | "nano" | "micro" | "esim"; + sms?: number; // 10=active, 20=inactive + talk?: number; // 10=active, 20=inactive + ipv4?: string; + ipv6?: string; + quota?: number; // Remaining quota + async?: { func: string; date: string | number }; +} + export interface FreebititAccountDetailsResponse { resultCode: string; status: { @@ -30,47 +51,7 @@ export interface FreebititAccountDetailsResponse { statusCode: string | number; }; masterAccount?: string; - responseDatas: - | { - kind: "MASTER" | "MVNO" | string; - account: string | number; - state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string; - startDate?: string | number; - relationCode?: string; - resultCode?: string | number; - planCode?: string; - iccid?: string | number; - imsi?: string | number; - eid?: string; - contractLine?: string; - size?: "standard" | "nano" | "micro" | "esim" | string; - sms?: number; // 10=active, 20=inactive - talk?: number; // 10=active, 20=inactive - ipv4?: string; - ipv6?: string; - quota?: number; // Remaining quota - async?: { func: string; date: string | number }; - } - | Array<{ - kind: "MASTER" | "MVNO" | string; - account: string | number; - state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string; - startDate?: string | number; - relationCode?: string; - resultCode?: string | number; - planCode?: string; - iccid?: string | number; - imsi?: string | number; - eid?: string; - contractLine?: string; - size?: "standard" | "nano" | "micro" | "esim" | string; - sms?: number; - talk?: number; - ipv4?: string; - ipv6?: string; - quota?: number; - async?: { func: string; date: string | number }; - }>; + responseDatas: FreebititAccountDetail | FreebititAccountDetail[]; } export interface FreebititTrafficInfoRequest { diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index 37b77232..2e6fb1a8 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -2,13 +2,11 @@ import { Module } from "@nestjs/common"; import { SalesforceService } from "./salesforce.service"; import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceAccountService } from "./services/salesforce-account.service"; -import { SalesforceCaseService } from "./services/salesforce-case.service"; @Module({ providers: [ SalesforceConnection, SalesforceAccountService, - SalesforceCaseService, SalesforceService, ], exports: [SalesforceService, SalesforceConnection], diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index 00f4dc67..0be69dc9 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -9,12 +9,6 @@ import { type AccountData, type UpsertResult, } from "./services/salesforce-account.service"; -import { - SalesforceCaseService, - CaseQueryParams, - CreateCaseUserData, -} from "./services/salesforce-case.service"; -import { SupportCase, CreateCaseRequest } from "@customer-portal/domain"; import type { SalesforceAccountRecord } from "@customer-portal/domain"; /** @@ -24,8 +18,7 @@ import type { SalesforceAccountRecord } from "@customer-portal/domain"; * - findAccountByCustomerNumber() - auth service (WHMCS linking) * - upsertAccount() - auth service (signup) * - getAccount() - users service (profile enhancement) - * - getCases() - future support functionality - * - createCase() - future support functionality + * Support-case functionality has been deferred and is intentionally absent. */ @Injectable() export class SalesforceService implements OnModuleInit { @@ -33,7 +26,6 @@ export class SalesforceService implements OnModuleInit { private configService: ConfigService, private connection: SalesforceConnection, private accountService: SalesforceAccountService, - private caseService: SalesforceCaseService, @Inject(Logger) private readonly logger: Logger ) {} @@ -86,26 +78,6 @@ export class SalesforceService implements OnModuleInit { return this.accountService.update(accountId, updates); } - // === CASE METHODS (For Future Support Functionality) === - - async getCases( - accountId: string, - params: CaseQueryParams = {} - ): Promise<{ cases: SupportCase[]; totalSize: number }> { - return this.caseService.getCases(accountId, params); - } - - async createCase( - userData: CreateCaseUserData, - caseRequest: CreateCaseRequest - ): Promise { - return this.caseService.createCase(userData, caseRequest); - } - - async updateCase(caseId: string, updates: Record): Promise { - return this.caseService.updateCase(caseId, updates); - } - // === ORDER METHODS (For Order Provisioning) === async updateOrder(orderData: { Id: string; [key: string]: unknown }): Promise { diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts deleted file mode 100644 index 5cff1fb1..00000000 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { getErrorMessage } from "@bff/core/utils/error.util"; -import { SalesforceConnection } from "./salesforce-connection.service"; -import { SupportCase, CreateCaseRequest, CaseType } from "@customer-portal/domain"; -import { CaseStatus, CasePriority, CASE_STATUS, CASE_PRIORITY } from "@customer-portal/domain"; -import type { - SalesforceCaseRecord, - SalesforceContactRecord, - SalesforceCreateResult, - SalesforceQueryResult, -} from "@customer-portal/domain"; - -export interface CaseQueryParams { - status?: string; - limit?: number; - offset?: number; -} - -export interface CreateCaseUserData { - email: string; - firstName: string; - lastName: string; - accountId: string; -} - -interface CaseData { - subject: string; - description: string; - accountId: string; - type?: string; - priority?: string; - origin?: string; -} - -@Injectable() -export class SalesforceCaseService { - constructor( - private connection: SalesforceConnection, - @Inject(Logger) private readonly logger: Logger - ) {} - - async getCases( - accountId: string, - params: CaseQueryParams = {} - ): Promise<{ cases: SupportCase[]; totalSize: number }> { - const validAccountId = this.validateId(accountId); - - try { - let query = ` - SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin, - CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name - FROM Case - WHERE AccountId = '${validAccountId}' - `; - - if (params.status) { - query += ` AND Status = '${this.safeSoql(params.status)}'`; - } - - query += " ORDER BY CreatedDate DESC"; - - if (params.limit) { - query += ` LIMIT ${params.limit}`; - } - - if (params.offset) { - query += ` OFFSET ${params.offset}`; - } - - const result = (await this.connection.query(query)) as SalesforceQueryResult< - SalesforceCaseRecord & { Owner?: { Name?: string } } - >; - const cases = result.records.map(record => this.transformCase(record)); - - return { cases, totalSize: result.totalSize }; - } catch (error) { - this.logger.error("Failed to get cases", { - error: getErrorMessage(error), - }); - throw new Error("Failed to get cases"); - } - } - - async createCase( - userData: CreateCaseUserData, - caseRequest: CreateCaseRequest - ): Promise { - try { - // Create contact on-demand for case creation - const contactId = await this.findOrCreateContact(userData); - - const caseData: CaseData = { - subject: caseRequest.subject, - description: caseRequest.description, - accountId: userData.accountId, - type: caseRequest.type || "Question", - priority: caseRequest.priority || "Medium", - origin: "Web", - }; - - const sfCase = await this.createSalesforceCase({ - ...caseData, - contactId, - }); - return this.transformCase(sfCase); - } catch (error) { - this.logger.error("Failed to create case", { error: getErrorMessage(error) }); - throw error; - } - } - - async updateCase(caseId: string, updates: Record): Promise { - const validCaseId = this.validateId(caseId); - - try { - const sobject = this.connection.sobject("Case") as unknown as { - update: (data: Record) => Promise; - }; - await sobject.update({ Id: validCaseId, ...updates }); - } catch (error) { - this.logger.error("Failed to update case", { - error: getErrorMessage(error), - }); - throw new Error("Failed to update case"); - } - } - - private async findOrCreateContact(userData: CreateCaseUserData): Promise { - try { - // Try to find existing contact - const existingContact = (await this.connection.query(` - SELECT Id FROM Contact - WHERE Email = '${this.safeSoql(userData.email)}' - AND AccountId = '${userData.accountId}' - LIMIT 1 - `)) as SalesforceQueryResult; - - if (existingContact.totalSize > 0) { - return existingContact.records[0]?.Id ?? ""; - } - - // Create new contact - const contactData = { - Email: userData.email, - FirstName: userData.firstName, - LastName: userData.lastName, - AccountId: userData.accountId, - }; - - const contactCreate = this.connection.sobject("Contact") as unknown as { - create: (data: Record) => Promise; - }; - const contactResult = await contactCreate.create(contactData); - return contactResult.id; - } catch (error) { - this.logger.error("Failed to find or create contact for case", { - error: getErrorMessage(error), - }); - throw error; - } - } - - private async createSalesforceCase( - caseData: CaseData & { contactId: string } - ): Promise { - const validTypes = ["Question", "Problem", "Feature Request"]; - const validPriorities = ["Low", "Medium", "High", "Critical"]; - - const sfData = { - Subject: caseData.subject.trim().substring(0, 255), - Description: caseData.description.trim().substring(0, 32000), - ContactId: caseData.contactId, - AccountId: caseData.accountId, - Type: validTypes.includes(caseData.type || "") ? caseData.type : "Question", - Priority: validPriorities.includes(caseData.priority || "") ? caseData.priority : "Medium", - Origin: caseData.origin || "Web", - }; - - const caseCreate = this.connection.sobject("Case") as unknown as { - create: (data: Record) => Promise; - }; - const caseResult = await caseCreate.create(sfData); - const createdCases = (await this.connection.query(` - SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin, - CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name - FROM Case - WHERE Id = '${caseResult.id}' - `)) as SalesforceQueryResult; - return createdCases.records[0] ?? ({} as SalesforceCaseRecord); - } - - private transformCase(sfCase: SalesforceCaseRecord & { Owner?: { Name?: string } }): SupportCase { - const status = sfCase.Status ?? ""; - const priority = sfCase.Priority ?? ""; - const type = sfCase.Type ?? ""; - const createdDate = sfCase.CreatedDate ?? new Date().toISOString(); - return { - id: sfCase.Id, - number: sfCase.CaseNumber ?? "", - subject: sfCase.Subject ?? "", - description: sfCase.Description ?? "", - status: this.mapSalesforceStatus(status), - priority: this.mapSalesforcePriority(priority), - type: this.mapSalesforceType(type), - createdDate, - lastModifiedDate: sfCase.LastModifiedDate ?? createdDate, - closedDate: sfCase.ClosedDate, - contactId: sfCase.ContactId ?? "", - accountId: sfCase.AccountId ?? "", - ownerId: sfCase.OwnerId ?? "", - ownerName: sfCase.Owner?.Name, - }; - } - - private mapSalesforceStatus(status: string): CaseStatus { - // Map Salesforce status values to our enum - switch (status) { - case "New": - return CASE_STATUS.NEW; - case "Working": - case "In Progress": - return CASE_STATUS.WORKING; - case "Escalated": - return CASE_STATUS.ESCALATED; - case "Closed": - return CASE_STATUS.CLOSED; - default: - return CASE_STATUS.NEW; // Default fallback - } - } - - private mapSalesforcePriority(priority: string): CasePriority { - // Map Salesforce priority values to our enum - switch (priority) { - case "Low": - return CASE_PRIORITY.LOW; - case "Medium": - return CASE_PRIORITY.MEDIUM; - case "High": - return CASE_PRIORITY.HIGH; - case "Critical": - return CASE_PRIORITY.CRITICAL; - default: - return CASE_PRIORITY.MEDIUM; // Default fallback - } - } - - private mapSalesforceType(type: string): CaseType { - // Map Salesforce type values to our enum - switch (type) { - case "Question": - return "Question"; - case "Problem": - return "Problem"; - case "Feature Request": - return "Feature Request"; - default: - return "Question"; // Default fallback - } - } - - private validateId(id: string): string { - const trimmed = id?.trim(); - if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) { - throw new Error("Invalid Salesforce ID format"); - } - return trimmed; - } - - private safeSoql(input: string): string { - return input.replace(/'/g, "\\'"); - } -} diff --git a/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts b/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts deleted file mode 100644 index 695699b5..00000000 --- a/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BadRequestException } from "@nestjs/common"; -import { assertSalesforceId, buildInClause, sanitizeSoqlLiteral } from "../soql.util"; - -describe("soql.util", () => { - describe("sanitizeSoqlLiteral", () => { - it("escapes single quotes and backslashes", () => { - const raw = "O'Reilly \\"books\\""; - const result = sanitizeSoqlLiteral(raw); - expect(result).toBe("O\\'Reilly \\\\"books\\\\""); - }); - - it("returns original string when no escaping needed", () => { - const raw = "Sample-Value"; - expect(sanitizeSoqlLiteral(raw)).toBe(raw); - }); - }); - - describe("assertSalesforceId", () => { - it("returns the id when valid", () => { - const id = "0015g00000N1ABC"; - expect(assertSalesforceId(id, "context")).toBe(id); - }); - - it("throws BadRequestException when id is invalid", () => { - expect(() => assertSalesforceId("invalid", "context")).toThrow(BadRequestException); - }); - }); - - describe("buildInClause", () => { - it("builds sanitized comma separated list", () => { - const clause = buildInClause(["abc", "O'Reilly"], "field"); - expect(clause).toBe("'abc', 'O\\'Reilly'"); - }); - - it("throws when no values provided", () => { - expect(() => buildInClause([], "field")).toThrow(BadRequestException); - }); - }); -}); diff --git a/apps/bff/src/integrations/salesforce/utils/soql.util.ts b/apps/bff/src/integrations/salesforce/utils/soql.util.ts deleted file mode 100644 index f09c390d..00000000 --- a/apps/bff/src/integrations/salesforce/utils/soql.util.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BadRequestException } from "@nestjs/common"; - -const SALESFORCE_ID_PATTERN = /^[a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})?$/; - -export function sanitizeSoqlLiteral(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); -} - -export function assertSalesforceId(value: string | undefined | null, context: string): string { - if (!value || !SALESFORCE_ID_PATTERN.test(value)) { - throw new BadRequestException(`Invalid Salesforce ID for ${context}`); - } - return value; -} - -export function buildInClause(values: string[], context: string): string { - if (values.length === 0) { - throw new BadRequestException(`At least one value required for ${context}`); - } - - return values.map(raw => `'${sanitizeSoqlLiteral(raw)}'`).join(", "); -} diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts index f1eda172..a49a9085 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts @@ -239,15 +239,17 @@ export class WhmcsConnectionService { typeof errorResponse.message === "string" && errorResponse.message.toLowerCase().includes("client not found") ) { - const byEmail = - typeof (params as any).email === "string" ? (params as any).email : undefined; - if (byEmail) { - throw new NotFoundException(`Client with email ${byEmail} not found`); + const emailParam = params["email"]; + if (typeof emailParam === "string") { + throw new NotFoundException(`Client with email ${emailParam} not found`); } - const byId = (params as any).clientid; - throw new NotFoundException( - `Client ${typeof byId === "string" || typeof byId === "number" ? byId : ""} not found` - ); + + const clientIdParam = params["clientid"]; + const identifier = + typeof clientIdParam === "string" || typeof clientIdParam === "number" + ? clientIdParam + : ""; + throw new NotFoundException(`Client ${identifier} not found`); } throw new Error(`WHMCS API Error: ${errorResponse.message}`); } diff --git a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts b/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts index dc1c910c..f4aec0be 100644 --- a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts +++ b/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts @@ -66,7 +66,7 @@ export class WhmcsDataTransformer { }); return invoice; - } catch (error) { + } catch (error: unknown) { this.logger.error(`Failed to transform invoice ${invoiceId}`, { error: getErrorMessage(error), whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record), @@ -129,7 +129,7 @@ export class WhmcsDataTransformer { }); return subscription; - } catch (error) { + } catch (error: unknown) { this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, { error: getErrorMessage(error), whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record), diff --git a/apps/bff/src/main.ts b/apps/bff/src/main.ts index b9cbfaef..f08a3bca 100644 --- a/apps/bff/src/main.ts +++ b/apps/bff/src/main.ts @@ -7,27 +7,29 @@ let app: INestApplication | null = null; const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; for (const signal of signals) { - process.once(signal, async () => { - logger.log(`Received ${signal}. Closing Nest application...`); + process.once(signal, () => { + void (async () => { + logger.log(`Received ${signal}. Closing Nest application...`); - if (!app) { - logger.warn("Nest application not initialized. Exiting immediately."); - process.exit(0); - return; - } + if (!app) { + logger.warn("Nest application not initialized. Exiting immediately."); + process.exit(0); + return; + } - try { - await app.close(); - logger.log("Nest application closed gracefully."); - } catch (error) { - const resolvedError = error as Error; - logger.error( - `Error during Nest application shutdown: ${resolvedError.message}`, - resolvedError.stack - ); - } finally { - process.exit(0); - } + try { + await app.close(); + logger.log("Nest application closed gracefully."); + } catch (error) { + const resolvedError = error as Error; + logger.error( + `Error during Nest application shutdown: ${resolvedError.message}`, + resolvedError.stack + ); + } finally { + process.exit(0); + } + })(); }); } diff --git a/apps/bff/src/modules/auth/auth-zod.controller.ts b/apps/bff/src/modules/auth/auth-zod.controller.ts index c2cfc548..a76e2f69 100644 --- a/apps/bff/src/modules/auth/auth-zod.controller.ts +++ b/apps/bff/src/modules/auth/auth-zod.controller.ts @@ -105,7 +105,7 @@ export class AuthController { @ApiResponse({ status: 200, description: "Login successful" }) @ApiResponse({ status: 401, description: "Invalid credentials" }) @ApiResponse({ status: 429, description: "Too many login attempts" }) - async login(@Req() req: Request & { user: { id: string; email: string; role?: string } }) { + async login(@Req() req: Request & { user: { id: string; email: string; role: string } }) { return this.authService.login(req.user, req); } @@ -219,7 +219,7 @@ export class AuthController { @Get("me") @ApiOperation({ summary: "Get current authentication status" }) - getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) { + getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) { // Return basic auth info only - full profile should use /api/me return { isAuthenticated: true, diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 7d9a03e6..f91c6e96 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -5,6 +5,7 @@ import { ConfigService } from "@nestjs/config"; import { APP_GUARD } from "@nestjs/core"; import { AuthService } from "./auth.service"; import { AuthController } from "./auth-zod.controller"; +import { AuthAdminController } from "./auth-admin.controller"; import { UsersModule } from "@bff/modules/users/users.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; import { IntegrationsModule } from "@bff/integrations/integrations.module"; @@ -33,7 +34,7 @@ import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workfl IntegrationsModule, EmailModule, ], - controllers: [AuthController], + controllers: [AuthController, AuthAdminController], providers: [ AuthService, JwtStrategy, diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index 19abd203..4fc80925 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -148,6 +148,7 @@ export class AuthService { { id: profile.id, email: profile.email, + role: prismaUser.role, }, { userAgent: request?.headers["user-agent"], @@ -155,7 +156,10 @@ export class AuthService { ); return { - user: profile, + user: { + ...profile, + role: prismaUser.role, + }, tokens, }; } @@ -176,7 +180,7 @@ export class AuthService { email: string, password: string, _request?: Request - ): Promise<{ id: string; email: string; role?: string } | null> { + ): Promise<{ id: string; email: string; role: string } | null> { const user = await this.usersService.findByEmailInternal(email); if (!user) { @@ -224,7 +228,7 @@ export class AuthService { return { id: user.id, email: user.email, - role: user.role || undefined, + role: user.role, }; } else { // Increment failed login attempts diff --git a/apps/bff/src/modules/auth/auth.types.ts b/apps/bff/src/modules/auth/auth.types.ts index c745d525..9e5fa9bd 100644 --- a/apps/bff/src/modules/auth/auth.types.ts +++ b/apps/bff/src/modules/auth/auth.types.ts @@ -1,4 +1,4 @@ -import type { UserProfile } from "@customer-portal/domain"; +import type { AuthenticatedUser } from "@customer-portal/domain"; import type { Request } from "express"; -export type RequestWithUser = Request & { user: UserProfile }; +export type RequestWithUser = Request & { user: AuthenticatedUser }; diff --git a/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts b/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts index fb89d39d..0cbc5585 100644 --- a/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts +++ b/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts @@ -1,5 +1,6 @@ import { Injectable } from "@nestjs/common"; import { ThrottlerGuard } from "@nestjs/throttler"; +import { createHash } from "crypto"; import type { Request } from "express"; @Injectable() @@ -16,9 +17,11 @@ export class AuthThrottleGuard extends ThrottlerGuard { "unknown"; const userAgent = req.headers["user-agent"] || "unknown"; - const userAgentHash = Buffer.from(userAgent).toString("base64").slice(0, 16); + const userAgentHash = createHash("sha256").update(userAgent).digest("hex").slice(0, 16); - const resolvedIp = await Promise.resolve(ip); + const normalizedIp = ip.replace(/^::ffff:/, ""); + + const resolvedIp = await Promise.resolve(normalizedIp); return `auth_${resolvedIp}_${userAgentHash}`; } } diff --git a/apps/bff/src/modules/auth/services/token-blacklist.service.ts b/apps/bff/src/modules/auth/services/token-blacklist.service.ts index aa2abdbd..9e46bab7 100644 --- a/apps/bff/src/modules/auth/services/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/services/token-blacklist.service.ts @@ -23,15 +23,20 @@ export class TokenBlacklistService { // Use JwtService to safely decode and validate token try { - const payload = this.jwtService.decode(token); + const decoded = this.jwtService.decode(token); - // Validate payload structure - if (!payload || !payload.sub || !payload.exp) { + if (!decoded || typeof decoded !== "object") { this.logger.warn("Invalid JWT payload structure for blacklisting"); return; } - const expiryTime = payload.exp * 1000; // Convert to milliseconds + const { sub, exp } = decoded as { sub?: unknown; exp?: unknown }; + if (typeof sub !== "string" || typeof exp !== "number") { + this.logger.warn("Invalid JWT payload structure for blacklisting"); + return; + } + + const expiryTime = exp * 1000; // Convert to milliseconds const currentTime = Date.now(); const ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds @@ -41,7 +46,7 @@ export class TokenBlacklistService { } else { this.logger.debug("Token already expired, not blacklisting"); } - } catch (parseError) { + } catch (_parseError: unknown) { // If we can't parse the token, blacklist it for the default JWT expiry time try { const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); diff --git a/apps/bff/src/modules/auth/services/token.service.ts b/apps/bff/src/modules/auth/services/token.service.ts index db4ce89a..685f0265 100644 --- a/apps/bff/src/modules/auth/services/token.service.ts +++ b/apps/bff/src/modules/auth/services/token.service.ts @@ -12,6 +12,21 @@ export interface RefreshTokenPayload { tokenId: string; deviceId?: string; userAgent?: string; + type: "refresh"; +} + +interface StoredRefreshToken { + familyId: string; + userId: string; + valid: boolean; +} + +interface StoredRefreshTokenFamily { + userId: string; + tokenHash: string; + deviceId?: string; + userAgent?: string; + createdAt?: string; } @Injectable() @@ -61,6 +76,7 @@ export class AuthTokenService { tokenId: familyId, deviceId: deviceInfo?.deviceId, userAgent: deviceInfo?.userAgent, + type: "refresh", }; // Generate tokens @@ -76,41 +92,41 @@ export class AuthTokenService { const refreshTokenHash = this.hashToken(refreshToken); const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); - if (this.redis.status !== "ready") { - this.logger.error("Redis not ready for token issuance", { status: this.redis.status }); - throw new UnauthorizedException("Session service unavailable"); - } + if (this.redis.status === "ready") { + try { + await this.redis.ping(); + await this.redis.setex( + `${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`, + refreshExpirySeconds, + JSON.stringify({ + userId: user.id, + tokenHash: refreshTokenHash, + deviceId: deviceInfo?.deviceId, + userAgent: deviceInfo?.userAgent, + createdAt: new Date().toISOString(), + }) + ); - try { - await this.redis.ping(); - await this.redis.setex( - `${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`, - refreshExpirySeconds, - JSON.stringify({ + // Store individual refresh token + await this.redis.setex( + `${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`, + refreshExpirySeconds, + JSON.stringify({ + familyId, + userId: user.id, + valid: true, + }) + ); + } catch (error) { + this.logger.error("Failed to store refresh token in Redis", { + error: error instanceof Error ? error.message : String(error), userId: user.id, - tokenHash: refreshTokenHash, - deviceId: deviceInfo?.deviceId, - userAgent: deviceInfo?.userAgent, - createdAt: new Date().toISOString(), - }) - ); - - // Store individual refresh token - await this.redis.setex( - `${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`, - refreshExpirySeconds, - JSON.stringify({ - familyId, - userId: user.id, - valid: true, - }) - ); - } catch (error) { - this.logger.error("Failed to store refresh token in Redis", { - error: error instanceof Error ? error.message : String(error), - userId: user.id, + }); + } + } else { + this.logger.warn("Redis not ready for token issuance; issuing non-rotating tokens", { + status: this.redis.status, }); - throw new UnauthorizedException("Unable to issue session tokens. Please try again."); } const accessExpiresAt = new Date( @@ -143,7 +159,15 @@ export class AuthTokenService { ): Promise { try { // Verify refresh token - const payload = this.jwtService.verify(refreshToken); + const payload = this.jwtService.verify(refreshToken); + + if (payload.type !== "refresh") { + this.logger.warn("Token presented to refresh endpoint is not a refresh token", { + tokenId: payload.tokenId, + }); + throw new UnauthorizedException("Invalid refresh token"); + } + const refreshTokenHash = this.hashToken(refreshToken); // Check if refresh token exists and is valid @@ -185,7 +209,7 @@ export class AuthTokenService { const user = { id: prismaUser.id, email: prismaUser.email, - role: prismaUser.role.toLowerCase(), // Convert UserRole enum to lowercase string + role: prismaUser.role, }; // Invalidate current refresh token @@ -201,6 +225,33 @@ export class AuthTokenService { this.logger.error("Token refresh failed", { error: error instanceof Error ? error.message : String(error), }); + + if (this.redis.status !== "ready") { + this.logger.warn("Redis unavailable during token refresh; issuing fallback token pair"); + const fallbackPayload = this.jwtService.decode(refreshToken) as + | RefreshTokenPayload + | null; + + const fallbackUserId = fallbackPayload?.userId; + + if (fallbackUserId) { + const fallbackUser = await this.usersService + .findByIdInternal(fallbackUserId) + .catch(() => null); + + if (fallbackUser) { + return this.generateTokenPair( + { + id: fallbackUser.id, + email: fallbackUser.email, + role: fallbackUser.role, + }, + deviceInfo + ); + } + } + } + throw new UnauthorizedException("Invalid refresh token"); } } diff --git a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts index d441967f..ce0f9a01 100644 --- a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts @@ -1,15 +1,16 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; import { ConfigService } from "@nestjs/config"; -import type { UserProfile } from "@customer-portal/domain"; -import { TokenBlacklistService } from "../services/token-blacklist.service"; +import type { AuthenticatedUser } from "@customer-portal/domain"; +import { UsersService } from "@bff/modules/users/users.service"; +import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private configService: ConfigService, - private tokenBlacklistService: TokenBlacklistService + private readonly usersService: UsersService ) { const jwtSecret = configService.get("JWT_SECRET"); if (!jwtSecret) { @@ -31,28 +32,24 @@ export class JwtStrategy extends PassportStrategy(Strategy) { role: string; iat?: number; exp?: number; - }): Promise { + }): Promise { // Validate payload structure if (!payload.sub || !payload.email) { throw new Error("Invalid JWT payload"); } - // Return user info - token blacklist is checked in GlobalAuthGuard - // This separation allows us to avoid request object dependency here - return { - id: payload.sub, - email: payload.email, - firstName: undefined, - lastName: undefined, - company: undefined, - phone: undefined, - mfaEnabled: false, - emailVerified: true, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - avatar: undefined, - preferences: {}, - lastLoginAt: undefined, - }; + const prismaUser = await this.usersService.findByIdInternal(payload.sub); + + if (!prismaUser) { + throw new UnauthorizedException("User not found"); + } + + if (prismaUser.email !== payload.email) { + throw new UnauthorizedException("Token subject does not match user record"); + } + + const profile = mapPrismaUserToUserProfile(prismaUser); + + return profile; } } diff --git a/apps/bff/src/modules/auth/strategies/local.strategy.ts b/apps/bff/src/modules/auth/strategies/local.strategy.ts index cbb5fb60..e1eccee9 100644 --- a/apps/bff/src/modules/auth/strategies/local.strategy.ts +++ b/apps/bff/src/modules/auth/strategies/local.strategy.ts @@ -14,7 +14,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { req: Request, email: string, password: string - ): Promise<{ id: string; email: string; role?: string }> { + ): Promise<{ id: string; email: string; role: string }> { const user = await this.authService.validateUser(email, password, req); if (!user) { throw new UnauthorizedException("Invalid credentials"); diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/catalog/services/base-catalog.service.ts index 681e25d0..16f20680 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/base-catalog.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { assertSalesforceId, sanitizeSoqlLiteral } from "@bff/integrations/salesforce/utils/soql.util"; import type { SalesforceProduct2WithPricebookEntries, SalesforceQueryResult, @@ -15,7 +16,8 @@ export class BaseCatalogService { protected readonly sf: SalesforceConnection, @Inject(Logger) protected readonly logger: Logger ) { - this.portalPriceBookId = process.env.PORTAL_PRICEBOOK_ID || "01sTL000008eLVlYAM"; + const portalPricebook = process.env.PORTAL_PRICEBOOK_ID || "01sTL000008eLVlYAM"; + this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID"); } protected getFields() { @@ -66,12 +68,15 @@ export class BaseCatalogService { ]; const allFields = [...baseFields, ...additionalFields].join(", "); + const safeCategory = sanitizeSoqlLiteral(category); + const safeItemClass = sanitizeSoqlLiteral(itemClass); + return ` SELECT ${allFields}, (SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1) FROM Product2 - WHERE ${fields.product.portalCategory} = '${category}' - AND ${fields.product.itemClass} = '${itemClass}' + WHERE ${fields.product.portalCategory} = '${safeCategory}' + AND ${fields.product.itemClass} = '${safeItemClass}' AND ${fields.product.portalAccessible} = true ${additionalConditions} ORDER BY ${fields.product.displayOrder} NULLS LAST, Name diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index e0372d90..8447543a 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -10,6 +10,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; +import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util"; import { mapInternetPlan, mapInternetInstallation, @@ -116,7 +117,8 @@ export class InternetCatalogService extends BaseCatalogService { // Get customer's eligibility from Salesforce const fields = this.getFields(); - const soql = `SELECT Id, ${fields.account.internetEligibility} FROM Account WHERE Id = '${mapping.sfAccountId}' LIMIT 1`; + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + const soql = `SELECT Id, ${fields.account.internetEligibility} FROM Account WHERE Id = '${sfAccountId}' LIMIT 1`; const accounts = await this.executeQuery(soql, "Customer Eligibility"); if (accounts.length === 0) { diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts index b564a5dd..91f8e422 100644 --- a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts @@ -1,9 +1,7 @@ import type { CatalogProductBase, - CatalogPricebookEntry, SalesforceProduct2WithPricebookEntries, SalesforcePricebookEntryRecord, - InternetCatalogProduct, InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, @@ -18,12 +16,68 @@ const fieldMap = getSalesforceFieldMap(); export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries; +const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = { + tierDescription: "Standard plan", + description: undefined, + features: undefined, +}; + +function getTierTemplate(tier?: string): InternetPlanTemplate { + if (!tier) { + return DEFAULT_PLAN_TEMPLATE; + } + + const normalized = tier.toLowerCase(); + switch (normalized) { + case "gold": + return { + tierDescription: "Gold plan", + description: "Premium speed internet plan", + features: ["Highest bandwidth", "Priority support"], + }; + case "silver": + return { + tierDescription: "Silver plan", + description: "Balanced performance plan", + features: ["Great value", "Reliable speeds"], + }; + case "bronze": + return { + tierDescription: "Bronze plan", + description: "Entry level plan", + features: ["Essential connectivity"], + }; + default: + return { + tierDescription: `${tier} plan`, + description: undefined, + features: undefined, + }; + } +} + +function inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" { + const normalized = sku.toLowerCase(); + if (normalized.includes("24")) return "24-Month"; + if (normalized.includes("12")) return "12-Month"; + return "One-time"; +} + +function inferAddonTypeFromSku( + sku: string +): "hikari-denwa-service" | "hikari-denwa-installation" | "other" { + const normalized = sku.toLowerCase(); + if (normalized.includes("installation")) return "hikari-denwa-installation"; + if (normalized.includes("denwa")) return "hikari-denwa-service"; + return "other"; +} + function getProductField( product: SalesforceCatalogProductRecord, fieldKey: keyof typeof fieldMap.product ): T | undefined { - const salesforceField = fieldMap.product[fieldKey]; - const value = (product as Record)[salesforceField]; + const salesforceField = fieldMap.product[fieldKey] as keyof SalesforceCatalogProductRecord; + const value = product[salesforceField]; return value as T | undefined; } @@ -52,28 +106,6 @@ function coerceNumber(value: unknown): number | undefined { return undefined; } -function buildPricebookEntry( - entry?: SalesforcePricebookEntryRecord -): CatalogPricebookEntry | undefined { - if (!entry) return undefined; - return { - id: entry.Id, - name: entry.Name, - unitPrice: coerceNumber(entry.UnitPrice), - pricebook2Id: entry.Pricebook2Id ?? undefined, - product2Id: entry.Product2Id ?? undefined, - isActive: entry.IsActive ?? undefined, - }; -} - -function getPricebookEntry( - product: SalesforceCatalogProductRecord -): CatalogPricebookEntry | undefined { - const nested = product.PricebookEntries?.records; - if (!Array.isArray(nested) || nested.length === 0) return undefined; - return buildPricebookEntry(nested[0]); -} - function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase { const sku = getStringField(product, "sku") ?? ""; const base: CatalogProductBase = { @@ -101,7 +133,7 @@ function parseFeatureList(product: SalesforceCatalogProductRecord): string[] | u } if (typeof raw === "string") { try { - const parsed = JSON.parse(raw); + const parsed = JSON.parse(raw) as unknown; return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : undefined; @@ -119,8 +151,8 @@ function derivePrices( const billingCycle = getStringField(product, "billingCycle")?.toLowerCase(); const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined; - let monthlyPrice = undefined; - let oneTimePrice = undefined; + let monthlyPrice: number | undefined; + let oneTimePrice: number | undefined; if (unitPrice !== undefined) { if (billingCycle === "monthly") { diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index 3a470dc7..e33f992b 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -299,20 +299,22 @@ export class MappingsService { async getMappingStats(): Promise { try { - const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([ - this.prisma.idMapping.count(), - this.prisma.idMapping.count(), - this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), - this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), - ]); + const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([ + this.prisma.idMapping.count(), + this.prisma.idMapping.count({ where: { whmcsClientId: { not: null } } }), + this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), + this.prisma.idMapping.count({ where: { whmcsClientId: { not: null }, sfAccountId: { not: null } } }), + ]); - const stats: MappingStats = { - totalMappings: totalCount, - whmcsMappings: whmcsCount, - salesforceMappings: sfCount, - completeMappings: completeCount, - orphanedMappings: 0, - }; + const orphanedMappings = whmcsCount - completeCount; + + const stats: MappingStats = { + totalMappings: totalCount, + whmcsMappings: whmcsCount, + salesforceMappings: sfCount, + completeMappings: completeCount, + orphanedMappings: orphanedMappings < 0 ? 0 : orphanedMappings, + }; this.logger.debug("Generated mapping statistics", stats); return stats; } catch (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 2b25213b..430070aa 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 @@ -47,7 +47,10 @@ export class OrderItemBuilder { } if (!meta.unitPrice) { - this.logger.error({ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, "PricebookEntry missing UnitPrice"); + this.logger.error( + { sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, + "PricebookEntry missing UnitPrice" + ); throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`); } @@ -71,7 +74,10 @@ export class OrderItemBuilder { this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully"); } catch (error) { - this.logger.error({ error, orderId, sku: normalizedSkuValue }, "Failed to create OrderItem"); + this.logger.error( + { error, orderId, sku: normalizedSkuValue }, + "Failed to create OrderItem" + ); throw error; } } diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 14804c2d..fddc6b3e 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -7,12 +7,12 @@ import { OrderItemBuilder } from "./order-item-builder.service"; import { orderDetailsSchema, orderSummarySchema, - type OrderDetailsResponse, - type OrderSummaryResponse, + z, type OrderItemSummary, type SalesforceOrderRecord, type SalesforceOrderItemRecord, type SalesforceQueryResult, + type SalesforceProduct2Record, } from "@customer-portal/domain"; import { getSalesforceFieldMap, @@ -23,25 +23,30 @@ import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/ const fieldMap = getSalesforceFieldMap(); +type OrderDetailsResponse = z.infer; +type OrderSummaryResponse = z.infer; + function getOrderStringField( order: SalesforceOrderRecord, key: keyof typeof fieldMap.order ): string | undefined { - const raw = (order as Record)[fieldMap.order[key]]; + const fieldName = fieldMap.order[key] as keyof SalesforceOrderRecord; + const raw = order[fieldName]; return typeof raw === "string" ? raw : undefined; } function pickProductString( - product: Record | undefined, + product: SalesforceProduct2Record | null | undefined, key: keyof typeof fieldMap.product ): string | undefined { if (!product) return undefined; - const raw = product[fieldMap.product[key]]; + const fieldName = fieldMap.product[key] as keyof SalesforceProduct2Record; + const raw = product[fieldName]; return typeof raw === "string" ? raw : undefined; } function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails { - const product = record.PricebookEntry?.Product2 as Record | undefined; + const product = record.PricebookEntry?.Product2 ?? undefined; return { id: record.Id ?? "", @@ -249,6 +254,7 @@ export class OrderOrchestrator { name: detail.product.name, sku: detail.product.sku, itemClass: detail.product.itemClass, + billingCycle: detail.billingCycle, whmcsProductId: detail.product.whmcsProductId, internetOfferingType: detail.product.internetOfferingType, internetPlanTier: detail.product.internetPlanTier, 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 f75c0342..613f5728 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -1,5 +1,6 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; +import { ZodError } from "zod"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; @@ -242,10 +243,26 @@ export class OrderValidator { const validatedBody = this.validateRequestFormat(rawBody); // 1b. Business validation (ensures userId-specific constraints) - const businessValidatedBody = orderBusinessValidationSchema.parse({ - ...validatedBody, - userId, - }); + let businessValidatedBody: OrderBusinessValidation; + try { + businessValidatedBody = orderBusinessValidationSchema.parse({ + ...validatedBody, + userId, + }); + } catch (error) { + if (error instanceof ZodError) { + const issues = error.issues.map(issue => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }); + throw new BadRequestException({ + message: "Order business validation failed", + errors: issues, + statusCode: 400, + }); + } + throw error; + } // 2. User and payment validation const userMapping = await this.validateUserMapping(userId); diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index 7b55dff8..26f8a151 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -4,11 +4,7 @@ import { mapPrismaUserToSharedUser, mapPrismaUserToEnhancedBase, } from "@bff/infra/utils/user-mapper.util"; -import type { - UpdateAddressRequest, - SalesforceAccountRecord, - SalesforceContactRecord, -} from "@customer-portal/domain"; +import type { UpdateAddressRequest, SalesforceContactRecord } from "@customer-portal/domain"; import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { PrismaService } from "@bff/infra/database/prisma.service"; @@ -20,9 +16,6 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; -// Salesforce Account interface based on the data model -interface SalesforceAccount extends SalesforceAccountRecord {} - // Use a subset of PrismaUser for updates type UserUpdateData = Partial< Pick< @@ -165,11 +158,11 @@ export class UsersService { try { const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); if (client) { - firstName = (client as any).firstname || firstName; - lastName = (client as any).lastname || lastName; - company = (client as any).companyname || company; - phone = (client as any).phonenumber || phone; - email = (client as any).email || email; + firstName = client.firstname || firstName; + lastName = client.lastname || lastName; + company = client.companyname || company; + phone = client.phonenumber || phone; + email = client.email || email; } } catch (err) { this.logger.warn("WHMCS client details unavailable for profile enrichment", { @@ -181,12 +174,10 @@ export class UsersService { } // Check Salesforce health flag (do not override fields) - let salesforceHealthy = true; if (mapping?.sfAccountId) { try { await this.salesforceService.getAccount(mapping.sfAccountId); } catch (error) { - salesforceHealthy = false; this.logger.error("Failed to fetch Salesforce account data", { error: getErrorMessage(error), userId, diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index 576f3e39..dd2aaf0f 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -16,6 +16,8 @@ export function SessionTimeoutWarning({ const [showWarning, setShowWarning] = useState(false); const [timeLeft, setTimeLeft] = useState(0); const expiryRef = useRef(null); + const dialogRef = useRef(null); + const previouslyFocusedElement = useRef(null); useEffect(() => { if (!isAuthenticated || !tokens?.expiresAt) { @@ -65,6 +67,12 @@ export function SessionTimeoutWarning({ useEffect(() => { if (!showWarning || !expiryRef.current) return undefined; + previouslyFocusedElement.current = document.activeElement as HTMLElement | null; + + const focusTimer = window.setTimeout(() => { + dialogRef.current?.focus(); + }, 0); + const interval = setInterval(() => { const expiryTime = expiryRef.current; if (!expiryTime) { @@ -81,7 +89,53 @@ export function SessionTimeoutWarning({ setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000)))); }, 60000); - return () => clearInterval(interval); + return () => { + clearInterval(interval); + clearTimeout(focusTimer); + previouslyFocusedElement.current?.focus(); + }; + }, [showWarning, logout]); + + useEffect(() => { + if (!showWarning) return undefined; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setShowWarning(false); + void logout(); + } + + if (event.key === "Tab") { + const focusableElements = dialogRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (!focusableElements || focusableElements.length === 0) { + event.preventDefault(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; }, [showWarning, logout]); const handleExtendSession = () => { @@ -107,14 +161,28 @@ export function SessionTimeoutWarning({ } return ( -
-
+
+
⚠️ -

Session Expiring Soon

+

+ Session Expiring Soon +

-

+

Your session will expire in{" "} {timeLeft} minute{timeLeft !== 1 ? "s" : ""} diff --git a/packages/domain/src/contracts/catalog.ts b/packages/domain/src/contracts/catalog.ts index 8ef40fce..9a38bb08 100644 --- a/packages/domain/src/contracts/catalog.ts +++ b/packages/domain/src/contracts/catalog.ts @@ -56,8 +56,15 @@ export interface SimActivationFeeCatalogItem extends SimCatalogProduct { isDefault: boolean; }; } - export interface VpnCatalogProduct extends CatalogProductBase { vpnRegion?: string; } +export interface CatalogPricebookEntry { + id?: string; + name?: string; + unitPrice?: number; + pricebook2Id?: string; + product2Id?: string; + isActive?: boolean; +} diff --git a/packages/domain/src/contracts/salesforce.ts b/packages/domain/src/contracts/salesforce.ts index 8a56d82c..e2611f1c 100644 --- a/packages/domain/src/contracts/salesforce.ts +++ b/packages/domain/src/contracts/salesforce.ts @@ -18,6 +18,10 @@ export interface SalesforceSObjectBase { LastModifiedDate?: IsoDateTimeString; } +export type SalesforceOrderStatus = string; +export type SalesforceOrderType = string; +export type SalesforceOrderItemStatus = string; + export interface SalesforceProduct2Record extends SalesforceSObjectBase { Name?: string; StockKeepingUnit?: string; @@ -53,8 +57,7 @@ export interface SalesforcePricebookEntryRecord extends SalesforceSObjectBase { Product2?: SalesforceProduct2Record | null; } -export interface SalesforceProduct2WithPricebookEntries - extends SalesforceProduct2Record { +export interface SalesforceProduct2WithPricebookEntries extends SalesforceProduct2Record { PricebookEntries?: { records?: SalesforcePricebookEntryRecord[]; }; @@ -100,6 +103,7 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase { EffectiveDate?: IsoDateTimeString | null; TotalAmount?: number | null; AccountId?: string | null; + Account?: { Name?: string | null } | null; Pricebook2Id?: string | null; Activation_Type__c?: string | null; Activation_Status__c?: string | null; @@ -120,6 +124,25 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase { ActivatedDate?: IsoDateTimeString | null; } +export interface SalesforceOrderItemSummary { + productName?: string; + sku?: string; + status?: SalesforceOrderItemStatus; + billingCycle?: string; +} +export interface SalesforceOrderSummary { + id: string; + orderNumber: string; + status: SalesforceOrderStatus; + orderType?: SalesforceOrderType; + effectiveDate: IsoDateTimeString; + totalAmount?: number; + createdDate: IsoDateTimeString; + lastModifiedDate: IsoDateTimeString; + whmcsOrderId?: string; + itemsSummary: SalesforceOrderItemSummary[]; +} + export interface SalesforceOrderItemRecord extends SalesforceSObjectBase { OrderId?: string | null; Quantity?: number | null; @@ -130,7 +153,6 @@ export interface SalesforceOrderItemRecord extends SalesforceSObjectBase { Billing_Cycle__c?: string | null; WHMCS_Service_ID__c?: string | null; } - export interface SalesforceAccountContactRecord extends SalesforceSObjectBase { AccountId?: string | null; ContactId?: string | null; @@ -142,4 +164,3 @@ export interface SalesforceContactRecord extends SalesforceSObjectBase { Email?: string | null; Phone?: string | null; } - diff --git a/packages/domain/src/entities/index.ts b/packages/domain/src/entities/index.ts index e2378ffb..b25a481c 100644 --- a/packages/domain/src/entities/index.ts +++ b/packages/domain/src/entities/index.ts @@ -1,6 +1,7 @@ // Export all entities - resolving conflicts explicitly export * from "./user"; export * from "./invoice"; +export * from "./subscription"; export * from "./payment"; export * from "./case"; export * from "./dashboard"; diff --git a/packages/domain/src/entities/invoice.ts b/packages/domain/src/entities/invoice.ts index 272cea42..938d8f75 100644 --- a/packages/domain/src/entities/invoice.ts +++ b/packages/domain/src/entities/invoice.ts @@ -1,9 +1,9 @@ // Invoice types from WHMCS -export type { - InvoiceSchema as Invoice, - InvoiceItemSchema as InvoiceItem, - InvoiceListSchema as InvoiceList, -} from "../validation"; +import type { InvoiceSchema, InvoiceItemSchema, InvoiceListSchema } from "../validation"; + +export type Invoice = InvoiceSchema; +export type InvoiceItem = InvoiceItemSchema; +export type InvoiceList = InvoiceListSchema; export interface InvoiceSsoLink { url: string; diff --git a/packages/domain/src/entities/subscription.ts b/packages/domain/src/entities/subscription.ts new file mode 100644 index 00000000..2f9b3c61 --- /dev/null +++ b/packages/domain/src/entities/subscription.ts @@ -0,0 +1,8 @@ +import type { SubscriptionSchema } from "../validation"; + +export type Subscription = SubscriptionSchema; + +export interface SubscriptionList { + subscriptions: Subscription[]; + totalCount: number; +} diff --git a/packages/domain/src/entities/user.ts b/packages/domain/src/entities/user.ts index dee971dc..7542ecf9 100644 --- a/packages/domain/src/entities/user.ts +++ b/packages/domain/src/entities/user.ts @@ -109,6 +109,12 @@ export interface UserProfile extends User { lastLoginAt?: string; } +export type UserRole = "user" | "admin"; + +export interface AuthenticatedUser extends UserProfile { + role: UserRole; +} + // MNP (Mobile Number Portability) business entity export interface MnpDetails { currentProvider: string; diff --git a/packages/domain/src/validation/api/responses.ts b/packages/domain/src/validation/api/responses.ts index 96570a72..69b04f99 100644 --- a/packages/domain/src/validation/api/responses.ts +++ b/packages/domain/src/validation/api/responses.ts @@ -1,13 +1,6 @@ import { z } from "zod"; import { userProfileSchema } from "../shared/entities"; -import { - orderDetailItemSchema, - orderDetailItemProductSchema, - orderDetailsSchema, - orderSummaryItemSchema, - orderSummarySchema, -} from "../shared/order"; export const authResponseSchema = z.object({ user: userProfileSchema, @@ -22,7 +15,3 @@ export const authResponseSchema = z.object({ export type AuthResponse = z.infer; export type AuthTokensSchema = AuthResponse["tokens"]; - -export { orderDetailsSchema, orderSummarySchema }; -export type OrderDetailsResponse = z.infer; -export type OrderSummaryResponse = z.infer; diff --git a/packages/domain/src/validation/forms/auth.ts b/packages/domain/src/validation/forms/auth.ts index 30d3b01e..5d2c9429 100644 --- a/packages/domain/src/validation/forms/auth.ts +++ b/packages/domain/src/validation/forms/auth.ts @@ -3,8 +3,6 @@ * Frontend form schemas that extend API request schemas with UI-specific fields */ -import { z } from "zod"; - import { loginRequestSchema, signupRequestSchema, @@ -59,23 +57,31 @@ export const changePasswordFormToRequest = ( // Import API types import type { - LoginRequestInput as LoginRequestData, - SignupRequestInput as SignupRequestData, - PasswordResetRequestInput as PasswordResetRequestData, - PasswordResetInput as PasswordResetData, - SetPasswordRequestInput as SetPasswordRequestData, - ChangePasswordRequestInput as ChangePasswordRequestData, - LinkWhmcsRequestInput as LinkWhmcsRequestData, + LoginRequestInput, + SignupRequestInput, + PasswordResetRequestInput, + PasswordResetInput, + SetPasswordRequestInput, + ChangePasswordRequestInput, + LinkWhmcsRequestInput, } from "../api/requests"; -// Export form types -export type LoginFormData = z.infer; -export type SignupFormData = z.infer; -export type PasswordResetRequestFormData = z.infer; -export type PasswordResetFormData = z.infer; -export type SetPasswordFormData = z.infer; -export type ChangePasswordFormData = z.infer; -export type LinkWhmcsFormData = z.infer; +type LoginRequestData = LoginRequestInput; +type SignupRequestData = SignupRequestInput; +type PasswordResetRequestData = PasswordResetRequestInput; +type PasswordResetData = PasswordResetInput; +type SetPasswordRequestData = SetPasswordRequestInput; +type ChangePasswordRequestData = ChangePasswordRequestInput; +type LinkWhmcsRequestData = LinkWhmcsRequestInput; + +// Export form types (aliases of API request types) +export type LoginFormData = LoginRequestInput; +export type SignupFormData = SignupRequestInput; +export type PasswordResetRequestFormData = PasswordResetRequestInput; +export type PasswordResetFormData = PasswordResetInput; +export type SetPasswordFormData = SetPasswordRequestInput; +export type ChangePasswordFormData = ChangePasswordRequestInput; +export type LinkWhmcsFormData = LinkWhmcsRequestInput; // Re-export API types for convenience export type { diff --git a/packages/domain/src/validation/forms/profile.ts b/packages/domain/src/validation/forms/profile.ts index 2cb9aa84..66942971 100644 --- a/packages/domain/src/validation/forms/profile.ts +++ b/packages/domain/src/validation/forms/profile.ts @@ -62,9 +62,12 @@ export const contactFormToRequest = (formData: ContactFormData): ContactRequestD import type { UpdateProfileRequest as UpdateProfileRequestData, ContactRequest as ContactRequestData, + UpdateAddressRequest as UpdateAddressRequestData, } from "../api/requests"; // Export form types and API request types export type ProfileEditFormData = z.infer; export type AddressFormData = z.infer; export type ContactFormData = z.infer; + +export type { UpdateProfileRequestData, UpdateAddressRequestData, ContactRequestData }; diff --git a/packages/domain/src/validation/index.ts b/packages/domain/src/validation/index.ts index c2b6f4f8..80bf1084 100644 --- a/packages/domain/src/validation/index.ts +++ b/packages/domain/src/validation/index.ts @@ -46,8 +46,11 @@ export { simChangePlanRequestSchema, simFeaturesRequestSchema, - // Contact API schemas + // Contact & billing schemas contactRequestSchema, + invoiceItemSchema, + invoiceSchema, + invoiceListSchema, // API types type LoginRequestInput, @@ -74,13 +77,7 @@ export { } from "./api/requests"; // Form schemas (frontend) - explicit exports for better tree shaking -export { - authResponseSchema, - type AuthResponse, - type AuthTokensSchema, - type OrderDetailsResponse, - type OrderSummaryResponse, -} from "./api/responses"; +export { authResponseSchema, type AuthResponse, type AuthTokensSchema } from "./api/responses"; export { // Auth form schemas diff --git a/packages/domain/src/validation/shared/entities.ts b/packages/domain/src/validation/shared/entities.ts index 43d6c3ec..bcbca52e 100644 --- a/packages/domain/src/validation/shared/entities.ts +++ b/packages/domain/src/validation/shared/entities.ts @@ -92,6 +92,7 @@ export const userProfileSchema = userSchema.extend({ avatar: z.string().optional(), preferences: z.record(z.string(), z.unknown()).optional(), lastLoginAt: timestampSchema.optional(), + role: z.enum(["user", "admin"]), }); export const prismaUserProfileSchema = z.object({ diff --git a/packages/domain/src/validation/shared/index.ts b/packages/domain/src/validation/shared/index.ts index 3aaa4df4..fcf39eae 100644 --- a/packages/domain/src/validation/shared/index.ts +++ b/packages/domain/src/validation/shared/index.ts @@ -47,7 +47,30 @@ export { // Validation utilities and helpers export * from "./utilities"; -export { userSchema, userProfileSchema } from "./entities"; +export { + userSchema, + userProfileSchema, + invoiceItemSchema, + invoiceSchema, + invoiceListSchema, + subscriptionSchema, + paymentMethodSchema, + paymentSchema, + caseCommentSchema, + supportCaseSchema, +} from "./entities"; +export type { + UserSchema, + UserProfileSchema, + InvoiceItemSchema, + InvoiceSchema, + InvoiceListSchema, + SubscriptionSchema, + PaymentMethodSchema, + PaymentSchema, + CaseCommentSchema, + SupportCaseSchema, +} from "./entities"; export { orderItemProductSchema, orderDetailItemSchema, diff --git a/packages/domain/src/validation/shared/order.ts b/packages/domain/src/validation/shared/order.ts index 387dd3a4..efd9e2a8 100644 --- a/packages/domain/src/validation/shared/order.ts +++ b/packages/domain/src/validation/shared/order.ts @@ -62,3 +62,8 @@ export const orderSummarySchema = z.object({ itemsSummary: z.array(orderSummaryItemSchema), }); +export type OrderItemProduct = z.infer; +export type OrderDetailItem = z.infer; +export type OrderItemSummary = z.infer; +export type OrderDetailsResponse = z.infer; +export type OrderSummaryResponse = z.infer;