From cdec21e0123d9ade938935294a813a4961329823 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 2 Oct 2025 18:35:26 +0900 Subject: [PATCH] Refactor authentication module by removing the deprecated AuthAdminController and related token migration services. Update AuthModule to streamline dependencies and enhance structure. Adjust imports in various services and controllers for improved maintainability. Revise documentation to reflect the removal of admin endpoints and clarify the new authentication setup. --- apps/bff/src/app/bootstrap.ts | 2 +- .../distributed-transaction.service.ts | 27 ++-- .../database/services/transaction.service.ts | 4 +- .../salesforce-request-queue.service.ts | 62 +++++--- .../services/whmcs-request-queue.service.ts | 48 +++++-- .../security/controllers/csrf.controller.ts | 1 - .../whmcs/services/whmcs-invoice.service.ts | 24 +++- .../whmcs/services/whmcs-payment.service.ts | 18 ++- .../services/invoice-transformer.service.ts | 7 +- .../services/payment-transformer.service.ts | 22 +-- .../whmcs-transformer-orchestrator.service.ts | 4 +- .../whmcs/types/whmcs-api.types.ts | 28 ++-- .../modules/auth/application/auth.facade.ts | 12 +- .../src/modules/auth/auth-admin.controller.ts | 132 ------------------ apps/bff/src/modules/auth/auth.module.ts | 13 +- .../infra/token/token-blacklist.service.ts | 2 +- .../workflows/password-workflow.service.ts | 4 +- .../workflows/signup-workflow.service.ts | 4 +- .../auth/presentation/http/auth.controller.ts | 34 +++-- .../presentation/http/guards/admin.guard.ts | 21 --- .../guards/failed-login-throttle.guard.ts | 2 +- .../http/guards/global-auth.guard.ts | 4 +- .../order-fulfillment-orchestrator.service.ts | 69 +++++---- .../order-fulfillment-validator.service.ts | 5 +- apps/bff/src/types/rate-limiter-flexible.d.ts | 40 ++++++ .../billing/components/PaymentMethodCard.tsx | 20 ++- .../PaymentMethodCard/PaymentMethodCard.tsx | 33 ++--- .../src/features/billing/hooks/useBilling.ts | 21 ++- .../catalog/components/base/PaymentForm.tsx | 4 +- apps/portal/src/lib/api/runtime/client.ts | 17 ++- docs/AUTH-MODULE-ARCHITECTURE.md | 2 - docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md | 54 +------ packages/domain/src/entities/payment.ts | 18 +-- .../domain/src/validation/api/requests.ts | 3 +- .../domain/src/validation/shared/entities.ts | 35 +++-- 35 files changed, 374 insertions(+), 422 deletions(-) delete mode 100644 apps/bff/src/modules/auth/auth-admin.controller.ts delete mode 100644 apps/bff/src/modules/auth/presentation/http/guards/admin.guard.ts create mode 100644 apps/bff/src/types/rate-limiter-flexible.d.ts diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index 536d8e00..68dcc9ff 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -119,7 +119,7 @@ export async function bootstrap(): Promise { // Global exception filters app.useGlobalFilters( new AuthErrorFilter(app.get(Logger)), // Handle auth errors first - new GlobalExceptionFilter(app.get(Logger), configService, app.get(SecureErrorMapperService)) // Handle all other errors + new GlobalExceptionFilter(app.get(Logger), app.get(SecureErrorMapperService)) // Handle all other errors ); // Global authentication guard will be registered via APP_GUARD provider in AuthModule diff --git a/apps/bff/src/core/database/services/distributed-transaction.service.ts b/apps/bff/src/core/database/services/distributed-transaction.service.ts index 817adb55..7a651019 100644 --- a/apps/bff/src/core/database/services/distributed-transaction.service.ts +++ b/apps/bff/src/core/database/services/distributed-transaction.service.ts @@ -116,7 +116,7 @@ export class DistributedTransactionService { timeout, }); - const stepResults: StepResultMap = {}; + const stepResults: StepResultMap = {} as StepResultMap; const executedSteps: string[] = []; const failedSteps: string[] = []; @@ -133,7 +133,8 @@ export class DistributedTransactionService { const result = await this.executeStepWithTimeout(step, timeout); const stepDuration = Date.now() - stepStartTime; - stepResults[step.id] = result; + const key = step.id as keyof StepResultMap; + Reflect.set(stepResults, key, result); executedSteps.push(step.id); this.logger.debug(`Step completed: ${step.id} [${transactionId}]`, { @@ -197,12 +198,7 @@ export class DistributedTransactionService { }); // Execute rollbacks for completed steps - const rollbacksExecuted = await this.executeRollbacks( - steps, - executedSteps, - stepResults, - transactionId - ); + const rollbacksExecuted = await this.executeRollbacks(steps, executedSteps, transactionId); return { success: false, @@ -314,12 +310,8 @@ export class DistributedTransactionService { if (!dbTransactionResult.success) { // Rollback external operations - await this.executeRollbacks( - externalSteps, - Object.keys(externalResult.stepResults), - externalResult.stepResults, - transactionId - ); + const executedExternalSteps = Object.keys(externalResult.stepResults) as string[]; + await this.executeRollbacks(externalSteps, executedExternalSteps, transactionId); throw new Error(dbTransactionResult.error || "Database transaction failed"); } @@ -339,7 +331,7 @@ export class DistributedTransactionService { duration, stepsExecuted: externalResult?.stepsExecuted || 0, stepsRolledBack: 0, - stepResults: (externalResult?.stepResults ?? {}) as TStepResults, + stepResults: (externalResult?.stepResults ?? ({} as StepResultMap)) as TStepResults, failedSteps: externalResult?.failedSteps || [], }; } catch (error) { @@ -357,7 +349,7 @@ export class DistributedTransactionService { duration, stepsExecuted: 0, stepsRolledBack: 0, - stepResults: {}, + stepResults: {} as TStepResults, failedSteps: [], }; } @@ -379,8 +371,7 @@ export class DistributedTransactionService { private async executeRollbacks( steps: TSteps, - executedSteps: string[], - _stepResults: Partial>, + executedSteps: readonly string[], transactionId: string ): Promise { this.logger.warn(`Executing rollbacks for ${executedSteps.length} steps [${transactionId}]`); diff --git a/apps/bff/src/core/database/services/transaction.service.ts b/apps/bff/src/core/database/services/transaction.service.ts index 50ab8101..6f601446 100644 --- a/apps/bff/src/core/database/services/transaction.service.ts +++ b/apps/bff/src/core/database/services/transaction.service.ts @@ -255,7 +255,9 @@ export class TransactionService { ); } - private enhanceContext(context: TransactionContext): TransactionContext { + private enhanceContext( + context: TransactionContext + ): TransactionContext & TransactionContextHelpers { const helpers: TransactionContextHelpers = { addOperation: (description: string) => { context.operations.push(`${new Date().toISOString()}: ${description}`); diff --git a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts index 8869653e..ef4a6cb7 100644 --- a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts +++ b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts @@ -1,7 +1,6 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import PQueue from "p-queue"; export interface SalesforceQueueMetrics { totalRequests: number; completedRequests: number; @@ -39,10 +38,29 @@ export interface SalesforceRequestOptions { * - Timeout: 10 minutes per request * - Rate limiting: Conservative 120 requests per minute (2 RPS) */ +type PQueueCtor = new (options: { + concurrency?: number; + interval?: number; + intervalCap?: number; + timeout?: number; + throwOnTimeout?: boolean; + carryoverConcurrencyCount?: boolean; +}) => PQueueInstance; + +interface PQueueInstance { + add(fn: () => Promise, options?: { priority?: number }): Promise; + clear(): void; + onIdle(): Promise; + on(event: "add" | "next" | "idle" | "error", listener: (...args: unknown[]) => void): void; + size: number; + pending: number; +} + @Injectable() export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDestroy { - private standardQueue: PQueue | null = null; - private longRunningQueue: PQueue | null = null; + private pQueueCtor: PQueueCtor | null = null; + private standardQueue: PQueueInstance | null = null; + private longRunningQueue: PQueueInstance | null = null; private readonly metrics: SalesforceQueueMetrics = { totalRequests: 0, completedRequests: 0, @@ -66,8 +84,20 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest this.dailyUsageResetTime = this.getNextDayReset(); } - private ensureQueuesInitialized(): { standardQueue: PQueue; longRunningQueue: PQueue } { + private async loadPQueue(): Promise { + if (!this.pQueueCtor) { + const module = await import("p-queue"); + this.pQueueCtor = module.default as PQueueCtor; + } + return this.pQueueCtor; + } + + private async ensureQueuesInitialized(): Promise<{ + standardQueue: PQueueInstance; + longRunningQueue: PQueueInstance; + }> { if (!this.standardQueue || !this.longRunningQueue) { + const PQueue = await this.loadPQueue(); const concurrency = this.configService.get("SF_QUEUE_CONCURRENCY", 15); const longRunningConcurrency = this.configService.get( "SF_QUEUE_LONG_RUNNING_CONCURRENCY", @@ -109,14 +139,11 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest throw new Error("Failed to initialize Salesforce queues"); } - return { - standardQueue, - longRunningQueue, - }; + return { standardQueue, longRunningQueue }; } - onModuleInit() { - this.ensureQueuesInitialized(); + async onModuleInit() { + await this.ensureQueuesInitialized(); const concurrency = this.configService.get("SF_QUEUE_CONCURRENCY", 15); const longRunningConcurrency = this.configService.get( "SF_QUEUE_LONG_RUNNING_CONCURRENCY", @@ -171,7 +198,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest requestFn: () => Promise, options: SalesforceRequestOptions = {} ): Promise { - const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized(); + const { standardQueue, longRunningQueue } = await this.ensureQueuesInitialized(); // Check daily API usage this.checkDailyUsage(); @@ -194,7 +221,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest }); try { - const result = await queue.add( + const result = (await queue.add( async () => { const waitTime = Date.now() - startTime; this.recordWaitTime(waitTime); @@ -247,7 +274,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest { priority: options.priority ?? 0, } - ); + )) as T; return result; } catch (error) { @@ -341,7 +368,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest * Clear the queues (emergency use only) */ async clearQueues(): Promise { - const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized(); + const { standardQueue, longRunningQueue } = await this.ensureQueuesInitialized(); this.logger.warn("Clearing Salesforce request queues", { standardQueueSize: standardQueue.size, @@ -367,7 +394,8 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - return await requestFn(); + const result = await requestFn(); + return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -446,7 +474,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest this.logger.debug("Salesforce standard queue is idle"); this.updateQueueMetrics(); }); - this.standardQueue.on("error", (error: Error) => { + this.standardQueue.on("error", (error: unknown) => { this.logger.error("Salesforce standard queue error", { error: error instanceof Error ? error.message : String(error), }); @@ -458,7 +486,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest this.logger.debug("Salesforce long-running queue is idle"); this.updateQueueMetrics(); }); - this.longRunningQueue.on("error", (error: Error) => { + this.longRunningQueue.on("error", (error: unknown) => { this.logger.error("Salesforce long-running queue error", { error: error instanceof Error ? error.message : String(error), }); diff --git a/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts index ab9ca77c..9e8f3b6c 100644 --- a/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts +++ b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts @@ -1,7 +1,24 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import PQueue from "p-queue"; + +type PQueueCtor = new (options: { + concurrency?: number; + interval?: number; + intervalCap?: number; + timeout?: number; + throwOnTimeout?: boolean; + carryoverConcurrencyCount?: boolean; +}) => PQueueInstance; + +interface PQueueInstance { + add(fn: () => Promise, options?: { priority?: number }): Promise; + clear(): void; + onIdle(): Promise; + on(event: "add" | "next" | "idle" | "error", listener: (...args: unknown[]) => void): void; + size: number; + pending: number; +} export interface WhmcsQueueMetrics { totalRequests: number; completedRequests: number; @@ -37,7 +54,8 @@ export interface WhmcsRequestOptions { */ @Injectable() export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { - private queue: PQueue | null = null; + private pQueueCtor: PQueueCtor | null = null; + private queue: PQueueInstance | null = null; private readonly metrics: WhmcsQueueMetrics = { totalRequests: 0, completedRequests: 0, @@ -57,8 +75,17 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { private readonly configService: ConfigService ) {} - private ensureQueueInitialized(): PQueue { + private async loadPQueue(): Promise { + if (!this.pQueueCtor) { + const module = await import("p-queue"); + this.pQueueCtor = module.default as PQueueCtor; + } + return this.pQueueCtor; + } + + private async ensureQueueInitialized(): Promise { if (!this.queue) { + const PQueue = await this.loadPQueue(); const concurrency = this.configService.get("WHMCS_QUEUE_CONCURRENCY", 15); const intervalCap = this.configService.get("WHMCS_QUEUE_INTERVAL_CAP", 300); const timeout = this.configService.get("WHMCS_QUEUE_TIMEOUT_MS", 30000); @@ -84,8 +111,8 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { return this.queue; } - onModuleInit() { - this.ensureQueueInitialized(); + async onModuleInit() { + await this.ensureQueueInitialized(); const concurrency = this.configService.get("WHMCS_QUEUE_CONCURRENCY", 15); const intervalCap = this.configService.get("WHMCS_QUEUE_INTERVAL_CAP", 300); const timeout = this.configService.get("WHMCS_QUEUE_TIMEOUT_MS", 30000); @@ -117,7 +144,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { * Execute a WHMCS API request through the queue */ async execute(requestFn: () => Promise, options: WhmcsRequestOptions = {}): Promise { - const queue = this.ensureQueueInitialized(); + const queue = await this.ensureQueueInitialized(); const startTime = Date.now(); const requestId = this.generateRequestId(); @@ -132,7 +159,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { }); try { - const result = await queue.add( + const result = (await queue.add( async () => { const waitTime = Date.now() - startTime; this.recordWaitTime(waitTime); @@ -174,7 +201,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { { priority: options.priority ?? 0, } - ); + )) as T; return result; } catch (error) { @@ -264,7 +291,8 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - return await requestFn(); + const result = await requestFn(); + return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -307,7 +335,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { this.updateQueueMetrics(); }); - this.queue.on("error", (error: Error) => { + this.queue.on("error", (error: unknown) => { this.logger.error("WHMCS queue error", { error: error instanceof Error ? error.message : String(error), }); diff --git a/apps/bff/src/core/security/controllers/csrf.controller.ts b/apps/bff/src/core/security/controllers/csrf.controller.ts index 63d16df2..d0b91bed 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.ts @@ -6,7 +6,6 @@ import { CsrfService } from "../services/csrf.service"; type AuthenticatedRequest = Request & { user?: { id: string; sessionId?: string }; sessionID?: string; - cookies: Record; }; @Controller("security/csrf") diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 32df45b8..5cc5f04a 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -1,7 +1,11 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; -import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; +import { Invoice, InvoiceList } from "@customer-portal/domain"; +import { + invoiceListSchema, + invoiceSchema, +} from "@customer-portal/domain/validation/shared/entities"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; @@ -62,7 +66,7 @@ export class WhmcsInvoiceService { const response = await this.connectionService.getInvoices(params); const transformed = this.transformInvoicesResponse(response, clientId, page, limit); - const result = invoiceListSchema.parse(transformed); + const result = invoiceListSchema.parse(transformed as unknown); // Cache the result await this.cacheService.setInvoicesList(userId, page, limit, status, result); @@ -205,8 +209,8 @@ export class WhmcsInvoiceService { for (const whmcsInvoice of response.invoices.invoice) { try { const transformed = this.invoiceTransformer.transformInvoice(whmcsInvoice); - const parsed = invoiceSchema.parse(transformed); - invoices.push(parsed as any); + const parsed = invoiceSchema.parse(transformed as unknown); + invoices.push(parsed); } catch (error) { this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { error: getErrorMessage(error), @@ -312,14 +316,22 @@ export class WhmcsInvoiceService { | "Cancelled" | "Refunded" | "Collections" - | "Payment Pending"; + | "Payment Pending" + | "Overdue"; dueDate?: Date; notes?: string; }): Promise<{ success: boolean; message?: string }> { try { + let statusForUpdate: WhmcsUpdateInvoiceParams["status"]; + if (params.status === "Payment Pending") { + statusForUpdate = "Unpaid"; + } else { + statusForUpdate = params.status; + } + const whmcsParams: WhmcsUpdateInvoiceParams = { invoiceid: params.invoiceId, - status: params.status, + status: statusForUpdate, duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined, notes: params.notes, }; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index fd2b09c0..9cb4559a 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -48,8 +48,16 @@ export class WhmcsPaymentService { clientid: clientId, }); - // Use consolidated array shape - const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response?.paymethods) + if (!response || response.result !== "success") { + const message = response?.message ?? "GetPayMethods call failed"; + this.logger.error("WHMCS GetPayMethods returned error", { + clientId, + response, + }); + throw new Error(message); + } + + const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods) ? response.paymethods : []; @@ -69,9 +77,9 @@ export class WhmcsPaymentService { .filter((method): method is PaymentMethod => method !== null); // Mark the first method as default (per product decision) - methods = methods.map((m, i) => - i === 0 ? { ...m, isDefault: true } : { ...m, isDefault: false } - ); + if (methods.length > 0) { + methods = methods.map((m, index) => ({ ...m, isDefault: index === 0 })); + } const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length }; if (!options?.fresh) { diff --git a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts index cc6a47c0..8a3a0d39 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts @@ -168,14 +168,17 @@ export class InvoiceTransformerService { "Draft", "Unpaid", "Paid", + "Pending", "Cancelled", "Refunded", "Collections", "Overdue", ]; - if (allowed.includes(status as Invoice["status"])) { - return status as Invoice["status"]; + const normalizedStatus = status === "Payment Pending" ? "Pending" : status; + + if (allowed.includes(normalizedStatus as Invoice["status"])) { + return normalizedStatus as Invoice["status"]; } throw new Error(`Unsupported WHMCS invoice status: ${status}`); diff --git a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts index 0ee269bb..03f9e551 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts @@ -49,18 +49,18 @@ export class PaymentTransformerService { id: whmcsPayMethod.id, type: whmcsPayMethod.type, description: whmcsPayMethod.description, - gatewayName: whmcsPayMethod.gateway_name ?? undefined, + gatewayName: whmcsPayMethod.gateway_name || undefined, + contactType: whmcsPayMethod.contact_type || undefined, + contactId: whmcsPayMethod.contact_id ?? undefined, + cardLastFour: whmcsPayMethod.card_last_four || undefined, + expiryDate: whmcsPayMethod.expiry_date || undefined, + startDate: whmcsPayMethod.start_date || undefined, + issueNumber: whmcsPayMethod.issue_number || undefined, + cardType: whmcsPayMethod.card_type || undefined, + remoteToken: whmcsPayMethod.remote_token || undefined, + lastUpdated: whmcsPayMethod.last_updated || undefined, + bankName: whmcsPayMethod.bank_name || undefined, isDefault: false, - lastFour: whmcsPayMethod.card_last_four ?? undefined, - expiryDate: whmcsPayMethod.expiry_date ?? undefined, - bankName: whmcsPayMethod.bank_name ?? undefined, - accountType: whmcsPayMethod.account_type ?? undefined, - remoteToken: whmcsPayMethod.remote_token ?? undefined, - ccType: whmcsPayMethod.card_type ?? undefined, - cardBrand: whmcsPayMethod.card_type ?? undefined, - billingContactId: whmcsPayMethod.billing_contact_id ?? undefined, - createdAt: whmcsPayMethod.created_at ?? undefined, - updatedAt: whmcsPayMethod.updated_at ?? undefined, }; if (!this.validator.validatePaymentMethod(transformed)) { diff --git a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts index 3721fbdf..3acc1659 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts @@ -126,7 +126,7 @@ export class WhmcsTransformerOrchestratorService { } catch (error) { this.logger.error("Payment method transformation failed in orchestrator", { error: DataUtils.toErrorMessage(error), - payMethodId: whmcsPayMethod?.id || whmcsPayMethod?.paymethodid, + payMethodId: whmcsPayMethod?.id, }); throw error; } @@ -141,7 +141,7 @@ export class WhmcsTransformerOrchestratorService { } catch (error) { this.logger.error("Payment method transformation failed in orchestrator", { error: DataUtils.toErrorMessage(error), - payMethodId: whmcsPayMethod?.id || whmcsPayMethod?.paymethodid, + payMethodId: whmcsPayMethod?.id, }); throw error; } diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index ae8f78af..0288ecfd 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -297,31 +297,32 @@ export interface WhmcsCatalogProductsResponse { // Payment Method Types export interface WhmcsPaymentMethod { id: number; - paymethodid?: number; type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount"; description: string; gateway_name?: string; + contact_type?: string; + contact_id?: number; card_last_four?: string; expiry_date?: string; - bank_name?: string; - account_type?: string; - remote_token?: string; + start_date?: string; + issue_number?: string; card_type?: string; - billing_contact_id?: number; - created_at?: string; - updated_at?: string; + remote_token?: string; + last_updated?: string; + bank_name?: string; } export interface WhmcsPayMethodsResponse { - // Consolidated (preferred) response shape for GetPayMethods - paymethods: WhmcsPaymentMethod[]; - totalresults?: number; + result: "success" | "error"; + clientid?: number | string; + paymethods?: WhmcsPaymentMethod[]; + message?: string; } -export interface WhmcsGetPayMethodsParams { +export interface WhmcsGetPayMethodsParams extends Record { clientid: number; paymethodid?: number; - [key: string]: unknown; + type?: "BankAccount" | "CreditCard"; } // Payment Gateway Types @@ -353,7 +354,8 @@ export interface WhmcsCreateInvoiceParams { | "Cancelled" | "Refunded" | "Collections" - | "Overdue"; + | "Overdue" + | "Payment Pending"; sendnotification?: boolean; paymentmethod?: string; taxrate?: number; diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index d1591627..e3f9e033 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -20,12 +20,12 @@ import { import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { PrismaService } from "@bff/infra/database/prisma.service"; -import { TokenBlacklistService } from "@bff/modules/auth/infra/token/token-blacklist.service"; -import { AuthTokenService } from "@bff/modules/auth/infra/token/token.service"; -import { AuthRateLimitService } from "@bff/modules/auth/infra/rate-limiting/auth-rate-limit.service"; -import { SignupWorkflowService } from "@bff/modules/auth/infra/workflows/signup-workflow.service"; -import { PasswordWorkflowService } from "@bff/modules/auth/infra/workflows/password-workflow.service"; -import { WhmcsLinkWorkflowService } from "@bff/modules/auth/infra/workflows/whmcs-link-workflow.service"; +import { TokenBlacklistService } from "../infra/token/token-blacklist.service"; +import { AuthTokenService } from "../infra/token/token.service"; +import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service"; +import { SignupWorkflowService } from "../infra/workflows/workflows/signup-workflow.service"; +import { PasswordWorkflowService } from "../infra/workflows/workflows/password-workflow.service"; +import { WhmcsLinkWorkflowService } from "../infra/workflows/workflows/whmcs-link-workflow.service"; import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; @Injectable() diff --git a/apps/bff/src/modules/auth/auth-admin.controller.ts b/apps/bff/src/modules/auth/auth-admin.controller.ts deleted file mode 100644 index 267653fe..00000000 --- a/apps/bff/src/modules/auth/auth-admin.controller.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - Controller, - Get, - Post, - Param, - UseGuards, - Query, - BadRequestException, - UsePipes, -} from "@nestjs/common"; -import { AdminGuard } from "./guards/admin.guard"; -import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; -import { UsersService } from "@bff/modules/users/users.service"; -import { TokenMigrationService } from "@bff/modules/auth/infra/token/token-migration.service"; -import { ZodValidationPipe } from "@bff/core/validation"; -import { - auditLogQuerySchema, - dryRunQuerySchema, - type AuditLogQuery, - type DryRunQuery, -} from "@customer-portal/domain"; -import { z } from "zod"; - -@UseGuards(AdminGuard) -@Controller("auth/admin") -export class AuthAdminController { - constructor( - private auditService: AuditService, - private usersService: UsersService, - private tokenMigrationService: TokenMigrationService - ) {} - - @Get("audit-logs") - @UsePipes(new ZodValidationPipe(auditLogQuerySchema)) - async getAuditLogs(@Query() query: AuditLogQuery) { - const { logs, total } = await this.auditService.getAuditLogs({ - page: query.page, - limit: query.limit, - action: query.action as AuditAction | undefined, - userId: query.userId, - }); - - return { - logs, - pagination: { - page: query.page, - limit: query.limit, - total, - totalPages: Math.ceil(total / query.limit), - }, - }; - } - - @Post("unlock-account/:userId") - async unlockAccount(@Param("userId") userId: string) { - const user = await this.usersService.findById(userId); - if (!user) { - throw new BadRequestException("User not found"); - } - - await this.usersService.update(userId, { - failedLoginAttempts: 0, - lockedUntil: null, - }); - - await this.auditService.log({ - userId, - action: AuditAction.ACCOUNT_UNLOCKED, - resource: "auth", - details: { adminAction: true, email: user.email }, - success: true, - }); - - return { message: "Account unlocked successfully" }; - } - - @Get("security-stats") - async getSecurityStats() { - return this.auditService.getSecurityStats(); - } - - @Get("token-migration/status") - async getTokenMigrationStatus() { - return this.tokenMigrationService.getMigrationStatus(); - } - - @Post("token-migration/run") - @UsePipes(new ZodValidationPipe(dryRunQuerySchema)) - async runTokenMigration(@Query() query: DryRunQuery) { - const isDryRun = query.dryRun ?? true; - const stats = await this.tokenMigrationService.migrateExistingTokens(isDryRun); - - await this.auditService.log({ - action: AuditAction.SYSTEM_MAINTENANCE, - resource: "auth", - details: { - operation: "token_migration", - dryRun: isDryRun, - stats, - }, - success: true, - }); - - return { - message: isDryRun ? "Migration dry run completed" : "Migration completed", - stats, - }; - } - - @Post("token-migration/cleanup") - @UsePipes(new ZodValidationPipe(dryRunQuerySchema)) - async cleanupOrphanedTokens(@Query() query: DryRunQuery) { - const isDryRun = query.dryRun ?? true; - const stats = await this.tokenMigrationService.cleanupOrphanedTokens(isDryRun); - - await this.auditService.log({ - action: AuditAction.SYSTEM_MAINTENANCE, - resource: "auth", - details: { - operation: "token_cleanup", - dryRun: isDryRun, - stats, - }, - success: true, - }); - - return { - message: isDryRun ? "Cleanup dry run completed" : "Cleanup completed", - stats, - }; - } -} diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 3a6cdac9..4e2068e5 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -5,7 +5,6 @@ import { ConfigService } from "@nestjs/config"; import { APP_GUARD } from "@nestjs/core"; import { AuthFacade } from "./application/auth.facade"; import { AuthController } from "./presentation/http/auth.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"; @@ -15,10 +14,9 @@ import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard"; import { TokenBlacklistService } from "./infra/token/token-blacklist.service"; import { EmailModule } from "@bff/infra/email/email.module"; import { AuthTokenService } from "./infra/token/token.service"; -import { TokenMigrationService } from "./infra/token/token-migration.service"; -import { SignupWorkflowService } from "./infra/workflows/signup-workflow.service"; -import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service"; -import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service"; +import { SignupWorkflowService } from "./infra/workflows/workflows/signup-workflow.service"; +import { PasswordWorkflowService } from "./infra/workflows/workflows/password-workflow.service"; +import { WhmcsLinkWorkflowService } from "./infra/workflows/workflows/whmcs-link-workflow.service"; import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./presentation/http/interceptors/login-result.interceptor"; import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service"; @@ -38,14 +36,13 @@ import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.serv IntegrationsModule, EmailModule, ], - controllers: [AuthController, AuthAdminController], + controllers: [AuthController], providers: [ AuthFacade, JwtStrategy, LocalStrategy, TokenBlacklistService, AuthTokenService, - TokenMigrationService, SignupWorkflowService, PasswordWorkflowService, WhmcsLinkWorkflowService, @@ -57,6 +54,6 @@ import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.serv useClass: GlobalAuthGuard, }, ], - exports: [AuthFacade, TokenBlacklistService, AuthTokenService, TokenMigrationService], + exports: [AuthFacade, TokenBlacklistService, AuthTokenService], }) export class AuthModule {} diff --git a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts index aa042924..352afc2f 100644 --- a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts @@ -4,7 +4,7 @@ import { JwtService } from "@nestjs/jwt"; import { createHash } from "crypto"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; -import { parseJwtExpiry } from "../utils/jwt-expiry.util"; +import { parseJwtExpiry } from "../../utils/jwt-expiry.util"; @Injectable() export class TokenBlacklistService { diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts index b457eabe..cd4132e2 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts @@ -8,8 +8,8 @@ import { UsersService } from "@bff/modules/users/users.service"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; import { EmailService } from "@bff/infra/email/email.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { AuthTokenService } from "../token.service"; -import { AuthRateLimitService } from "../auth-rate-limit.service"; +import { AuthTokenService } from "../../token/token.service"; +import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service"; import { type AuthTokens, type UserProfile } from "@customer-portal/domain"; import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index f1846249..66a57e36 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -15,8 +15,8 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { PrismaService } from "@bff/infra/database/prisma.service"; -import { AuthTokenService } from "../token.service"; -import { AuthRateLimitService } from "../auth-rate-limit.service"; +import { AuthTokenService } from "../../token/token.service"; +import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { signupRequestSchema, diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index e8051f50..212b46e0 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -17,7 +17,7 @@ import { LocalAuthGuard } from "./guards/local-auth.guard"; import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; -import { Public } from "./decorators/public.decorator"; +import { Public } from "../../decorators/public.decorator"; import { ZodValidationPipe } from "@bff/core/validation"; // Import Zod schemas from domain @@ -49,19 +49,35 @@ import type { AuthTokens } from "@customer-portal/domain"; type RequestWithCookies = Request & { cookies?: Record }; -const EXTRACT_BEARER = (req: RequestWithCookies): string | undefined => { - const authHeader = req.headers?.authorization; - if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) { - return authHeader.slice(7); +const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => { + const rawHeader = req.headers?.authorization; + + if (typeof rawHeader === "string") { + return rawHeader; } - if (Array.isArray(authHeader) && authHeader.length > 0 && authHeader[0]?.startsWith("Bearer ")) { - return authHeader[0]?.slice(7); + + if (Array.isArray(rawHeader)) { + const headerValues: string[] = rawHeader; + for (const candidate of headerValues) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate; + } + } + } + + return undefined; +}; + +const extractBearerToken = (req: RequestWithCookies): string | undefined => { + const authHeader = resolveAuthorizationHeader(req); + if (authHeader && authHeader.startsWith("Bearer ")) { + return authHeader.slice(7); } return undefined; }; const extractTokenFromRequest = (req: RequestWithCookies): string | undefined => { - const headerToken = EXTRACT_BEARER(req); + const headerToken = extractBearerToken(req); if (headerToken) { return headerToken; } @@ -225,7 +241,7 @@ export class AuthController { @Post("request-password-reset") @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) - async requestPasswordReset(@Body() body: PasswordResetRequestInput) { + async requestPasswordReset(@Body() body: PasswordResetRequestInput, @Req() req: Request) { await this.authFacade.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/admin.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/admin.guard.ts deleted file mode 100644 index 991dc4f1..00000000 --- a/apps/bff/src/modules/auth/presentation/http/guards/admin.guard.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; -import { UserRole } from "@prisma/client"; -import type { Request } from "express"; - -@Injectable() -export class AdminGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const user = request.user; - - if (!user) { - throw new ForbiddenException("Authentication required"); - } - - if (user.role !== UserRole.ADMIN) { - throw new ForbiddenException("Admin access required"); - } - - return true; - } -} diff --git a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts index 18b20dda..3fff3f94 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts @@ -1,6 +1,6 @@ import { Injectable, ExecutionContext } from "@nestjs/common"; import type { Request } from "express"; -import { AuthRateLimitService } from "@bff/modules/auth/infra/rate-limiting/auth-rate-limit.service"; +import { AuthRateLimitService } from "../../../infra/rate-limiting/auth-rate-limit.service"; @Injectable() export class FailedLoginThrottleGuard { diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index c1c0bb35..8d6d5186 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -11,8 +11,8 @@ import { ExtractJwt } from "passport-jwt"; import type { Request } from "express"; -import { TokenBlacklistService } from "@bff/modules/auth/infra/token/token-blacklist.service"; -import { IS_PUBLIC_KEY } from "@bff/modules/auth/decorators/public.decorator"; +import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service"; +import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator"; type RequestWithCookies = Request & { cookies?: Record }; diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 5a5d8ce4..389c7f26 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -124,6 +124,10 @@ export class OrderFulfillmentOrchestrator { } // Step 3: Execute the main fulfillment workflow as a distributed transaction + let mappingResult: OrderItemMappingResult | undefined; + let whmcsCreateResult: { orderId: number } | undefined; + let whmcsAcceptResult: WhmcsOrderResult | undefined; + const fulfillmentResult = await this.distributedTransactionService.executeDistributedTransaction( [ @@ -146,14 +150,22 @@ export class OrderFulfillmentOrchestrator { }, critical: true, }, + { + id: "order_details", + description: "Retain order details in context", + execute: () => Promise.resolve(context.orderDetails), + critical: false, + }, { id: "mapping", description: "Map OrderItems to WHMCS format", - execute: async () => { + execute: () => { if (!context.orderDetails) { - throw new Error("Order details are required for mapping"); + return Promise.reject(new Error("Order details are required for mapping")); } - return this.orderWhmcsMapper.mapOrderItemsToWhmcs(context.orderDetails.items); + const result = this.orderWhmcsMapper.mapOrderItemsToWhmcs(context.orderDetails.items); + mappingResult = result; + return Promise.resolve(result); }, critical: true, }, @@ -161,7 +173,9 @@ export class OrderFulfillmentOrchestrator { id: "whmcs_create", description: "Create order in WHMCS", execute: async () => { - const mappingResult = fulfillmentResult.stepResults?.mapping; + if (!context.validation) { + throw new Error("Validation context is missing"); + } if (!mappingResult) { throw new Error("Mapping result is not available"); } @@ -171,8 +185,8 @@ export class OrderFulfillmentOrchestrator { `Provisioned from Salesforce Order ${sfOrderId}` ); - return await this.whmcsOrderService.addOrder({ - clientId: context.validation!.clientId, + const result = await this.whmcsOrderService.addOrder({ + clientId: context.validation.clientId, items: mappingResult.whmcsItems, paymentMethod: "stripe", promoCode: "1st Month Free (Monthly Plan)", @@ -181,21 +195,24 @@ export class OrderFulfillmentOrchestrator { noinvoiceemail: true, noemail: true, }); + + whmcsCreateResult = result; + return result; }, - rollback: async () => { - const createResult = fulfillmentResult.stepResults?.whmcs_create; - if (createResult?.orderId) { + rollback: () => { + if (whmcsCreateResult?.orderId) { // Note: WHMCS doesn't have an automated cancel API // Manual intervention required for order cleanup this.logger.error( "WHMCS order created but fulfillment failed - manual cleanup required", { - orderId: createResult.orderId, + orderId: whmcsCreateResult.orderId, sfOrderId, action: "MANUAL_CLEANUP_REQUIRED", } ); } + return Promise.resolve(); }, critical: true, }, @@ -203,28 +220,33 @@ export class OrderFulfillmentOrchestrator { id: "whmcs_accept", description: "Accept/provision order in WHMCS", execute: async () => { - const createResult = fulfillmentResult.stepResults?.whmcs_create; - if (!createResult?.orderId) { + if (!whmcsCreateResult?.orderId) { throw new Error("WHMCS order ID missing before acceptance step"); } - return await this.whmcsOrderService.acceptOrder(createResult.orderId, sfOrderId); + const result = await this.whmcsOrderService.acceptOrder( + whmcsCreateResult.orderId, + sfOrderId + ); + + whmcsAcceptResult = result; + return result; }, - rollback: async () => { - const acceptResult = fulfillmentResult.stepResults?.whmcs_accept; - if (acceptResult?.orderId) { + rollback: () => { + if (whmcsAcceptResult?.orderId) { // Note: WHMCS doesn't have an automated cancel API for accepted orders // Manual intervention required for service termination this.logger.error( "WHMCS order accepted but fulfillment failed - manual cleanup required", { - orderId: acceptResult.orderId, - serviceIds: acceptResult.serviceIds, + orderId: whmcsAcceptResult.orderId, + serviceIds: whmcsAcceptResult.serviceIds, sfOrderId, action: "MANUAL_SERVICE_TERMINATION_REQUIRED", } ); } + return Promise.resolve(); }, critical: true, }, @@ -238,9 +260,9 @@ export class OrderFulfillmentOrchestrator { orderDetails: context.orderDetails, configurations, }); - return { completed: true }; + return { completed: true as const }; } - return { skipped: true }; + return { skipped: true as const }; }, critical: false, // SIM fulfillment failure shouldn't rollback the entire order }, @@ -249,13 +271,12 @@ export class OrderFulfillmentOrchestrator { description: "Update Salesforce with success", execute: async () => { const fields = this.fieldMapService.getFieldMap(); - const whmcsResult = fulfillmentResult.stepResults?.whmcs_accept; return await this.salesforceService.updateOrder({ Id: sfOrderId, Status: "Completed", [fields.order.activationStatus]: "Activated", - [fields.order.whmcsOrderId]: whmcsResult?.orderId?.toString(), + [fields.order.whmcsOrderId]: whmcsAcceptResult?.orderId?.toString(), }); }, rollback: async () => { @@ -286,8 +307,8 @@ export class OrderFulfillmentOrchestrator { } // Update context with results - context.mappingResult = fulfillmentResult.stepResults?.mapping; - context.whmcsResult = fulfillmentResult.stepResults?.whmcs_accept; + context.mappingResult = mappingResult; + context.whmcsResult = whmcsAcceptResult; this.logger.log("Transactional fulfillment completed successfully", { sfOrderId, diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index 846f1fe3..cc3341e7 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -6,7 +6,6 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SalesforceOrderRecord } from "@customer-portal/domain"; import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map"; -import { orderFulfillmentValidationSchema } from "@customer-portal/domain"; import { sfOrderIdParamSchema } from "@customer-portal/domain"; type OrderStringFieldKey = "activationStatus"; @@ -36,9 +35,11 @@ export class OrderFulfillmentValidator { * Validates SF order, gets client ID, checks payment method, checks idempotency */ async validateFulfillmentRequest( - sfOrderId: string, + rawSfOrderId: string, idempotencyKey: string ): Promise { + const { sfOrderId } = sfOrderIdParamSchema.parse({ sfOrderId: rawSfOrderId }); + this.logger.log("Starting fulfillment validation", { sfOrderId, idempotencyKey, diff --git a/apps/bff/src/types/rate-limiter-flexible.d.ts b/apps/bff/src/types/rate-limiter-flexible.d.ts new file mode 100644 index 00000000..4965db95 --- /dev/null +++ b/apps/bff/src/types/rate-limiter-flexible.d.ts @@ -0,0 +1,40 @@ +import type { Redis } from "ioredis"; + +declare module "rate-limiter-flexible" { + export interface RateLimiterOptions { + storeClient: Redis; + points: number; + duration: number; + blockDuration?: number; + keyPrefix?: string; + inmemoryBlockOnConsumed?: number; + insuranceLimiter?: RateLimiterMemory; + } + + export class RateLimiterRes { + remainingPoints: number; + consumedPoints: number; + msBeforeNext: number; + constructor(data: { + remainingPoints: number; + consumedPoints: number; + msBeforeNext: number; + }); + } + + export class RateLimiterRedis { + readonly points: number; + constructor(options: RateLimiterOptions); + consume(key: string, points?: number): Promise; + delete(key: string): Promise; + penalty(key: string, points?: number): Promise; + reward(key: string, points?: number): Promise; + } + + export class RateLimiterMemory { + readonly points: number; + constructor(options: RateLimiterOptions); + consume(key: string, points?: number): Promise; + delete(key: string): Promise; + } +} diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx index 898a1839..2e4ed9d7 100644 --- a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx +++ b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx @@ -2,7 +2,6 @@ import { CreditCardIcon, BanknotesIcon, DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import type { PaymentMethod } from "@customer-portal/domain"; -import { StatusPill } from "@/components/atoms/status-pill"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -53,16 +52,15 @@ const isBankAccount = (type: PaymentMethod["type"]) => type === "BankAccount" || type === "RemoteBankAccount"; const formatCardDisplay = (method: PaymentMethod) => { - // Show ***** and last 4 digits for any payment method with lastFour - if (method.lastFour) { - return `***** ${method.lastFour}`; + if (method.cardLastFour) { + return `***** ${method.cardLastFour}`; } - + // Fallback based on type if (isCreditCard(method.type)) { - return method.cardBrand ? `${method.cardBrand.toUpperCase()} Card` : "Credit Card"; + return method.cardType ? `${method.cardType.toUpperCase()} Card` : "Credit Card"; } - + if (isBankAccount(method.type)) { return method.bankName || "Bank Account"; } @@ -71,10 +69,10 @@ const formatCardDisplay = (method: PaymentMethod) => { }; const formatCardBrand = (method: PaymentMethod) => { - if (isCreditCard(method.type) && method.cardBrand) { - return method.cardBrand.toUpperCase(); + if (isCreditCard(method.type) && method.cardType) { + return method.cardType.toUpperCase(); } - + if (isBankAccount(method.type) && method.bankName) { return method.bankName; } @@ -96,7 +94,7 @@ export function PaymentMethodCard({ const cardDisplay = formatCardDisplay(paymentMethod); const cardBrand = formatCardBrand(paymentMethod); const expiry = formatExpiry(paymentMethod.expiryDate); - const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardBrand ?? paymentMethod.ccType); + const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardType); return (
( }, ref ) => { - const { - type, - description, - gatewayDisplayName, - isDefault, - lastFour, - expiryDate, - bankName, - accountType, - ccType, - cardBrand, - } = paymentMethod; + const { type, description, gatewayName, isDefault, expiryDate, bankName, cardType, cardLastFour } = + paymentMethod; const formatExpiryDate = (expiry?: string) => { if (!expiry) return null; @@ -92,13 +82,12 @@ const PaymentMethodCard = forwardRef( }; const renderPaymentMethodDetails = () => { - if (type === "BankAccount") { + if (type === "BankAccount" || type === "RemoteBankAccount") { return (
{bankName || "Bank Account"}
- {accountType && {accountType}} - {lastFour && •••• {lastFour}} + {cardLastFour && •••• {cardLastFour}}
); @@ -108,12 +97,12 @@ const PaymentMethodCard = forwardRef( return (
- {cardBrand || ccType || "Credit Card"} - {lastFour && •••• {lastFour}} + {cardType || "Credit Card"} + {cardLastFour && •••• {cardLastFour}}
{formatExpiryDate(expiryDate) && Expires {formatExpiryDate(expiryDate)}} - {gatewayDisplayName && • {gatewayDisplayName}} + {gatewayName && • {gatewayName}}
); @@ -139,7 +128,7 @@ const PaymentMethodCard = forwardRef( "flex-shrink-0 rounded-lg flex items-center justify-center", compact ? "w-8 h-8 bg-gray-100" : "w-10 h-10 bg-gray-100", type === "CreditCard" || type === "RemoteCreditCard" - ? getCardBrandColor(cardBrand) + ? getCardBrandColor(cardType) : "text-gray-600" )} > @@ -151,7 +140,7 @@ const PaymentMethodCard = forwardRef( {renderPaymentMethodDetails()} {/* Description if different from generated details */} - {description && !description.includes(lastFour || "") && ( + {description && !description.includes(cardLastFour || "") && (
{description}
)} @@ -184,8 +173,8 @@ const PaymentMethodCard = forwardRef(
{/* Gateway info for compact view */} - {compact && gatewayDisplayName && ( -
via {gatewayDisplayName}
+ {compact && gatewayName && ( +
via {gatewayName}
)} ); diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index 0293117d..1602e3f9 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -10,6 +10,7 @@ import { } from "@tanstack/react-query"; import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/api"; import type { InvoiceQueryParams } from "@/lib/api/types"; +import type { QueryParams } from "@/lib/api/runtime/client"; import type { Invoice, InvoiceList, @@ -85,10 +86,24 @@ type SsoLinkMutationOptions = UseMutationOptions< { invoiceId: number; target?: "view" | "download" | "pay" } >; +const toQueryParams = (params: InvoiceQueryParams): QueryParams => { + const query: QueryParams = {}; + for (const [key, value] of Object.entries(params)) { + if (value === undefined) { + continue; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + query[key] = value; + } + } + return query; +}; + async function fetchInvoices(params?: InvoiceQueryParams): Promise { + const query = params ? toQueryParams(params) : undefined; const response = await apiClient.GET( "/api/invoices", - params ? { params: { query: params as Record } } : undefined + query ? { params: { query } } : undefined ); const data = getDataOrDefault(response, emptyInvoiceList); const parsed = invoiceListSchema.parse(data); @@ -113,9 +128,9 @@ export function useInvoices( params?: InvoiceQueryParams, options?: InvoicesQueryOptions ): UseQueryResult { - const queryParams = params ? (params as Record) : {}; + const queryKeyParams = params ? { ...params } : undefined; return useQuery({ - queryKey: queryKeys.billing.invoices(queryParams), + queryKey: queryKeys.billing.invoices(queryKeyParams), queryFn: () => fetchInvoices(params), ...options, }); diff --git a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx index 3f9851af..ee313072 100644 --- a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx +++ b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx @@ -90,8 +90,8 @@ export function PaymentForm({ const renderMethod = (method: PaymentMethod) => { const methodId = String(method.id); const isSelected = selectedMethod === methodId; - const label = method.cardBrand - ? `${method.cardBrand.toUpperCase()} ${method.lastFour ? `•••• ${method.lastFour}` : ""}`.trim() + const label = method.cardType + ? `${method.cardType.toUpperCase()} ${method.cardLastFour ? `•••• ${method.cardLastFour}` : ""}`.trim() : (method.description ?? method.type); return ( diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index ad24c0a8..8f9b3eb0 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -13,11 +13,18 @@ export class ApiError extends Error { export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; -type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +export type HttpMethod = + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE" + | "HEAD" + | "OPTIONS"; type PathParams = Record; -type QueryPrimitive = string | number | boolean; -type QueryParams = Record< +export type QueryPrimitive = string | number | boolean; +export type QueryParams = Record< string, QueryPrimitive | QueryPrimitive[] | readonly QueryPrimitive[] | undefined >; @@ -138,11 +145,11 @@ const buildQueryString = (query?: QueryParams): string => { } if (Array.isArray(value)) { - value.forEach(entry => appendPrimitive(key, entry)); + (value as readonly QueryPrimitive[]).forEach(entry => appendPrimitive(key, entry)); continue; } - appendPrimitive(key, value); + appendPrimitive(key, value as QueryPrimitive); } return searchParams.toString(); diff --git a/docs/AUTH-MODULE-ARCHITECTURE.md b/docs/AUTH-MODULE-ARCHITECTURE.md index 2a1e327d..e355f915 100644 --- a/docs/AUTH-MODULE-ARCHITECTURE.md +++ b/docs/AUTH-MODULE-ARCHITECTURE.md @@ -44,7 +44,6 @@ modules/auth/ - `GlobalAuthGuard`: wraps Passport JWT, checks blacklist via `TokenBlacklistService`. - `AuthThrottleGuard`: Nest Throttler-based guard for general rate limits. - `FailedLoginThrottleGuard`: uses `AuthRateLimitService` for login attempt limiting. -- `AdminGuard`: ensures authenticated user has admin role (uses Prisma enum). ### Interceptors - `LoginResultInterceptor`: ties into `FailedLoginThrottleGuard` to clear counters on success/failure. @@ -65,7 +64,6 @@ modules/auth/ ### Token (`infra/token`) - `token.service.ts`: issues/rotates access + refresh tokens, enforces Redis-backed refresh token families. - `token-blacklist.service.ts`: stores revoked access tokens. -- `token-migration.service.ts`: helpers for legacy flows. ### Rate Limiting (`infra/rate-limiting/auth-rate-limit.service.ts`) - Built on `rate-limiter-flexible` with Redis storage. diff --git a/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md b/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md index bb145e42..36e46e32 100644 --- a/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md +++ b/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md @@ -98,35 +98,7 @@ This document summarizes the implementation of the Redis-required token flow wit ### 5. Migration Utilities for Existing Keys -**New Service:** `TokenMigrationService` - -- Migrates existing refresh tokens to new per-user structure -- Handles orphaned token cleanup -- Provides migration statistics and status -- Supports dry-run mode for safe testing - -**Admin Endpoints Added:** - -- `GET /auth/admin/token-migration/status`: Check migration status -- `POST /auth/admin/token-migration/run?dryRun=true`: Run migration -- `POST /auth/admin/token-migration/cleanup?dryRun=true`: Cleanup orphaned tokens - -**Migration Features:** - -- Scans existing refresh token families and tokens -- Creates per-user token sets for existing tokens -- Identifies and removes orphaned tokens -- Comprehensive logging and error handling -- Audit trail for all migration operations - -**Files Created:** - -- `apps/bff/src/modules/auth/services/token-migration.service.ts` - -**Files Modified:** - -- `apps/bff/src/modules/auth/auth.module.ts` -- `apps/bff/src/modules/auth/auth-admin.controller.ts` +Legacy helpers (`token-migration.service.ts`) have been removed along with the admin-only migration endpoints. ## 🚀 Deployment Instructions @@ -158,28 +130,7 @@ SF_QUEUE_LONG_RUNNING_TIMEOUT_MS=600000 ### 2. Migration Process -1. **Check Migration Status:** - - ```bash - GET /auth/admin/token-migration/status - ``` - -2. **Run Dry-Run Migration:** - - ```bash - POST /auth/admin/token-migration/run?dryRun=true - ``` - -3. **Execute Actual Migration:** - - ```bash - POST /auth/admin/token-migration/run?dryRun=false - ``` - -4. **Cleanup Orphaned Tokens:** - ```bash - POST /auth/admin/token-migration/cleanup?dryRun=false - ``` +Legacy admin migration endpoints were removed. If migration is needed in the future, plan a manual script or one-off job. ### 3. Feature Flag Rollout @@ -263,7 +214,6 @@ SF_QUEUE_INTERVAL_CAP=800 - [ ] Token migration dry-run and execution - [ ] Per-user token limit enforcement - [ ] Orphaned token cleanup -- [ ] Admin endpoint security (admin-only access) ## 🔄 Rollback Plan diff --git a/packages/domain/src/entities/payment.ts b/packages/domain/src/entities/payment.ts index 63412947..f1976adc 100644 --- a/packages/domain/src/entities/payment.ts +++ b/packages/domain/src/entities/payment.ts @@ -5,17 +5,17 @@ export interface PaymentMethod extends WhmcsEntity { type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual"; description: string; gatewayName?: string; - isDefault?: boolean; - lastFour?: string; + contactType?: string; + contactId?: number; + cardLastFour?: string; expiryDate?: string; - bankName?: string; - accountType?: string; + startDate?: string; + issueNumber?: string; + cardType?: string; remoteToken?: string; - ccType?: string; - cardBrand?: string; - billingContactId?: number; - createdAt?: string; - updatedAt?: string; + lastUpdated?: string; + bankName?: string; + isDefault?: boolean; } export interface PaymentMethodList { diff --git a/packages/domain/src/validation/api/requests.ts b/packages/domain/src/validation/api/requests.ts index 2118456a..5828f130 100644 --- a/packages/domain/src/validation/api/requests.ts +++ b/packages/domain/src/validation/api/requests.ts @@ -14,6 +14,7 @@ import { requiredAddressSchema, genderEnum, } from "../shared/primitives"; +import { invoiceStatusSchema } from "../shared/entities"; const invoiceStatusEnum = z.enum(["Paid", "Unpaid", "Overdue", "Cancelled", "Collections"]); const subscriptionStatusEnum = z.enum([ @@ -269,7 +270,7 @@ export const invoiceItemSchema = z.object({ export const invoiceSchema = z.object({ id: z.number().int().positive(), number: z.string().min(1, "Invoice number is required"), - status: z.string().min(1, "Status is required"), + status: invoiceStatusSchema, currency: z.string().length(3, "Currency must be 3 characters"), currencySymbol: z.string().optional(), total: z.number().nonnegative("Total must be non-negative"), diff --git a/packages/domain/src/validation/shared/entities.ts b/packages/domain/src/validation/shared/entities.ts index 53fa1ef6..71b8b09e 100644 --- a/packages/domain/src/validation/shared/entities.ts +++ b/packages/domain/src/validation/shared/entities.ts @@ -55,15 +55,15 @@ const paymentMethodTypeSchema = z.enum([ "Manual", ]); -const orderStatusSchema = z.enum(["Pending", "Active", "Cancelled", "Fraud"]); -const invoiceStatusSchema = z.enum(tupleFromEnum(INVOICE_STATUS)); -const subscriptionStatusSchema = z.enum(tupleFromEnum(SUBSCRIPTION_STATUS)); -const caseStatusSchema = z.enum(tupleFromEnum(CASE_STATUS)); -const casePrioritySchema = z.enum(tupleFromEnum(CASE_PRIORITY)); -const paymentStatusSchema = z.enum(tupleFromEnum(PAYMENT_STATUS)); -const caseTypeSchema = z.enum(["Question", "Problem", "Feature Request"]); +export const orderStatusSchema = z.enum(["Pending", "Active", "Cancelled", "Fraud"]); +export const invoiceStatusSchema = z.enum(tupleFromEnum(INVOICE_STATUS)); +export const subscriptionStatusSchema = z.enum(tupleFromEnum(SUBSCRIPTION_STATUS)); +export const caseStatusSchema = z.enum(tupleFromEnum(CASE_STATUS)); +export const casePrioritySchema = z.enum(tupleFromEnum(CASE_PRIORITY)); +export const paymentStatusSchema = z.enum(tupleFromEnum(PAYMENT_STATUS)); +export const caseTypeSchema = z.enum(["Question", "Problem", "Feature Request"]); -const subscriptionCycleSchema = z.enum([ +export const subscriptionCycleSchema = z.enum([ "Monthly", "Quarterly", "Semi-Annually", @@ -220,18 +220,17 @@ export const paymentMethodSchema = whmcsEntitySchema.extend({ type: paymentMethodTypeSchema, description: z.string().min(1, "Payment method description is required"), gatewayName: z.string().optional(), - gatewayDisplayName: z.string().optional(), - isDefault: z.boolean().optional(), - lastFour: z.string().length(4, "Last four must be exactly 4 digits").optional(), + contactType: z.string().optional(), + contactId: z.number().int().positive().optional(), + cardLastFour: z.string().length(4, "Last four must be exactly 4 digits").optional(), expiryDate: z.string().optional(), - bankName: z.string().optional(), - accountType: z.string().optional(), + startDate: z.string().optional(), + issueNumber: z.string().optional(), + cardType: z.string().optional(), remoteToken: z.string().optional(), - ccType: z.string().optional(), - cardBrand: z.string().optional(), - billingContactId: z.number().int().positive().optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), + lastUpdated: z.string().optional(), + bankName: z.string().optional(), + isDefault: z.boolean().optional(), }); export const paymentSchema = z.object({