From b206de8dba96fa32648efc6caaddcdd101246f83 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 24 Feb 2026 19:05:30 +0900 Subject: [PATCH] refactor: enterprise-grade cleanup of BFF and domain packages Comprehensive refactoring across 70 files (net -298 lines) improving type safety, error handling, and code organization: - Replace .passthrough()/.catchall(z.unknown()) with .strip() in all Zod schemas - Tighten Record to bounded union types where possible - Replace throw new Error with domain-specific exceptions (OrderException, FulfillmentException, WhmcsOperationException, SalesforceOperationException, etc.) - Split AuthTokenService (625 lines) into TokenGeneratorService and TokenRefreshService with thin orchestrator - Deduplicate FreebitClientService with shared makeRequest() method - Add typed interfaces to WHMCS facade, order service, and fulfillment mapper - Externalize hardcoded config values to ConfigService with env fallbacks - Consolidate duplicate billing cycle enums into shared billingCycleSchema - Standardize logger usage (nestjs-pino @Inject(Logger) everywhere) - Move shared WHMCS number coercion helpers to whmcs-utils/schema.ts --- apps/bff/src/core/config/auth-dev.config.ts | 5 - .../infra/cache/distributed-lock.service.ts | 4 +- .../distributed-transaction.service.ts | 10 +- apps/bff/src/infra/email/queue/email.queue.ts | 4 +- .../services/freebit-client.service.ts | 183 ++---- .../services/freebit-mapper.service.ts | 7 +- .../services/japanpost-connection.service.ts | 36 +- .../events/shared/pubsub.service.ts | 5 +- .../opportunity-cancellation.service.ts | 38 +- .../opportunity-mutation.service.ts | 34 +- .../services/salesforce-account.service.ts | 45 +- .../services/salesforce-case.service.ts | 32 +- .../services/salesforce-order.service.ts | 18 +- .../salesforce-sim-inventory.service.ts | 17 +- .../services/whmcs-error-handler.service.ts | 12 +- .../services/whmcs-http-client.service.ts | 7 +- .../whmcs/facades/whmcs.facade.ts | 48 +- .../whmcs/services/whmcs-currency.service.ts | 25 +- .../whmcs/services/whmcs-order.service.ts | 70 ++- apps/bff/src/modules/auth/auth.module.ts | 4 + .../bff/src/modules/auth/infra/token/index.ts | 2 + .../infra/token/token-generator.service.ts | 214 +++++++ .../infra/token/token-migration.service.ts | 4 +- .../auth/infra/token/token-refresh.service.ts | 336 +++++++++++ .../modules/auth/infra/token/token.service.ts | 533 +----------------- .../workflows/password-workflow.service.ts | 13 +- .../steps/generate-auth-result.step.ts | 4 +- .../workflows/whmcs-link-workflow.service.ts | 10 +- .../whmcs-migration-workflow.service.ts | 17 +- .../http/guards/global-auth.guard.ts | 8 +- .../http/guards/permissions.guard.ts | 10 +- .../notifications/notifications.service.ts | 19 +- .../orders/controllers/checkout.controller.ts | 23 +- .../services/checkout-session.service.ts | 38 ++ .../fulfillment-context-mapper.service.ts | 45 +- .../fulfillment-step-executors.service.ts | 9 +- .../fulfillment-step-factory.service.ts | 13 +- .../orders/services/order-builder.service.ts | 22 +- .../order-fulfillment-orchestrator.service.ts | 5 +- .../services/order-idempotency.service.ts | 13 +- .../services/order-item-builder.service.ts | 5 +- .../services/sim-fulfillment.service.ts | 7 +- .../realtime-connection-limiter.service.ts | 7 +- .../internet-eligibility.service.ts | 15 +- .../workflow/workflow-case-manager.service.ts | 3 +- .../cancellation/cancellation.service.ts | 8 +- .../interfaces/sim-base.interface.ts | 30 +- .../services/mutations/sim-topup.service.ts | 3 +- .../services/sim-orchestrator.service.ts | 6 +- .../services/sim-validation.service.ts | 7 +- .../support/sim-notification.service.ts | 17 +- .../sim-management/sim.controller.ts | 3 +- .../users/infra/user-profile.service.ts | 4 +- .../users/queue/address-reconcile.queue.ts | 6 +- .../verification/residence-card.controller.ts | 3 +- .../billing/providers/whmcs/raw.types.ts | 7 +- packages/domain/checkout/schema.ts | 5 +- packages/domain/common/errors.ts | 1 + .../domain/common/providers/salesforce.ts | 2 + .../common/providers/whmcs-utils/schema.ts | 28 + packages/domain/common/schema.ts | 5 +- .../domain/customer/providers/whmcs/mapper.ts | 2 +- .../customer/providers/whmcs/raw.types.ts | 12 +- packages/domain/customer/schema.ts | 49 +- packages/domain/dashboard/schema.ts | 10 +- .../orders/providers/salesforce/raw.types.ts | 37 +- packages/domain/orders/schema.ts | 2 +- .../payments/providers/whmcs/raw.types.ts | 4 +- packages/domain/payments/schema.ts | 5 +- packages/domain/services/contract.ts | 2 +- .../providers/whmcs/raw.types.ts | 21 +- packages/domain/subscriptions/schema.ts | 18 +- 72 files changed, 1287 insertions(+), 989 deletions(-) create mode 100644 apps/bff/src/modules/auth/infra/token/token-generator.service.ts create mode 100644 apps/bff/src/modules/auth/infra/token/token-refresh.service.ts diff --git a/apps/bff/src/core/config/auth-dev.config.ts b/apps/bff/src/core/config/auth-dev.config.ts index c0f9200a..ca8603d7 100644 --- a/apps/bff/src/core/config/auth-dev.config.ts +++ b/apps/bff/src/core/config/auth-dev.config.ts @@ -40,8 +40,3 @@ export const getDevAuthConfig = (): DevAuthConfig => { skipOtp: isDevelopment && process.env["SKIP_OTP"] === "true", }; }; - -/** - * @deprecated Use getDevAuthConfig() instead to ensure env vars are read after ConfigModule loads - */ -export const devAuthConfig = getDevAuthConfig(); diff --git a/apps/bff/src/infra/cache/distributed-lock.service.ts b/apps/bff/src/infra/cache/distributed-lock.service.ts index ba1495ce..8e008bec 100644 --- a/apps/bff/src/infra/cache/distributed-lock.service.ts +++ b/apps/bff/src/infra/cache/distributed-lock.service.ts @@ -7,7 +7,7 @@ * Uses Redis SET NX PX pattern for atomic lock acquisition with TTL. */ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { Redis } from "ioredis"; import { randomUUID } from "node:crypto"; @@ -99,7 +99,7 @@ export class DistributedLockService { const lock = await this.acquire(key, options); if (!lock) { - throw new Error(`Unable to acquire lock for key: ${key}`); + throw new InternalServerErrorException(`Unable to acquire lock for key: ${key}`); } try { diff --git a/apps/bff/src/infra/database/services/distributed-transaction.service.ts b/apps/bff/src/infra/database/services/distributed-transaction.service.ts index 5ff92372..cabc1c69 100644 --- a/apps/bff/src/infra/database/services/distributed-transaction.service.ts +++ b/apps/bff/src/infra/database/services/distributed-transaction.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { TransactionService, type TransactionOperation } from "./transaction.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -331,7 +331,7 @@ export class DistributedTransactionService { ); if (!externalResult.success) { - throw new Error(externalResult.error || "External operations failed"); + throw new InternalServerErrorException(externalResult.error || "External operations failed"); } this.logger.debug(`Executing database operations [${transactionId}]`); @@ -360,7 +360,7 @@ export class DistributedTransactionService { }); if (!result.success) { - throw new Error(result.error || "Database transaction failed"); + throw new InternalServerErrorException(result.error || "Database transaction failed"); } return result.data!; @@ -503,7 +503,9 @@ export class DistributedTransactionService { } } - throw new Error(`Step ${step.id} failed after ${totalAttempts} attempts`); + throw new InternalServerErrorException( + `Step ${step.id} failed after ${totalAttempts} attempts` + ); } private generateTransactionId(): string { diff --git a/apps/bff/src/infra/email/queue/email.queue.ts b/apps/bff/src/infra/email/queue/email.queue.ts index d13bbceb..344690aa 100644 --- a/apps/bff/src/infra/email/queue/email.queue.ts +++ b/apps/bff/src/infra/email/queue/email.queue.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common"; import { InjectQueue } from "@nestjs/bullmq"; import { Queue, Job } from "bullmq"; import { Logger } from "nestjs-pino"; @@ -69,7 +69,7 @@ export class EmailQueueService { error: errorMessage, }); - throw new Error(`Failed to queue email: ${errorMessage}`); + throw new InternalServerErrorException(`Failed to queue email: ${errorMessage}`); } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index 053da70c..54796be7 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -24,17 +24,38 @@ export class FreebitClientService { ) {} /** - * Make an authenticated request to Freebit API with retry logic + * Make an authenticated form-encoded request to Freebit API with retry logic */ async makeAuthenticatedRequest( endpoint: string, payload: TPayload + ): Promise { + return this.makeRequest(endpoint, payload, "form"); + } + + /** + * Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.) + */ + async makeAuthenticatedJsonRequest< + TResponse extends FreebitResponseBase, + TPayload extends object, + >(endpoint: string, payload: TPayload): Promise { + return this.makeRequest(endpoint, payload, "json"); + } + + /** + * Core authenticated request handler shared by form-encoded and JSON variants. + */ + private async makeRequest( + endpoint: string, + payload: TPayload, + contentType: "form" | "json" ): Promise { const config = this.authService.getConfig(); const authKey = await this.authService.getAuthKey(); - const url = this.buildUrl(config.baseUrl, endpoint); const requestPayload = { ...payload, authKey }; + const logLabel = contentType === "json" ? "Freebit JSON API" : "Freebit API"; let attempt = 0; try { @@ -42,7 +63,7 @@ export class FreebitClientService { async () => { attempt += 1; - this.logger.debug(`Freebit API request`, { + this.logger.debug(`${logLabel} request`, { url, attempt, maxAttempts: config.retryAttempts, @@ -52,17 +73,27 @@ export class FreebitClientService { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); try { + const headers = + contentType === "json" + ? { "Content-Type": "application/json" } + : { "Content-Type": "application/x-www-form-urlencoded" }; + + const body = + contentType === "json" + ? JSON.stringify(requestPayload) + : `json=${JSON.stringify(requestPayload)}`; + const response = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `json=${JSON.stringify(requestPayload)}`, + headers, + body, signal: controller.signal, }); if (!response.ok) { const isProd = process.env["NODE_ENV"] === "production"; const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); - this.logger.error("Freebit API HTTP error", { + this.logger.error(`${logLabel} HTTP error`, { url, status: response.status, statusText: response.statusText, @@ -87,140 +118,12 @@ export class FreebitClientService { resultCode, statusCode, statusMessage: responseData.status?.message, - ...(isProd ? {} : { fullResponse: JSON.stringify(responseData) }), - }; - this.logger.error("Freebit API returned error response", errorDetails); - // Also log to console for visibility in dev - if (!isProd) { - console.error("[FREEBIT ERROR]", JSON.stringify(errorDetails, null, 2)); - } - - throw new FreebitError( - `API Error: ${responseData.status?.message || "Unknown error"}`, - resultCode, - statusCode, - responseData.status?.message - ); - } - - this.logger.debug("Freebit API request successful", { url, resultCode }); - return responseData; - } finally { - clearTimeout(timeoutId); - } - }, - { - maxAttempts: config.retryAttempts, - baseDelayMs: 1000, - maxDelayMs: 10000, - isRetryable: error => { - if (error instanceof FreebitError) { - if (error.isAuthError() && attempt === 1) { - this.logger.warn("Freebit auth error detected, clearing cache and retrying", { - url, - }); - this.authService.clearAuthCache(); - return true; - } - return error.isRetryable(); - } - return RetryableErrors.isTransientError(error); - }, - logger: this.logger, - logContext: "Freebit API request", - } - ); - - // Track successful API call - this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => { - this.logger.debug("Failed to track API call", { - error: error instanceof Error ? error.message : String(error), - }); - }); - - return responseData; - } catch (error: unknown) { - // Track failed API call - this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => { - this.logger.debug("Failed to track API call error", { - error: trackError instanceof Error ? trackError.message : String(trackError), - }); - }); - throw error; - } - } - - /** - * Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.) - */ - async makeAuthenticatedJsonRequest< - TResponse extends FreebitResponseBase, - TPayload extends object, - >(endpoint: string, payload: TPayload): Promise { - const config = this.authService.getConfig(); - const authKey = await this.authService.getAuthKey(); - const url = this.buildUrl(config.baseUrl, endpoint); - - // Add authKey to the payload for authentication - const requestPayload = { ...payload, authKey }; - - let attempt = 0; - // Log request details in dev for debugging - const isProd = process.env["NODE_ENV"] === "production"; - if (!isProd) { - this.logger.debug("[FREEBIT JSON API REQUEST]", { - url, - payload: redactForLogs(requestPayload), - }); - } - try { - const responseData = await withRetry( - async () => { - attempt += 1; - this.logger.debug("Freebit JSON API request", { - url, - attempt, - maxAttempts: config.retryAttempts, - payload: redactForLogs(requestPayload), - }); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.timeout); - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestPayload), - signal: controller.signal, - }); - - if (!response.ok) { - throw new FreebitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status.toString() - ); - } - - const responseData = (await response.json()) as TResponse; - - const resultCode = this.normalizeResultCode(responseData.resultCode); - const statusCode = this.normalizeResultCode(responseData.status?.statusCode); - - if (resultCode && resultCode !== "100") { - const isProd = process.env["NODE_ENV"] === "production"; - const errorDetails = { - url, - resultCode, - statusCode, - message: responseData.status?.message, ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), attempt, }; - this.logger.error("Freebit JSON API returned error result code", errorDetails); - // Always log to console in dev for visibility - if (!isProd) { - console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2)); - } + this.logger.error(`${logLabel} returned error response`, errorDetails); + this.logger.debug({ errorDetails }, `${logLabel} error details`); + throw new FreebitError( `API Error: ${responseData.status?.message || "Unknown error"}`, resultCode, @@ -229,7 +132,7 @@ export class FreebitClientService { ); } - this.logger.debug("Freebit JSON API request successful", { url, resultCode }); + this.logger.debug(`${logLabel} request successful`, { url, resultCode }); return responseData; } finally { clearTimeout(timeoutId); @@ -242,7 +145,7 @@ export class FreebitClientService { isRetryable: error => { if (error instanceof FreebitError) { if (error.isAuthError() && attempt === 1) { - this.logger.warn("Freebit auth error detected, clearing cache and retrying", { + this.logger.warn(`${logLabel} auth error detected, clearing cache and retrying`, { url, }); this.authService.clearAuthCache(); @@ -253,7 +156,7 @@ export class FreebitClientService { return RetryableErrors.isTransientError(error); }, logger: this.logger, - logContext: "Freebit JSON API request", + logContext: `${logLabel} request`, } ); diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts index 4d6b1c97..08a806dd 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -1,5 +1,6 @@ import { Injectable, Inject, Optional } from "@nestjs/common"; import { Logger } from "nestjs-pino"; +import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import type { FreebitAccountDetailsResponse, FreebitTrafficInfoResponse, @@ -72,7 +73,7 @@ export class FreebitMapperService { async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise { const account = response.responseDatas[0]; if (!account) { - throw new Error("No account data in response"); + throw new FreebitOperationException("No account data in response"); } // Debug: Log raw voice option fields from API response @@ -212,7 +213,7 @@ export class FreebitMapperService { */ mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage { if (!response.traffic) { - throw new Error("No traffic data in response"); + throw new FreebitOperationException("No traffic data in response"); } const todayUsageKb = Number.parseInt(response.traffic.today, 10) || 0; @@ -237,7 +238,7 @@ export class FreebitMapperService { */ mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory { if (!response.quotaHistory) { - throw new Error("No history data in response"); + throw new FreebitOperationException("No history data in response"); } return { diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts index e8ad5e01..6f4892c2 100644 --- a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts +++ b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts @@ -9,10 +9,16 @@ * JAPAN_POST_CLIENT_SECRET - OAuth client secret * * Optional Environment Variables: - * JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000) + * JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000) + * JAPAN_POST_DEFAULT_CLIENT_IP - Default client IP for x-forwarded-for (default: 127.0.0.1) */ -import { Injectable, Inject, type OnModuleInit } from "@nestjs/common"; +import { + Injectable, + Inject, + InternalServerErrorException, + type OnModuleInit, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -26,6 +32,7 @@ interface JapanPostConfig { clientId: string; clientSecret: string; timeout: number; + defaultClientIp: string; } interface ConfigValidationError { @@ -58,6 +65,8 @@ export class JapanPostConnectionService implements OnModuleInit { clientId: this.configService.get("JAPAN_POST_CLIENT_ID") || "", clientSecret: this.configService.get("JAPAN_POST_CLIENT_SECRET") || "", timeout: this.configService.get("JAPAN_POST_TIMEOUT") || 10000, + defaultClientIp: + this.configService.get("JAPAN_POST_DEFAULT_CLIENT_IP") || "127.0.0.1", }; // Validate configuration @@ -159,7 +168,7 @@ export class JapanPostConnectionService implements OnModuleInit { method: "POST", headers: { "Content-Type": "application/json", - "x-forwarded-for": "127.0.0.1", // Required by API + "x-forwarded-for": this.config.defaultClientIp, // Required by API }, body: JSON.stringify({ grant_type: "client_credentials", @@ -186,7 +195,7 @@ export class JapanPostConnectionService implements OnModuleInit { apiMessage: parsedError?.message, hint: this.getErrorHint(response.status, parsedError?.error_code), }); - throw new Error(`Token request failed: HTTP ${response.status}`); + throw new InternalServerErrorException(`Token request failed: HTTP ${response.status}`); } const data = (await response.json()) as JapanPostTokenResponse; @@ -213,11 +222,13 @@ export class JapanPostConnectionService implements OnModuleInit { timeoutMs: this.config.timeout, durationMs, }); - throw new Error(`Token request timed out after ${this.config.timeout}ms`); + throw new InternalServerErrorException( + `Token request timed out after ${this.config.timeout}ms` + ); } // Only log if not already logged above (non-ok response) - if (!(error instanceof Error && error.message.startsWith("Token request failed"))) { + if (!(error instanceof InternalServerErrorException)) { this.logger.error("Japan Post token request error", { endpoint: tokenUrl, error: extractErrorMessage(error), @@ -298,7 +309,8 @@ export class JapanPostConnectionService implements OnModuleInit { * @param clientIp - Client IP address for x-forwarded-for header * @returns Raw Japan Post API response */ - async searchByZipCode(zipCode: string, clientIp: string = "127.0.0.1"): Promise { + async searchByZipCode(zipCode: string, clientIp?: string): Promise { + const ip = clientIp || this.config.defaultClientIp; const token = await this.getAccessToken(); const controller = new AbortController(); @@ -314,7 +326,7 @@ export class JapanPostConnectionService implements OnModuleInit { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", - "x-forwarded-for": clientIp, + "x-forwarded-for": ip, }, signal: controller.signal, }); @@ -337,7 +349,7 @@ export class JapanPostConnectionService implements OnModuleInit { apiMessage: parsedError?.message, hint: this.getErrorHint(response.status, parsedError?.error_code), }); - throw new Error(`ZIP code search failed: HTTP ${response.status}`); + throw new InternalServerErrorException(`ZIP code search failed: HTTP ${response.status}`); } const data = await response.json(); @@ -360,11 +372,13 @@ export class JapanPostConnectionService implements OnModuleInit { timeoutMs: this.config.timeout, durationMs, }); - throw new Error(`ZIP search timed out after ${this.config.timeout}ms`); + throw new InternalServerErrorException( + `ZIP search timed out after ${this.config.timeout}ms` + ); } // Only log if not already logged above (non-ok response) - if (!(error instanceof Error && error.message.startsWith("ZIP code search failed"))) { + if (!(error instanceof InternalServerErrorException)) { this.logger.error("Japan Post ZIP search error", { zipCode, endpoint: url, diff --git a/apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts b/apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts index 86bf755f..66c32d7e 100644 --- a/apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts +++ b/apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts @@ -16,6 +16,7 @@ import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import PubSubApiClientPkg from "salesforce-pubsub-api-client"; import { SalesforceConnection } from "../../services/salesforce-connection.service.js"; +import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import type { PubSubClient, PubSubClientConstructor, PubSubCallback } from "./pubsub.types.js"; import { parseNumRequested } from "./pubsub.utils.js"; @@ -63,7 +64,9 @@ export class PubSubClientService implements OnModuleDestroy { const instanceUrl = this.sfConnection.getInstanceUrl(); if (!accessToken || !instanceUrl) { - throw new Error("Salesforce access token or instance URL missing for Pub/Sub client"); + throw new SalesforceOperationException( + "Salesforce access token or instance URL missing for Pub/Sub client" + ); } const pubSubEndpoint = diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts index d352ae6e..01fed6f4 100644 --- a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts @@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "../salesforce-connection.service.js"; import { assertSalesforceId } from "../../utils/soql.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js"; import { @@ -19,6 +20,13 @@ import { requireStringField, } from "./opportunity.types.js"; +/** + * Salesforce Opportunity record payload. + * Keys are dynamic (resolved from OPPORTUNITY_FIELD_MAP at runtime), + * so a static interface is not possible. + */ +type SalesforceOpportunityPayload = Record; + @Injectable() export class OpportunityCancellationService { constructor( @@ -43,7 +51,8 @@ export class OpportunityCancellationService { const safeData = (() => { const unknownData: unknown = data; - if (!isRecord(unknownData)) throw new Error("Invalid cancellation data"); + if (!isRecord(unknownData)) + throw new SalesforceOperationException("Invalid cancellation data"); return { scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), @@ -58,7 +67,7 @@ export class OpportunityCancellationService { cancellationNotice: safeData.cancellationNotice, }); - const payload: Record = { + const payload: SalesforceOpportunityPayload = { Id: safeOppId, [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate, @@ -69,7 +78,9 @@ export class OpportunityCancellationService { try { const updateMethod = this.sf.sobject("Opportunity").update; if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); + throw new SalesforceOperationException( + "Salesforce Opportunity update method not available" + ); } await updateMethod(payload as Record & { Id: string }); @@ -83,7 +94,7 @@ export class OpportunityCancellationService { error: extractErrorMessage(error), opportunityId: safeOppId, }); - throw new Error("Failed to update cancellation information"); + throw new SalesforceOperationException("Failed to update cancellation information"); } } @@ -103,7 +114,8 @@ export class OpportunityCancellationService { const safeData = (() => { const unknownData: unknown = data; - if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data"); + if (!isRecord(unknownData)) + throw new SalesforceOperationException("Invalid SIM cancellation data"); return { scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), @@ -117,7 +129,7 @@ export class OpportunityCancellationService { cancellationNotice: safeData.cancellationNotice, }); - const payload: Record = { + const payload: SalesforceOpportunityPayload = { Id: safeOppId, [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, [OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate, @@ -127,7 +139,9 @@ export class OpportunityCancellationService { try { const updateMethod = this.sf.sobject("Opportunity").update; if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); + throw new SalesforceOperationException( + "Salesforce Opportunity update method not available" + ); } await updateMethod(payload as Record & { Id: string }); @@ -141,7 +155,7 @@ export class OpportunityCancellationService { error: extractErrorMessage(error), opportunityId: safeOppId, }); - throw new Error("Failed to update SIM cancellation information"); + throw new SalesforceOperationException("Failed to update SIM cancellation information"); } } @@ -155,7 +169,7 @@ export class OpportunityCancellationService { opportunityId: safeOppId, }); - const payload: Record = { + const payload: SalesforceOpportunityPayload = { Id: safeOppId, [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLED, }; @@ -163,7 +177,9 @@ export class OpportunityCancellationService { try { const updateMethod = this.sf.sobject("Opportunity").update; if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); + throw new SalesforceOperationException( + "Salesforce Opportunity update method not available" + ); } await updateMethod(payload as Record & { Id: string }); @@ -176,7 +192,7 @@ export class OpportunityCancellationService { error: extractErrorMessage(error), opportunityId: safeOppId, }); - throw new Error("Failed to mark cancellation complete"); + throw new SalesforceOperationException("Failed to mark cancellation complete"); } } } diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts index a5651edd..eb673edc 100644 --- a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts @@ -11,6 +11,7 @@ import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "../salesforce-connection.service.js"; import { assertSalesforceId } from "../../utils/soql.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import { type OpportunityStageValue, type OpportunityProductTypeValue, @@ -22,6 +23,13 @@ import { } from "@customer-portal/domain/opportunity"; import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js"; +/** + * Salesforce Opportunity record payload. + * Keys are dynamic (resolved from OPPORTUNITY_FIELD_MAP at runtime), + * so a static interface is not possible. + */ +type SalesforceOpportunityPayload = Record; + @Injectable() export class OpportunityMutationService { private readonly opportunityRecordTypeIds: Partial< @@ -66,7 +74,7 @@ export class OpportunityMutationService { const commodityType = getDefaultCommodityType(request.productType); const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType); - const payload: Record = { + const payload: SalesforceOpportunityPayload = { [OPPORTUNITY_FIELD_MAP.name]: opportunityName, [OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId, [OPPORTUNITY_FIELD_MAP.stage]: request.stage, @@ -83,13 +91,15 @@ export class OpportunityMutationService { try { const createMethod = this.sf.sobject("Opportunity").create; if (!createMethod) { - throw new Error("Salesforce Opportunity create method not available"); + throw new SalesforceOperationException( + "Salesforce Opportunity create method not available" + ); } const result = (await createMethod(payload)) as { id?: string; success?: boolean }; if (!result?.id) { - throw new Error("Salesforce did not return Opportunity ID"); + throw new SalesforceOperationException("Salesforce did not return Opportunity ID"); } this.logger.log("Opportunity created successfully", { @@ -116,7 +126,7 @@ export class OpportunityMutationService { } this.logger.error(errorDetails, "Failed to create Opportunity"); - throw new Error("Failed to create service lifecycle record"); + throw new SalesforceOperationException("Failed to create service lifecycle record"); } } @@ -136,7 +146,7 @@ export class OpportunityMutationService { reason, }); - const payload: Record = { + const payload: SalesforceOpportunityPayload = { Id: safeOppId, [OPPORTUNITY_FIELD_MAP.stage]: stage, }; @@ -144,7 +154,9 @@ export class OpportunityMutationService { try { const updateMethod = this.sf.sobject("Opportunity").update; if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); + throw new SalesforceOperationException( + "Salesforce Opportunity update method not available" + ); } await updateMethod(payload as Record & { Id: string }); @@ -159,7 +171,7 @@ export class OpportunityMutationService { opportunityId: safeOppId, stage, }); - throw new Error("Failed to update service lifecycle stage"); + throw new SalesforceOperationException("Failed to update service lifecycle stage"); } } @@ -177,7 +189,7 @@ export class OpportunityMutationService { whmcsServiceId, }); - const payload: Record = { + const payload: SalesforceOpportunityPayload = { Id: safeOppId, [OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId, [OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl]: `productselect=${whmcsServiceId}`, @@ -186,7 +198,9 @@ export class OpportunityMutationService { try { const updateMethod = this.sf.sobject("Opportunity").update; if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); + throw new SalesforceOperationException( + "Salesforce Opportunity update method not available" + ); } await updateMethod(payload as Record & { Id: string }); @@ -220,7 +234,7 @@ export class OpportunityMutationService { try { const updateMethod = this.sf.sobject("Order").update; if (!updateMethod) { - throw new Error("Salesforce Order update method not available"); + throw new SalesforceOperationException("Salesforce Order update method not available"); } await updateMethod({ diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index c3f13921..6cc22132 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -2,11 +2,20 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import { SalesforceConnection } from "./salesforce-connection.service.js"; import type { SalesforceAccountRecord } from "@customer-portal/domain/customer"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import { customerNumberSchema, salesforceIdSchema } from "@customer-portal/domain/common"; +/** + * Salesforce Account/Contact record payloads. + * Mix of static Salesforce fields and dynamic keys from ConfigService + * (portal status, portal source, etc.), so a static interface is not possible. + */ +type SalesforceAccountPayload = Record; +type SalesforceContactPayload = Record; + /** * Salesforce Account Service * @@ -59,7 +68,7 @@ export class SalesforceAccountService { this.logger.error("Failed to find account by customer number", { error: extractErrorMessage(error), }); - throw new Error("Failed to find account"); + throw new SalesforceOperationException("Failed to find account"); } } @@ -90,7 +99,7 @@ export class SalesforceAccountService { this.logger.error("Failed to find account with details by customer number", { error: extractErrorMessage(error), }); - throw new Error("Failed to find account"); + throw new SalesforceOperationException("Failed to find account"); } } @@ -126,7 +135,7 @@ export class SalesforceAccountService { accountId, error: extractErrorMessage(error), }); - throw new Error("Failed to get account details"); + throw new SalesforceOperationException("Failed to get account details"); } } @@ -183,7 +192,7 @@ export class SalesforceAccountService { const personAccountRecordTypeId = await this.resolvePersonAccountRecordTypeId(); - const accountPayload: Record = { + const accountPayload: SalesforceAccountPayload = { // Person Account fields (required for Person Accounts) FirstName: data.firstName, LastName: data.lastName, @@ -191,7 +200,7 @@ export class SalesforceAccountService { PersonMobilePhone: data.phone, // Record type for Person Accounts (required) RecordTypeId: personAccountRecordTypeId, - // Portal tracking fields + // Portal tracking fields (dynamic keys from ConfigService) [this.portalStatusField]: data.portalStatus ?? "Not Yet", [this.portalSourceField]: data.portalSource, }; @@ -206,13 +215,15 @@ export class SalesforceAccountService { try { const createMethod = this.connection.sobject("Account").create; if (!createMethod) { - throw new Error("Salesforce create method not available"); + throw new SalesforceOperationException("Salesforce create method not available"); } const result = await createMethod(accountPayload); if (!result || typeof result !== "object" || !("id" in result)) { - throw new Error("Salesforce Account creation failed - no ID returned"); + throw new SalesforceOperationException( + "Salesforce Account creation failed - no ID returned" + ); } const accountId = result.id as string; @@ -275,7 +286,7 @@ export class SalesforceAccountService { }, "Failed to create Salesforce Account" ); - throw new Error("Failed to create customer account in CRM"); + throw new SalesforceOperationException("Failed to create customer account in CRM"); } } @@ -295,14 +306,14 @@ export class SalesforceAccountService { )) as SalesforceResponse<{ Id: string; Name: string }>; if (recordTypeQuery.totalSize === 0) { - throw new Error( + throw new SalesforceOperationException( "No Person Account record type found. Person Accounts may not be enabled in this Salesforce org." ); } const record = recordTypeQuery.records[0]; if (!record) { - throw new Error("Person Account RecordType record not found"); + throw new SalesforceOperationException("Person Account RecordType record not found"); } const recordTypeId = record.Id; this.logger.debug("Found Person Account RecordType", { @@ -314,7 +325,7 @@ export class SalesforceAccountService { this.logger.error("Failed to query Person Account RecordType", { error: extractErrorMessage(error), }); - throw new Error("Failed to determine Person Account record type"); + throw new SalesforceOperationException("Failed to determine Person Account record type"); } } @@ -349,11 +360,11 @@ export class SalesforceAccountService { const personContactId = accountRecord.records[0]?.PersonContactId; if (!personContactId) { - throw new Error("PersonContactId not found for Person Account"); + throw new SalesforceOperationException("PersonContactId not found for Person Account"); } // Update the PersonContact with additional fields - const contactPayload: Record = { + const contactPayload: SalesforceContactPayload = { Id: personContactId, MobilePhone: data.phone, Sex__c: mapGenderToSalesforce(data.gender), @@ -362,7 +373,7 @@ export class SalesforceAccountService { const updateMethod = this.connection.sobject("Contact").update; if (!updateMethod) { - throw new Error("Salesforce update method not available"); + throw new SalesforceOperationException("Salesforce update method not available"); } await updateMethod(contactPayload as Record & { Id: string }); @@ -374,7 +385,7 @@ export class SalesforceAccountService { error: extractErrorMessage(error), accountId: data.accountId, }); - throw new Error("Failed to update customer contact in CRM"); + throw new SalesforceOperationException("Failed to update customer contact in CRM"); } } @@ -417,7 +428,7 @@ export class SalesforceAccountService { } // Build contact update payload with Japanese mailing address fields - const contactPayload: Record = { + const contactPayload: SalesforceContactPayload = { Id: personContactId, MailingStreet: address.mailingStreet || "", MailingCity: address.mailingCity, @@ -466,7 +477,7 @@ export class SalesforceAccountService { update: SalesforceAccountPortalUpdate ): Promise { const validAccountId = salesforceIdSchema.parse(accountId); - const payload: Record = { Id: validAccountId }; + const payload: SalesforceAccountPayload = { Id: validAccountId }; if (update.status) { payload[this.portalStatusField] = update.status; diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index d81fd388..afca8492 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "./salesforce-connection.service.js"; import { assertSalesforceId } from "../utils/soql.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import { CASE_FIELDS } from "../constants/field-maps.js"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support"; @@ -44,6 +45,13 @@ import { // Types // ============================================================================ +/** + * Salesforce Case record payload. + * Keys are dynamic (resolved from CASE_FIELDS constant at runtime), + * so a static interface is not possible. + */ +type SalesforceCasePayload = Record; + /** * Parameters for creating any case in Salesforce. * @@ -117,7 +125,7 @@ export class SalesforceCaseService { error: extractErrorMessage(error), accountId: safeAccountId, }); - throw new Error("Failed to fetch support cases"); + throw new SalesforceOperationException("Failed to fetch support cases"); } } @@ -154,7 +162,7 @@ export class SalesforceCaseService { error: extractErrorMessage(error), caseId: safeCaseId, }); - throw new Error("Failed to fetch support case"); + throw new SalesforceOperationException("Failed to fetch support case"); } } @@ -191,7 +199,7 @@ export class SalesforceCaseService { ? toSalesforcePriority(params.priority) : SALESFORCE_CASE_PRIORITY.MEDIUM; - const casePayload: Record = { + const casePayload: SalesforceCasePayload = { [CASE_FIELDS.origin]: params.origin, [CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW, [CASE_FIELDS.priority]: sfPriority, @@ -221,7 +229,7 @@ export class SalesforceCaseService { const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; if (!created.id) { - throw new Error("Salesforce did not return a case ID"); + throw new SalesforceOperationException("Salesforce did not return a case ID"); } // Fetch the created case to get the CaseNumber @@ -241,7 +249,7 @@ export class SalesforceCaseService { accountIdTail: safeAccountId.slice(-4), origin: params.origin, }); - throw new Error("Failed to create case"); + throw new SalesforceOperationException("Failed to create case"); } } @@ -254,7 +262,7 @@ export class SalesforceCaseService { async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> { this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail }); - const casePayload: Record = { + const casePayload: SalesforceCasePayload = { [CASE_FIELDS.origin]: params.origin ?? "Web", [CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW, [CASE_FIELDS.priority]: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM, @@ -269,7 +277,7 @@ export class SalesforceCaseService { const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; if (!created.id) { - throw new Error("Salesforce did not return a case ID"); + throw new SalesforceOperationException("Salesforce did not return a case ID"); } // Fetch the created case to get the CaseNumber @@ -288,7 +296,7 @@ export class SalesforceCaseService { error: extractErrorMessage(error), email: params.suppliedEmail, }); - throw new Error("Failed to create contact request"); + throw new SalesforceOperationException("Failed to create contact request"); } } @@ -384,7 +392,7 @@ export class SalesforceCaseService { error: extractErrorMessage(error), caseId: safeCaseId, }); - throw new Error("Failed to fetch case messages"); + throw new SalesforceOperationException("Failed to fetch case messages"); } } @@ -414,7 +422,7 @@ export class SalesforceCaseService { { caseId: safeCaseId }, "Attempted to add comment to non-existent/unauthorized case" ); - throw new Error("Case not found"); + throw new SalesforceOperationException("Case not found"); } this.logger.log("Adding comment to case", { @@ -434,7 +442,7 @@ export class SalesforceCaseService { }; if (!created.id) { - throw new Error("Salesforce did not return a comment ID"); + throw new SalesforceOperationException("Salesforce did not return a comment ID"); } const createdAt = new Date().toISOString(); @@ -450,7 +458,7 @@ export class SalesforceCaseService { error: extractErrorMessage(error), caseId: safeCaseId, }); - throw new Error("Failed to add comment"); + throw new SalesforceOperationException("Failed to add comment"); } } } diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts index 7c227a79..965d0440 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts @@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "./salesforce-connection.service.js"; import { assertSalesforceId, buildInClause } from "../utils/soql.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders"; import type { SalesforceOrderItemRecord, @@ -28,6 +29,13 @@ import { import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import { SalesforceOrderFieldMapService } from "../config/order-field-map.service.js"; +/** + * Salesforce Order record payload. + * Keys are dynamic (resolved from SalesforceOrderFieldMap at runtime), + * so a static interface is not possible. + */ +type SalesforceOrderPayload = Record; + /** * Salesforce Order Service * @@ -114,7 +122,7 @@ export class SalesforceOrderService { /** * Create a new order in Salesforce */ - async createOrder(orderFields: Record): Promise<{ id: string }> { + async createOrder(orderFields: SalesforceOrderPayload): Promise<{ id: string }> { const typeField = this.orderFieldMap.fields.order.type; this.logger.log({ orderType: orderFields[typeField] }, "Creating Salesforce Order"); @@ -132,7 +140,7 @@ export class SalesforceOrderService { } async createOrderWithItems( - orderFields: Record, + orderFields: SalesforceOrderPayload, items: Array<{ pricebookEntryId: string; unitPrice: number; quantity: number; sku?: string }> ): Promise<{ id: string }> { if (!items.length) { @@ -172,7 +180,7 @@ export class SalesforceOrderService { .map(err => `[${err.statusCode}] ${err.message}`) .join("; "); - throw new Error( + throw new SalesforceOperationException( errorDetails || "Salesforce composite tree returned errors during order creation" ); } @@ -182,7 +190,9 @@ export class SalesforceOrderService { ); if (!orderResult?.id) { - throw new Error("Salesforce composite tree response missing order ID"); + throw new SalesforceOperationException( + "Salesforce composite tree response missing order ID" + ); } this.logger.log( diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts index 89836db0..e739e27a 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts @@ -65,6 +65,15 @@ interface SalesforceSIMInventoryResponse { }>; } +/** Payload for updating a SIM_Inventory__c record */ +interface SimInventoryUpdatePayload { + Id: string; + Status__c: SimInventoryStatus; + Assigned_Account__c?: string; + Assigned_Order__c?: string; + SIM_Type__c?: string; +} + @Injectable() export class SalesforceSIMInventoryService { constructor( @@ -193,20 +202,20 @@ export class SalesforceSIMInventoryService { }); try { - const updatePayload: Record = { + const updatePayload: SimInventoryUpdatePayload = { Id: safeId, Status__c: SIM_INVENTORY_STATUS.ASSIGNED, }; // Add optional assignment fields if provided if (details?.accountId) { - updatePayload["Assigned_Account__c"] = details.accountId; + updatePayload.Assigned_Account__c = details.accountId; } if (details?.orderId) { - updatePayload["Assigned_Order__c"] = details.orderId; + updatePayload.Assigned_Order__c = details.orderId; } if (details?.simType) { - updatePayload["SIM_Type__c"] = details.simType; + updatePayload.SIM_Type__c = details.simType; } await this.sf.sobject("SIM_Inventory__c").update?.(updatePayload as { Id: string }); diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts index 8591480d..275b817f 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts @@ -16,15 +16,11 @@ export class WhmcsErrorHandlerService { /** * Handle WHMCS API error response */ - handleApiError( - errorResponse: WhmcsErrorResponse, - action: string, - params: Record - ): never { + handleApiError(errorResponse: WhmcsErrorResponse, action: string): never { const message = errorResponse.message; const errorCode = errorResponse.errorcode; - const mapped = this.mapProviderErrorToDomain(action, message, errorCode, params); + const mapped = this.mapProviderErrorToDomain(action, message, errorCode); throw new DomainHttpException(mapped.code, mapped.status); } @@ -172,8 +168,7 @@ export class WhmcsErrorHandlerService { private mapProviderErrorToDomain( action: string, message: string, - providerErrorCode: string | undefined, - params: Record + providerErrorCode: string | undefined ): { code: ErrorCodeType; status: HttpStatus } { // 1) ValidateLogin: user credentials are wrong (expected) if ( @@ -199,7 +194,6 @@ export class WhmcsErrorHandlerService { } // 5) Default: external service error - void params; // reserved for future mapping detail; keep signature stable return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 15d61e1b..6e3f836c 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import { redactForLogs } from "@bff/core/logging/redaction.util.js"; import type { WhmcsResponse } from "@customer-portal/domain/common/providers"; import type { @@ -140,7 +141,7 @@ export class WhmcsHttpClientService { } } - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + throw new WhmcsOperationException(`HTTP ${response.status}: ${response.statusText}`); } return this.parseResponse(responseText, action, params); @@ -242,7 +243,7 @@ export class WhmcsHttpClientService { parseError: extractErrorMessage(parseError), params: redactForLogs(params), }); - throw new Error("Invalid JSON response from WHMCS API"); + throw new WhmcsOperationException("Invalid JSON response from WHMCS API"); } // Validate basic response structure @@ -255,7 +256,7 @@ export class WhmcsHttpClientService { : { responseText: responseText.slice(0, 500) }), params: redactForLogs(params), }); - throw new Error("Invalid response structure from WHMCS API"); + throw new WhmcsOperationException("Invalid response structure from WHMCS API"); } // Handle error responses according to WHMCS API documentation diff --git a/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts b/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts index 7b684c06..7cc0fa49 100644 --- a/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts +++ b/apps/bff/src/integrations/whmcs/facades/whmcs.facade.ts @@ -37,12 +37,48 @@ import type { WhmcsProductListResponse, } from "@customer-portal/domain/subscriptions/providers"; import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/services/providers"; +import type { WhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers"; import type { WhmcsRequestOptions, WhmcsConnectionStats, } from "../connection/types/connection.types.js"; +/** + * Parameters for WHMCS UpdateClient API. + * Any client field can be updated; these are the most common ones. + */ +interface WhmcsUpdateClientParams { + firstname?: string | undefined; + lastname?: string | undefined; + companyname?: string | undefined; + email?: string | undefined; + address1?: string | undefined; + address2?: string | undefined; + city?: string | undefined; + state?: string | undefined; + postcode?: string | undefined; + country?: string | undefined; + phonenumber?: string | undefined; + currency?: string | number | undefined; + language?: string | undefined; + status?: string | undefined; + notes?: string | undefined; + [key: string]: unknown; +} + +/** + * Parameters for WHMCS GetOrders API. + */ +interface WhmcsGetOrdersParams { + id?: string; + userid?: number; + status?: string; + limitstart?: number; + limitnum?: number; + [key: string]: unknown; +} + /** * WHMCS Connection Facade * @@ -105,7 +141,7 @@ export class WhmcsConnectionFacade implements OnModuleInit { if (response.result === "error") { const errorResponse = response as WhmcsErrorResponse; - this.errorHandler.handleApiError(errorResponse, action, params); + this.errorHandler.handleApiError(errorResponse, action); } return response.data as T; @@ -189,7 +225,7 @@ export class WhmcsConnectionFacade implements OnModuleInit { async updateClient( clientId: number, - updateData: Record + updateData: WhmcsUpdateClientParams ): Promise<{ result: string }> { return this.makeRequest<{ result: string }>("UpdateClient", { clientid: clientId, @@ -244,11 +280,11 @@ export class WhmcsConnectionFacade implements OnModuleInit { // ORDER OPERATIONS (Used by order services) // ========================================== - async addOrder(params: Record) { + async addOrder(params: WhmcsAddOrderPayload) { return this.makeRequest("AddOrder", params); } - async getOrders(params: Record = {}) { + async getOrders(params: WhmcsGetOrdersParams = {}) { return this.makeRequest("GetOrders", params); } @@ -323,10 +359,6 @@ export class WhmcsConnectionFacade implements OnModuleInit { return this.configService.getBaseUrl(); } - // ========================================== - // UTILITY METHODS - // ========================================== - /** * Determine request priority based on action type */ diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts index e2583a24..ca2e027d 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts @@ -114,7 +114,7 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse; // Check if response has currencies data (success case) or error fields - if (response.result === "success" || (response.currencies && !response["error"])) { + if (response.result === "success" || (response.currencies && response.result !== "error")) { // Parse the WHMCS response format into currency objects this.currencies = this.parseWhmcsCurrenciesResponse(response); @@ -135,13 +135,12 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { } else { this.logger.error("WHMCS GetCurrencies returned error", { result: response?.result, - message: response?.["message"], - error: response?.["error"], - errorcode: response?.["errorcode"], + message: response?.message, + errorcode: response?.errorcode, fullResponse: JSON.stringify(response, null, 2), }); throw new WhmcsOperationException( - `WHMCS GetCurrencies error: ${response?.["message"] || response?.["error"] || "Unknown error"}`, + `WHMCS GetCurrencies error: ${response?.message || "Unknown error"}`, { operation: "getCurrencies" } ); } @@ -187,7 +186,9 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { } } else { // Fallback: try to parse flat format (currencies[currency][0][id], etc.) - const currencyKeys = Object.keys(response).filter( + // The flat format has dynamic keys not present in the typed schema — values are always strings + const flat = response as unknown as Record; + const currencyKeys = Object.keys(flat).filter( key => key.startsWith("currencies[currency][") && key.includes("][id]") ); @@ -203,12 +204,12 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { // Build currency objects from the flat response for (const index of currencyIndices) { const currency: Currency = { - id: Number.parseInt(String(response[`currencies[currency][${index}][id]`])) || 0, - code: String(response[`currencies[currency][${index}][code]`] || ""), - prefix: String(response[`currencies[currency][${index}][prefix]`] || ""), - suffix: String(response[`currencies[currency][${index}][suffix]`] || ""), - format: String(response[`currencies[currency][${index}][format]`] || "1"), - rate: String(response[`currencies[currency][${index}][rate]`] || "1.00000"), + id: Number.parseInt(String(flat[`currencies[currency][${index}][id]`])) || 0, + code: String(flat[`currencies[currency][${index}][code]`] ?? ""), + prefix: String(flat[`currencies[currency][${index}][prefix]`] ?? ""), + suffix: String(flat[`currencies[currency][${index}][suffix]`] ?? ""), + format: String(flat[`currencies[currency][${index}][format]`] ?? "1"), + rate: String(flat[`currencies[currency][${index}][rate]`] ?? "1.00000"), }; // Validate that we have essential currency data diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index 7583bc90..eef6ba5f 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -4,20 +4,39 @@ import { WhmcsConnectionFacade } from "../facades/whmcs.facade.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js"; -import type { - WhmcsOrderItem, - WhmcsAddOrderParams, - WhmcsAddOrderResponse, - WhmcsOrderResult, -} from "@customer-portal/domain/orders/providers"; import { buildWhmcsAddOrderPayload, whmcsAddOrderResponseSchema, whmcsAcceptOrderResponseSchema, + type WhmcsOrderItem, + type WhmcsAddOrderParams, + type WhmcsAddOrderResponse, + type WhmcsOrderResult, + type WhmcsAddOrderPayload, + type WhmcsAcceptOrderResponse, } from "@customer-portal/domain/orders/providers"; export type { WhmcsOrderItem, WhmcsAddOrderParams, WhmcsOrderResult }; +/** Raw WHMCS order record from GetOrders API */ +interface WhmcsGetOrdersOrder { + id?: string | number; + ordernum?: string; + date?: string; + status?: string; + invoiceid?: string | number; + [key: string]: unknown; +} + +/** Raw WHMCS GetOrders API response */ +interface WhmcsGetOrdersResponse { + orders?: { + order?: WhmcsGetOrdersOrder[]; + }; + totalresults?: number; + [key: string]: unknown; +} + @Injectable() export class WhmcsOrderService { constructor( @@ -47,16 +66,14 @@ export class WhmcsOrderService { this.logger.debug("Built WHMCS AddOrder payload", { clientId: params.clientId, - productCount: Array.isArray(addOrderPayload["pid"]) - ? (addOrderPayload["pid"] as unknown[]).length - : 0, - pids: addOrderPayload["pid"], - quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added - billingCycles: addOrderPayload["billingcycle"], - hasConfigOptions: Boolean(addOrderPayload["configoptions"]), - hasCustomFields: Boolean(addOrderPayload["customfields"]), - promoCode: addOrderPayload["promocode"], - paymentMethod: addOrderPayload["paymentmethod"], + productCount: addOrderPayload.pid.length, + pids: addOrderPayload.pid, + quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added + billingCycles: addOrderPayload.billingcycle, + hasConfigOptions: Boolean(addOrderPayload.configoptions), + hasCustomFields: Boolean(addOrderPayload.customfields), + promoCode: addOrderPayload.promocode, + paymentMethod: addOrderPayload.paymentmethod, }); // Call WHMCS AddOrder API @@ -135,7 +152,7 @@ export class WhmcsOrderService { // Call WHMCS AcceptOrder API // Note: The HTTP client throws errors automatically if result === "error" // So we only get here if the request was successful - const response = (await this.connection.acceptOrder(orderId)) as Record; + const response = (await this.connection.acceptOrder(orderId)) as WhmcsAcceptOrderResponse; // Log the full response for debugging this.logger.debug("WHMCS AcceptOrder response", { @@ -248,15 +265,14 @@ export class WhmcsOrderService { /** * Get order details from WHMCS */ - async getOrderDetails(orderId: number): Promise | null> { + async getOrderDetails(orderId: number): Promise { try { // Note: The HTTP client throws errors automatically if result === "error" const response = (await this.connection.getOrders({ id: orderId.toString(), - })) as Record; + })) as WhmcsGetOrdersResponse; - const orders = response["orders"] as { order?: Record[] } | undefined; - return orders?.order?.[0] ?? null; + return response.orders?.order?.[0] ?? null; } catch (error) { this.logger.error("Failed to get WHMCS order details", { error: extractErrorMessage(error), @@ -302,19 +318,19 @@ export class WhmcsOrderService { * * Delegates to shared mapper function from integration package */ - private buildAddOrderPayload(params: WhmcsAddOrderParams): Record { + private buildAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAddOrderPayload { const payload = buildWhmcsAddOrderPayload(params); this.logger.debug("Built WHMCS AddOrder payload", { clientId: params.clientId, productCount: params.items.length, - pids: payload["pid"], - billingCycles: payload["billingcycle"], - hasConfigOptions: !!payload["configoptions"], - hasCustomFields: !!payload["customfields"], + pids: payload.pid, + billingCycles: payload.billingcycle, + hasConfigOptions: !!payload.configoptions, + hasCustomFields: !!payload.customfields, }); - return payload as Record; + return payload; } private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult { diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 64a1527a..665a6e0e 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -15,6 +15,8 @@ import { TokenRevocationService } from "./infra/token/token-revocation.service.j import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { AuthTokenService } from "./infra/token/token.service.js"; +import { TokenGeneratorService } from "./infra/token/token-generator.service.js"; +import { TokenRefreshService } from "./infra/token/token-refresh.service.js"; import { JoseJwtService } from "./infra/token/jose-jwt.service.js"; import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js"; import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js"; @@ -62,6 +64,8 @@ import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.serv TokenBlacklistService, TokenStorageService, TokenRevocationService, + TokenGeneratorService, + TokenRefreshService, AuthTokenService, JoseJwtService, PasswordResetTokenService, diff --git a/apps/bff/src/modules/auth/infra/token/index.ts b/apps/bff/src/modules/auth/infra/token/index.ts index edef8493..9dac27a2 100644 --- a/apps/bff/src/modules/auth/infra/token/index.ts +++ b/apps/bff/src/modules/auth/infra/token/index.ts @@ -5,6 +5,8 @@ */ export { AuthTokenService } from "./token.service.js"; +export { TokenGeneratorService } from "./token-generator.service.js"; +export { TokenRefreshService } from "./token-refresh.service.js"; export { JoseJwtService } from "./jose-jwt.service.js"; export { TokenBlacklistService } from "./token-blacklist.service.js"; export { TokenStorageService } from "./token-storage.service.js"; diff --git a/apps/bff/src/modules/auth/infra/token/token-generator.service.ts b/apps/bff/src/modules/auth/infra/token/token-generator.service.ts new file mode 100644 index 00000000..d3432cbb --- /dev/null +++ b/apps/bff/src/modules/auth/infra/token/token-generator.service.ts @@ -0,0 +1,214 @@ +import { + BadRequestException, + Injectable, + Inject, + ServiceUnavailableException, +} from "@nestjs/common"; +import { Redis } from "ioredis"; +import { Logger } from "nestjs-pino"; +import { randomBytes, createHash } from "crypto"; +import type { JWTPayload } from "jose"; +import type { AuthTokens } from "@customer-portal/domain/auth"; +import type { UserRole } from "@customer-portal/domain/customer"; +import { JoseJwtService } from "./jose-jwt.service.js"; +import { TokenStorageService } from "./token-storage.service.js"; + +interface RefreshTokenPayload extends JWTPayload { + userId: string; + familyId?: string | undefined; + tokenId: string; + deviceId?: string | undefined; + userAgent?: string | undefined; + type: "refresh"; +} + +interface DeviceInfo { + deviceId?: string | undefined; + userAgent?: string | undefined; +} + +const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable"; + +/** + * Token Generator Service + * + * Handles all token creation logic: access tokens, refresh tokens, and token pairs. + */ +@Injectable() +export class TokenGeneratorService { + readonly ACCESS_TOKEN_EXPIRY = "15m"; + readonly REFRESH_TOKEN_EXPIRY = "7d"; + + constructor( + private readonly jwtService: JoseJwtService, + private readonly storage: TokenStorageService, + @Inject("REDIS_CLIENT") private readonly redis: Redis, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Generate a new token pair with refresh token storage + */ + async generateTokenPair( + user: { + id: string; + email: string; + role?: UserRole; + }, + deviceInfo?: DeviceInfo + ): Promise { + if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) { + this.logger.error("Invalid user ID provided for token generation", { + userId: user.id, + }); + throw new BadRequestException("Invalid user ID for token generation"); + } + if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) { + this.logger.error("Invalid user email provided for token generation", { + userId: user.id, + }); + throw new BadRequestException("Invalid user email for token generation"); + } + + const accessTokenId = this.generateTokenId(); + const refreshFamilyId = this.generateTokenId(); + const refreshTokenId = this.generateTokenId(); + + const accessPayload = { + sub: user.id, + email: user.email, + role: user.role || "USER", + tokenId: accessTokenId, + type: "access", + }; + + const refreshPayload: RefreshTokenPayload = { + userId: user.id, + familyId: refreshFamilyId, + tokenId: refreshTokenId, + deviceId: deviceInfo?.deviceId, + userAgent: deviceInfo?.userAgent, + type: "refresh", + }; + + const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY); + + const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); + const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds); + + const refreshTokenHash = this.hashToken(refreshToken); + const refreshAbsoluteExpiresAt = new Date( + Date.now() + refreshExpirySeconds * 1000 + ).toISOString(); + + if (this.redis.status !== "ready") { + this.logger.error("Redis not ready for token issuance", { + status: this.redis.status, + }); + throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE); + } + + try { + await this.storage.storeRefreshToken({ + userId: user.id, + familyId: refreshFamilyId, + refreshTokenHash, + deviceInfo, + refreshExpirySeconds, + absoluteExpiresAt: refreshAbsoluteExpiresAt, + }); + } catch (error) { + this.logger.error("Failed to store refresh token in Redis", { + error: error instanceof Error ? error.message : String(error), + userId: user.id, + }); + throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE); + } + + const accessExpiresAt = new Date( + Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) + ).toISOString(); + const refreshExpiresAt = refreshAbsoluteExpiresAt; + + this.logger.debug("Generated new token pair", { + userId: user.id, + accessTokenId, + refreshFamilyId, + refreshTokenId, + }); + + return { + accessToken, + refreshToken, + expiresAt: accessExpiresAt, + refreshExpiresAt, + tokenType: "Bearer", + }; + } + + /** + * Generate new access and refresh tokens for rotation + */ + async generateRotationTokenPair( + user: { id: string; email: string; role: string }, + familyId: string, + remainingSeconds: number, + deviceInfo?: DeviceInfo + ): Promise<{ newAccessToken: string; newRefreshToken: string; newRefreshTokenHash: string }> { + const accessTokenId = this.generateTokenId(); + const refreshTokenId = this.generateTokenId(); + + const accessPayload = { + sub: user.id, + email: user.email, + role: user.role || "USER", + tokenId: accessTokenId, + type: "access", + }; + + const newRefreshPayload: RefreshTokenPayload = { + userId: user.id, + familyId, + tokenId: refreshTokenId, + deviceId: deviceInfo?.deviceId, + userAgent: deviceInfo?.userAgent, + type: "refresh", + }; + + const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY); + const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds); + const newRefreshTokenHash = this.hashToken(newRefreshToken); + + return { newAccessToken, newRefreshToken, newRefreshTokenHash }; + } + + generateTokenId(): string { + return randomBytes(32).toString("hex"); + } + + hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); + } + + parseExpiryToMs(expiry: string): number { + const unit = expiry.slice(-1); + const value = Number.parseInt(expiry.slice(0, -1)); + + switch (unit) { + case "s": + return value * 1000; + case "m": + return value * 60 * 1000; + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + default: + return 15 * 60 * 1000; + } + } + + parseExpiryToSeconds(expiry: string): number { + return Math.floor(this.parseExpiryToMs(expiry) / 1000); + } +} diff --git a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts index 5258f061..ca6f1809 100644 --- a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { Redis } from "ioredis"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -53,7 +53,7 @@ export class TokenMigrationService { this.logger.log("Starting token migration", { dryRun }); if (this.redis.status !== "ready") { - throw new Error("Redis is not ready for migration"); + throw new ServiceUnavailableException("Redis is not ready for migration"); } try { diff --git a/apps/bff/src/modules/auth/infra/token/token-refresh.service.ts b/apps/bff/src/modules/auth/infra/token/token-refresh.service.ts new file mode 100644 index 00000000..58696fcd --- /dev/null +++ b/apps/bff/src/modules/auth/infra/token/token-refresh.service.ts @@ -0,0 +1,336 @@ +import { + Injectable, + Inject, + UnauthorizedException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Redis } from "ioredis"; +import { Logger } from "nestjs-pino"; +import type { JWTPayload } from "jose"; +import type { AuthTokens } from "@customer-portal/domain/auth"; +import type { UserAuth } from "@customer-portal/domain/customer"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; +import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; +import { TokenGeneratorService } from "./token-generator.service.js"; +import { TokenStorageService } from "./token-storage.service.js"; +import { TokenRevocationService } from "./token-revocation.service.js"; +import { JoseJwtService } from "./jose-jwt.service.js"; + +interface RefreshTokenPayload extends JWTPayload { + userId: string; + familyId?: string | undefined; + tokenId: string; + deviceId?: string | undefined; + userAgent?: string | undefined; + type: "refresh"; +} + +interface DeviceInfo { + deviceId?: string | undefined; + userAgent?: string | undefined; +} + +const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token"; +const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable"; + +interface ValidatedTokenContext { + payload: RefreshTokenPayload; + familyId: string; + refreshTokenHash: string; + remainingSeconds: number; + absoluteExpiresAt: string; + createdAt: string; +} + +/** + * Token Refresh Service + * + * Handles refresh token validation, rotation, and re-issuance. + */ +@Injectable() +export class TokenRefreshService { + private readonly allowRedisFailOpen: boolean; + + constructor( + private readonly generator: TokenGeneratorService, + private readonly storage: TokenStorageService, + private readonly revocation: TokenRevocationService, + private readonly jwtService: JoseJwtService, + private readonly usersService: UsersService, + configService: ConfigService, + @Inject("REDIS_CLIENT") private readonly redis: Redis, + @Inject(Logger) private readonly logger: Logger + ) { + this.allowRedisFailOpen = + configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true"; + } + + /** + * Refresh access token using refresh token rotation + */ + async refreshTokens( + refreshToken: string, + deviceInfo?: DeviceInfo + ): Promise<{ tokens: AuthTokens; user: UserAuth }> { + if (!refreshToken) { + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + this.checkRedisForRefresh(); + + try { + const tokenContext = await this.validateAndExtractTokenContext(refreshToken); + return await this.performTokenRotation(tokenContext, deviceInfo); + } catch (error) { + return this.handleRefreshError(error); + } + } + + private checkRedisForRefresh(): void { + if (!this.allowRedisFailOpen && this.redis.status !== "ready") { + this.logger.error("Redis unavailable for token refresh", { + redisStatus: this.redis.status, + }); + throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE); + } + } + + private async validateAndExtractTokenContext( + refreshToken: string + ): Promise { + const payload = await this.verifyRefreshTokenPayload(refreshToken); + const familyId = this.extractFamilyId(payload); + const refreshTokenHash = this.generator.hashToken(refreshToken); + + await this.validateStoredToken(refreshTokenHash, familyId, payload); + const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime( + familyId, + refreshTokenHash + ); + + return { + payload, + familyId, + refreshTokenHash, + remainingSeconds, + absoluteExpiresAt, + createdAt, + }; + } + + private async verifyRefreshTokenPayload(refreshToken: string): Promise { + const payload = await 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(ERROR_INVALID_REFRESH_TOKEN); + } + if (!payload.userId || typeof payload.userId !== "string") { + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + if (!payload.tokenId || typeof payload.tokenId !== "string") { + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + return payload; + } + + private extractFamilyId(payload: RefreshTokenPayload): string { + return typeof payload.familyId === "string" && payload.familyId.length > 0 + ? payload.familyId + : payload.tokenId; + } + + private async validateStoredToken( + refreshTokenHash: string, + familyId: string, + payload: RefreshTokenPayload + ): Promise { + const { token: storedTokenData, family: familyData } = await this.storage.getTokenAndFamily( + refreshTokenHash, + familyId + ); + + if (!storedTokenData) { + this.logger.warn("Refresh token not found or expired", { + tokenHash: refreshTokenHash.slice(0, 8), + }); + await this.revocation.invalidateTokenFamily(familyId); + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + const tokenRecord = this.storage.parseRefreshTokenRecord(storedTokenData); + if (!tokenRecord) { + this.logger.warn("Stored refresh token payload was invalid JSON", { + tokenHash: refreshTokenHash.slice(0, 8), + }); + await this.storage.deleteToken(refreshTokenHash); + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) { + this.logger.warn("Refresh token record mismatch", { + tokenHash: refreshTokenHash.slice(0, 8), + }); + await this.revocation.invalidateTokenFamily(tokenRecord.familyId); + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + if (!tokenRecord.valid) { + this.logger.warn("Refresh token marked as invalid", { + tokenHash: refreshTokenHash.slice(0, 8), + }); + await this.revocation.invalidateTokenFamily(tokenRecord.familyId); + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null; + if (family && family.tokenHash !== refreshTokenHash) { + this.logger.warn("Refresh token does not match current family token", { + familyId: familyId.slice(0, 8), + tokenHash: refreshTokenHash.slice(0, 8), + }); + await this.revocation.invalidateTokenFamily(familyId); + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + } + + private async calculateTokenLifetime( + familyId: string, + refreshTokenHash: string + ): Promise<{ remainingSeconds: number; absoluteExpiresAt: string; createdAt: string }> { + const { family: familyData } = await this.storage.getTokenAndFamily(refreshTokenHash, familyId); + const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null; + + let remainingSeconds: number | null = null; + let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt; + + if (absoluteExpiresAt) { + const absMs = Date.parse(absoluteExpiresAt); + if (Number.isNaN(absMs)) { + absoluteExpiresAt = undefined; + } else { + remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000)); + } + } + + if (remainingSeconds === null) { + remainingSeconds = await this.calculateRemainingSecondsFromTtl(familyId, refreshTokenHash); + absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString(); + } + + if (!remainingSeconds || remainingSeconds <= 0) { + await this.revocation.invalidateTokenFamily(familyId); + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + const createdAt = family?.createdAt ?? new Date().toISOString(); + const expiresAt = + absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString(); + + return { remainingSeconds, absoluteExpiresAt: expiresAt, createdAt }; + } + + private async calculateRemainingSecondsFromTtl( + familyId: string, + refreshTokenHash: string + ): Promise { + const familyKey = `${this.storage.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`; + const tokenKey = `${this.storage.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`; + const ttl = await this.storage.getTtl(familyKey); + + if (typeof ttl === "number" && ttl > 0) { + return ttl; + } + + const tokenTtl = await this.storage.getTtl(tokenKey); + return typeof tokenTtl === "number" && tokenTtl > 0 + ? tokenTtl + : this.generator.parseExpiryToSeconds(this.generator.REFRESH_TOKEN_EXPIRY); + } + + private async performTokenRotation( + context: ValidatedTokenContext, + deviceInfo?: DeviceInfo + ): Promise<{ tokens: AuthTokens; user: UserAuth }> { + const user = await this.usersService.findByIdInternal(context.payload.userId); + if (!user) { + this.logger.warn("User not found during token refresh", { userId: context.payload.userId }); + throw new UnauthorizedException("User not found"); + } + + const userProfile = mapPrismaUserToDomain(user); + const { newAccessToken, newRefreshToken, newRefreshTokenHash } = + await this.generator.generateRotationTokenPair( + user, + context.familyId, + context.remainingSeconds, + deviceInfo + ); + + const refreshExpiresAt = + context.absoluteExpiresAt ?? + new Date(Date.now() + context.remainingSeconds * 1000).toISOString(); + + const rotationResult = await this.storage.atomicTokenRotation({ + oldTokenHash: context.refreshTokenHash, + newTokenHash: newRefreshTokenHash, + familyId: context.familyId, + userId: user.id, + deviceInfo, + createdAt: context.createdAt, + absoluteExpiresAt: refreshExpiresAt, + ttlSeconds: context.remainingSeconds, + }); + + if (!rotationResult.success) { + this.logger.warn("Atomic token rotation failed - possible concurrent refresh", { + error: rotationResult.error, + familyId: context.familyId.slice(0, 8), + tokenHash: context.refreshTokenHash.slice(0, 8), + }); + await this.revocation.invalidateTokenFamily(context.familyId); + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } + + const accessExpiresAt = new Date( + Date.now() + this.generator.parseExpiryToMs(this.generator.ACCESS_TOKEN_EXPIRY) + ).toISOString(); + + this.logger.debug("Refreshed token pair", { userId: context.payload.userId }); + + return { + tokens: { + accessToken: newAccessToken, + refreshToken: newRefreshToken, + expiresAt: accessExpiresAt, + refreshExpiresAt, + tokenType: "Bearer", + }, + user: userProfile, + }; + } + + private handleRefreshError(error: unknown): never { + if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) { + throw error; + } + + this.logger.error("Token refresh failed with unexpected error", { + error: error instanceof Error ? error.message : String(error), + }); + + if (this.redis.status !== "ready") { + this.logger.error("Redis unavailable for token refresh - failing closed for security", { + redisStatus: this.redis.status, + securityReason: "refresh_token_rotation_requires_redis", + }); + throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE); + } + + throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + } +} diff --git a/apps/bff/src/modules/auth/infra/token/token.service.ts b/apps/bff/src/modules/auth/infra/token/token.service.ts index acac5575..978372e4 100644 --- a/apps/bff/src/modules/auth/infra/token/token.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token.service.ts @@ -1,26 +1,14 @@ -import { - Injectable, - Inject, - UnauthorizedException, - ServiceUnavailableException, -} from "@nestjs/common"; +import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; -import { randomBytes, createHash } from "crypto"; import type { JWTPayload } from "jose"; import type { AuthTokens } from "@customer-portal/domain/auth"; import type { UserAuth, UserRole } from "@customer-portal/domain/customer"; -import { UsersService } from "@bff/modules/users/application/users.service.js"; -import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; -import { JoseJwtService } from "./jose-jwt.service.js"; -import { TokenStorageService } from "./token-storage.service.js"; +import { TokenGeneratorService } from "./token-generator.service.js"; +import { TokenRefreshService } from "./token-refresh.service.js"; import { TokenRevocationService } from "./token-revocation.service.js"; -const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable"; -const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token"; -const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable"; - export interface RefreshTokenPayload extends JWTPayload { userId: string; /** @@ -38,76 +26,46 @@ export interface RefreshTokenPayload extends JWTPayload { type: "refresh"; } -interface DeviceInfo { +export interface DeviceInfo { deviceId?: string | undefined; userAgent?: string | undefined; } -interface ValidatedTokenContext { - payload: RefreshTokenPayload; - familyId: string; - refreshTokenHash: string; - remainingSeconds: number; - absoluteExpiresAt: string; - createdAt: string; -} +const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable"; /** * Auth Token Service * - * Handles token generation and refresh operations. - * Delegates storage operations to TokenStorageService. - * Delegates revocation operations to TokenRevocationService. + * Thin orchestrator that delegates to focused services: + * - TokenGeneratorService: token creation + * - TokenRefreshService: refresh + rotation logic + * - TokenRevocationService: token revocation + * + * Preserves the existing public API so consumers don't need changes. */ @Injectable() export class AuthTokenService { - private readonly ACCESS_TOKEN_EXPIRY = "15m"; - private readonly REFRESH_TOKEN_EXPIRY = "7d"; - private readonly allowRedisFailOpen: boolean; private readonly requireRedisForTokens: boolean; private readonly maintenanceMode: boolean; private readonly maintenanceMessage: string; - private readonly jwtService: JoseJwtService; - private readonly configService: ConfigService; - private readonly storage: TokenStorageService; - private readonly revocation: TokenRevocationService; - private readonly redis: Redis; - private readonly logger: Logger; - private readonly usersService: UsersService; - - // eslint-disable-next-line max-params constructor( - jwtService: JoseJwtService, + private readonly generator: TokenGeneratorService, + private readonly refreshService: TokenRefreshService, + private readonly revocation: TokenRevocationService, configService: ConfigService, - storage: TokenStorageService, - revocation: TokenRevocationService, - @Inject("REDIS_CLIENT") redis: Redis, - @Inject(Logger) logger: Logger, - usersService: UsersService + @Inject("REDIS_CLIENT") private readonly redis: Redis, + @Inject(Logger) private readonly logger: Logger ) { - this.jwtService = jwtService; - this.configService = configService; - this.storage = storage; - this.revocation = revocation; - this.redis = redis; - this.logger = logger; - this.usersService = usersService; - - this.allowRedisFailOpen = - this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true"; this.requireRedisForTokens = - this.configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true"; - this.maintenanceMode = this.configService.get("AUTH_MAINTENANCE_MODE", "false") === "true"; - this.maintenanceMessage = this.configService.get( + configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true"; + this.maintenanceMode = configService.get("AUTH_MAINTENANCE_MODE", "false") === "true"; + this.maintenanceMessage = configService.get( "AUTH_MAINTENANCE_MESSAGE", "Authentication service is temporarily unavailable for maintenance. Please try again later." ); } - /** - * Check if authentication service is available - */ private checkServiceAvailability(): void { if (this.maintenanceMode) { this.logger.warn("Authentication service in maintenance mode", { @@ -129,113 +87,11 @@ export class AuthTokenService { * Generate a new token pair with refresh token rotation */ async generateTokenPair( - user: { - id: string; - email: string; - role?: UserRole; - }, + user: { id: string; email: string; role?: UserRole }, deviceInfo?: DeviceInfo ): Promise { - // Validate required user fields - if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) { - this.logger.error("Invalid user ID provided for token generation", { - userId: user.id, - }); - throw new Error("Invalid user ID for token generation"); - } - if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) { - this.logger.error("Invalid user email provided for token generation", { - userId: user.id, - }); - throw new Error("Invalid user email for token generation"); - } - this.checkServiceAvailability(); - - const accessTokenId = this.generateTokenId(); - const refreshFamilyId = this.generateTokenId(); - const refreshTokenId = this.generateTokenId(); - - // Create access token payload - const accessPayload = { - sub: user.id, - email: user.email, - role: user.role || "USER", - tokenId: accessTokenId, - type: "access", - }; - - // Create refresh token payload - const refreshPayload: RefreshTokenPayload = { - userId: user.id, - familyId: refreshFamilyId, - tokenId: refreshTokenId, - deviceId: deviceInfo?.deviceId, - userAgent: deviceInfo?.userAgent, - type: "refresh", - }; - - // Generate tokens - const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY); - - const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); - const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds); - - // Store refresh token in Redis - const refreshTokenHash = this.hashToken(refreshToken); - const refreshAbsoluteExpiresAt = new Date( - Date.now() + refreshExpirySeconds * 1000 - ).toISOString(); - - // Store refresh token in Redis - this is required for secure token rotation - if (this.redis.status !== "ready") { - this.logger.error("Redis not ready for token issuance", { - status: this.redis.status, - requireRedisForTokens: this.requireRedisForTokens, - }); - // Always fail if Redis is unavailable - tokens without storage cannot be - // securely rotated or revoked, creating a security vulnerability - throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE); - } - - try { - await this.storage.storeRefreshToken({ - userId: user.id, - familyId: refreshFamilyId, - refreshTokenHash, - deviceInfo, - refreshExpirySeconds, - absoluteExpiresAt: refreshAbsoluteExpiresAt, - }); - } catch (error) { - this.logger.error("Failed to store refresh token in Redis", { - error: error instanceof Error ? error.message : String(error), - userId: user.id, - }); - // Always fail on storage error - issuing tokens that can't be validated - // or rotated creates a security vulnerability - throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE); - } - - const accessExpiresAt = new Date( - Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) - ).toISOString(); - const refreshExpiresAt = refreshAbsoluteExpiresAt; - - this.logger.debug("Generated new token pair", { - userId: user.id, - accessTokenId, - refreshFamilyId, - refreshTokenId, - }); - - return { - accessToken, - refreshToken, - expiresAt: accessExpiresAt, - refreshExpiresAt, - tokenType: "Bearer", - }; + return this.generator.generateTokenPair(user, deviceInfo); } /** @@ -245,323 +101,8 @@ export class AuthTokenService { refreshToken: string, deviceInfo?: DeviceInfo ): Promise<{ tokens: AuthTokens; user: UserAuth }> { - if (!refreshToken) { - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - this.checkServiceAvailability(); - this.checkRedisForRefresh(); - - try { - const tokenContext = await this.validateAndExtractTokenContext(refreshToken); - return await this.performTokenRotation(tokenContext, deviceInfo); - } catch (error) { - return this.handleRefreshError(error); - } - } - - /** - * Check Redis availability for refresh operations - */ - private checkRedisForRefresh(): void { - if (!this.allowRedisFailOpen && this.redis.status !== "ready") { - this.logger.error("Redis unavailable for token refresh", { - redisStatus: this.redis.status, - }); - throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE); - } - } - - /** - * Validate refresh token and extract context for rotation - */ - private async validateAndExtractTokenContext( - refreshToken: string - ): Promise { - const payload = await this.verifyRefreshTokenPayload(refreshToken); - const familyId = this.extractFamilyId(payload); - const refreshTokenHash = this.hashToken(refreshToken); - - await this.validateStoredToken(refreshTokenHash, familyId, payload); - const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime( - familyId, - refreshTokenHash - ); - - return { - payload, - familyId, - refreshTokenHash, - remainingSeconds, - absoluteExpiresAt, - createdAt, - }; - } - - /** - * Verify JWT and validate payload structure - */ - private async verifyRefreshTokenPayload(refreshToken: string): Promise { - const payload = await 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(ERROR_INVALID_REFRESH_TOKEN); - } - if (!payload.userId || typeof payload.userId !== "string") { - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - if (!payload.tokenId || typeof payload.tokenId !== "string") { - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - - return payload; - } - - /** - * Extract family ID from payload (supports legacy tokens) - */ - private extractFamilyId(payload: RefreshTokenPayload): string { - return typeof payload.familyId === "string" && payload.familyId.length > 0 - ? payload.familyId - : payload.tokenId; - } - - /** - * Validate stored token data against payload - */ - private async validateStoredToken( - refreshTokenHash: string, - familyId: string, - payload: RefreshTokenPayload - ): Promise { - const { token: storedTokenData, family: familyData } = await this.storage.getTokenAndFamily( - refreshTokenHash, - familyId - ); - - if (!storedTokenData) { - this.logger.warn("Refresh token not found or expired", { - tokenHash: refreshTokenHash.slice(0, 8), - }); - await this.revocation.invalidateTokenFamily(familyId); - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - - const tokenRecord = this.storage.parseRefreshTokenRecord(storedTokenData); - if (!tokenRecord) { - this.logger.warn("Stored refresh token payload was invalid JSON", { - tokenHash: refreshTokenHash.slice(0, 8), - }); - await this.storage.deleteToken(refreshTokenHash); - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - - if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) { - this.logger.warn("Refresh token record mismatch", { - tokenHash: refreshTokenHash.slice(0, 8), - }); - await this.revocation.invalidateTokenFamily(tokenRecord.familyId); - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - - if (!tokenRecord.valid) { - this.logger.warn("Refresh token marked as invalid", { - tokenHash: refreshTokenHash.slice(0, 8), - }); - await this.revocation.invalidateTokenFamily(tokenRecord.familyId); - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - - const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null; - if (family && family.tokenHash !== refreshTokenHash) { - this.logger.warn("Refresh token does not match current family token", { - familyId: familyId.slice(0, 8), - tokenHash: refreshTokenHash.slice(0, 8), - }); - await this.revocation.invalidateTokenFamily(familyId); - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - } - - /** - * Calculate remaining lifetime for refresh token - */ - private async calculateTokenLifetime( - familyId: string, - refreshTokenHash: string - ): Promise<{ remainingSeconds: number; absoluteExpiresAt: string; createdAt: string }> { - const { family: familyData } = await this.storage.getTokenAndFamily(refreshTokenHash, familyId); - const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null; - - let remainingSeconds: number | null = null; - let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt; - - if (absoluteExpiresAt) { - const absMs = Date.parse(absoluteExpiresAt); - if (Number.isNaN(absMs)) { - absoluteExpiresAt = undefined; - } else { - remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000)); - } - } - - if (remainingSeconds === null) { - remainingSeconds = await this.calculateRemainingSecondsFromTtl(familyId, refreshTokenHash); - absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString(); - } - - if (!remainingSeconds || remainingSeconds <= 0) { - await this.revocation.invalidateTokenFamily(familyId); - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - - const createdAt = family?.createdAt ?? new Date().toISOString(); - const expiresAt = - absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString(); - - return { remainingSeconds, absoluteExpiresAt: expiresAt, createdAt }; - } - - /** - * Calculate remaining seconds from Redis TTL - */ - private async calculateRemainingSecondsFromTtl( - familyId: string, - refreshTokenHash: string - ): Promise { - const familyKey = `${this.storage.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`; - const tokenKey = `${this.storage.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`; - const ttl = await this.storage.getTtl(familyKey); - - if (typeof ttl === "number" && ttl > 0) { - return ttl; - } - - const tokenTtl = await this.storage.getTtl(tokenKey); - return typeof tokenTtl === "number" && tokenTtl > 0 - ? tokenTtl - : this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); - } - - /** - * Perform atomic token rotation and generate new token pair - */ - private async performTokenRotation( - context: ValidatedTokenContext, - deviceInfo?: DeviceInfo - ): Promise<{ tokens: AuthTokens; user: UserAuth }> { - const user = await this.usersService.findByIdInternal(context.payload.userId); - if (!user) { - this.logger.warn("User not found during token refresh", { userId: context.payload.userId }); - throw new UnauthorizedException("User not found"); - } - - const userProfile = mapPrismaUserToDomain(user); - const { newAccessToken, newRefreshToken, newRefreshTokenHash } = - await this.generateNewTokenPair(user, context, deviceInfo); - - const refreshExpiresAt = - context.absoluteExpiresAt ?? - new Date(Date.now() + context.remainingSeconds * 1000).toISOString(); - - const rotationResult = await this.storage.atomicTokenRotation({ - oldTokenHash: context.refreshTokenHash, - newTokenHash: newRefreshTokenHash, - familyId: context.familyId, - userId: user.id, - deviceInfo, - createdAt: context.createdAt, - absoluteExpiresAt: refreshExpiresAt, - ttlSeconds: context.remainingSeconds, - }); - - if (!rotationResult.success) { - this.logger.warn("Atomic token rotation failed - possible concurrent refresh", { - error: rotationResult.error, - familyId: context.familyId.slice(0, 8), - tokenHash: context.refreshTokenHash.slice(0, 8), - }); - await this.revocation.invalidateTokenFamily(context.familyId); - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); - } - - const accessExpiresAt = new Date( - Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) - ).toISOString(); - - this.logger.debug("Refreshed token pair", { userId: context.payload.userId }); - - return { - tokens: { - accessToken: newAccessToken, - refreshToken: newRefreshToken, - expiresAt: accessExpiresAt, - refreshExpiresAt, - tokenType: "Bearer", - }, - user: userProfile, - }; - } - - /** - * Generate new access and refresh tokens - */ - private async generateNewTokenPair( - user: { id: string; email: string; role: string }, - context: ValidatedTokenContext, - deviceInfo?: DeviceInfo - ): Promise<{ newAccessToken: string; newRefreshToken: string; newRefreshTokenHash: string }> { - const accessTokenId = this.generateTokenId(); - const refreshTokenId = this.generateTokenId(); - - const accessPayload = { - sub: user.id, - email: user.email, - role: user.role || "USER", - tokenId: accessTokenId, - type: "access", - }; - - const newRefreshPayload: RefreshTokenPayload = { - userId: user.id, - familyId: context.familyId, - tokenId: refreshTokenId, - deviceId: deviceInfo?.deviceId, - userAgent: deviceInfo?.userAgent, - type: "refresh", - }; - - const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY); - const newRefreshToken = await this.jwtService.sign(newRefreshPayload, context.remainingSeconds); - const newRefreshTokenHash = this.hashToken(newRefreshToken); - - return { newAccessToken, newRefreshToken, newRefreshTokenHash }; - } - - /** - * Handle errors during token refresh - */ - private handleRefreshError(error: unknown): never { - if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) { - throw error; - } - - this.logger.error("Token refresh failed with unexpected error", { - error: error instanceof Error ? error.message : String(error), - }); - - if (this.redis.status !== "ready") { - this.logger.error("Redis unavailable for token refresh - failing closed for security", { - redisStatus: this.redis.status, - securityReason: "refresh_token_rotation_requires_redis", - }); - throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE); - } - - throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN); + return this.refreshService.refreshTokens(refreshToken, deviceInfo); } /** @@ -591,34 +132,4 @@ export class AuthTokenService { async revokeAllUserTokens(userId: string): Promise { return this.revocation.revokeAllUserTokens(userId); } - - private generateTokenId(): string { - return randomBytes(32).toString("hex"); - } - - private hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); - } - - private parseExpiryToMs(expiry: string): number { - const unit = expiry.slice(-1); - const value = Number.parseInt(expiry.slice(0, -1)); - - switch (unit) { - case "s": - return value * 1000; - case "m": - return value * 60 * 1000; - case "h": - return value * 60 * 60 * 1000; - case "d": - return value * 24 * 60 * 60 * 1000; - default: - return 15 * 60 * 1000; - } - } - - private parseExpiryToSeconds(expiry: string): number { - return Math.floor(this.parseExpiryToMs(expiry) / 1000); - } } diff --git a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts index f3b7d339..a831f27b 100644 --- a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, + InternalServerErrorException, UnauthorizedException, NotFoundException, } from "@nestjs/common"; @@ -88,7 +89,7 @@ export class PasswordWorkflowService { } const prismaUser = await this.usersService.findByIdInternal(user.id); if (!prismaUser) { - throw new Error("Failed to load user after password setup"); + throw new InternalServerErrorException("Failed to load user after password setup"); } const userProfile = mapPrismaUserToDomain(prismaUser); @@ -106,7 +107,7 @@ export class PasswordWorkflowService { criticality: OperationCriticality.CRITICAL, context: `Set password for user ${user.id}`, logger: this.logger, - rethrow: [NotFoundException, BadRequestException], + rethrow: [NotFoundException, BadRequestException, InternalServerErrorException], fallbackMessage: "Failed to set password", } ); @@ -163,7 +164,7 @@ export class PasswordWorkflowService { await this.usersService.update(prismaUser.id, { passwordHash }); const freshUser = await this.usersService.findByIdInternal(prismaUser.id); if (!freshUser) { - throw new Error("Failed to load user after password reset"); + throw new InternalServerErrorException("Failed to load user after password reset"); } // Force re-login everywhere after password reset await this.tokenService.revokeAllUserTokens(freshUser.id); @@ -174,7 +175,7 @@ export class PasswordWorkflowService { criticality: OperationCriticality.CRITICAL, context: "Reset password", logger: this.logger, - rethrow: [BadRequestException], + rethrow: [BadRequestException, InternalServerErrorException], fallbackMessage: "Failed to reset password", } ); @@ -221,7 +222,7 @@ export class PasswordWorkflowService { await this.usersService.update(user.id, { passwordHash }); const prismaUser = await this.usersService.findByIdInternal(user.id); if (!prismaUser) { - throw new Error("Failed to load user after password change"); + throw new InternalServerErrorException("Failed to load user after password change"); } const userProfile = mapPrismaUserToDomain(prismaUser); @@ -251,7 +252,7 @@ export class PasswordWorkflowService { criticality: OperationCriticality.CRITICAL, context: `Change password for user ${user.id}`, logger: this.logger, - rethrow: [NotFoundException, BadRequestException], + rethrow: [NotFoundException, BadRequestException, InternalServerErrorException], fallbackMessage: "Failed to change password", } ); diff --git a/apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts b/apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts index fa1e81cd..467b16b3 100644 --- a/apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts +++ b/apps/bff/src/modules/auth/infra/workflows/steps/generate-auth-result.step.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { UsersService } from "@bff/modules/users/application/users.service.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; @@ -36,7 +36,7 @@ export class GenerateAuthResultStep { // Load fresh user from DB const freshUser = await this.usersService.findByIdInternal(userId); if (!freshUser) { - throw new Error("Failed to load created user"); + throw new InternalServerErrorException("Failed to load created user"); } // Log audit event diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts index eac8e708..fb06c4c9 100644 --- a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts @@ -3,6 +3,7 @@ import { ConflictException, Inject, Injectable, + InternalServerErrorException, UnauthorizedException, } from "@nestjs/common"; import { Logger } from "nestjs-pino"; @@ -110,7 +111,7 @@ export class WhmcsLinkWorkflowService { const prismaUser = await this.usersService.findByIdInternal(createdUser.id); if (!prismaUser) { - throw new Error("Failed to load newly linked user"); + throw new InternalServerErrorException("Failed to load newly linked user"); } const userProfile: User = mapPrismaUserToDomain(prismaUser); @@ -137,7 +138,12 @@ export class WhmcsLinkWorkflowService { criticality: OperationCriticality.CRITICAL, context: "WHMCS account linking", logger: this.logger, - rethrow: [BadRequestException, ConflictException, UnauthorizedException], + rethrow: [ + BadRequestException, + ConflictException, + InternalServerErrorException, + UnauthorizedException, + ], fallbackMessage: "Failed to link WHMCS account", } ); diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts index 7e9b5403..c019b71b 100644 --- a/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/whmcs-migration-workflow.service.ts @@ -23,6 +23,12 @@ import { GetStartedSessionService } from "../otp/get-started-session.service.js" import { SignupUserCreationService } from "./signup/signup-user-creation.service.js"; import { UpdateSalesforceFlagsStep, GenerateAuthResultStep } from "./steps/index.js"; +/** WHMCS client update payload for account migration (password + optional custom fields) */ +interface WhmcsMigrationClientUpdate { + password2: string; + customfields?: string; +} + /** * WHMCS Migration Workflow Service * @@ -226,15 +232,20 @@ export class WhmcsMigrationWorkflowService { if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth; if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender; - const updateData: Record = { + const updateData: WhmcsMigrationClientUpdate = { password2: password, }; if (Object.keys(customfieldsMap).length > 0) { - updateData["customfields"] = serializeWhmcsKeyValueMap(customfieldsMap); + updateData.customfields = serializeWhmcsKeyValueMap(customfieldsMap); } - await this.whmcsClientService.updateClient(clientId, updateData); + // customfields is sent as base64-encoded serialized PHP array to the WHMCS API, + // which differs from the parsed client schema type — cast is intentional + await this.whmcsClientService.updateClient( + clientId, + updateData as unknown as Parameters[1] + ); this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data"); } 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 54bcee57..df50b4fb 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 @@ -1,5 +1,6 @@ -import { Injectable, UnauthorizedException, Logger } from "@nestjs/common"; +import { Injectable, Inject, UnauthorizedException } from "@nestjs/common"; import type { CanActivate, ExecutionContext } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; import { Reflector } from "@nestjs/core"; import type { Request } from "express"; @@ -30,13 +31,12 @@ type RequestWithRoute = RequestWithCookies & { @Injectable() export class GlobalAuthGuard implements CanActivate { - private readonly logger = new Logger(GlobalAuthGuard.name); - constructor( private reflector: Reflector, private readonly tokenBlacklistService: TokenBlacklistService, private readonly jwtService: JoseJwtService, - private readonly usersService: UsersService + private readonly usersService: UsersService, + @Inject(Logger) private readonly logger: Logger ) {} async canActivate(context: ExecutionContext): Promise { diff --git a/apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts index c79d7a3d..558116c9 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts @@ -1,5 +1,6 @@ -import { Injectable, ForbiddenException, Logger } from "@nestjs/common"; +import { Injectable, Inject, ForbiddenException } from "@nestjs/common"; import type { CanActivate, ExecutionContext } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; import { Reflector } from "@nestjs/core"; import type { Request } from "express"; @@ -24,9 +25,10 @@ type RequestWithUser = Request & { user?: UserWithRole }; */ @Injectable() export class PermissionsGuard implements CanActivate { - private readonly logger = new Logger(PermissionsGuard.name); - - constructor(private readonly reflector: Reflector) {} + constructor( + private readonly reflector: Reflector, + @Inject(Logger) private readonly logger: Logger + ) {} canActivate(context: ExecutionContext): boolean { const requiredPermissions = this.reflector.getAllAndOverride( diff --git a/apps/bff/src/modules/notifications/notifications.service.ts b/apps/bff/src/modules/notifications/notifications.service.ts index 410fa400..1eba5a42 100644 --- a/apps/bff/src/modules/notifications/notifications.service.ts +++ b/apps/bff/src/modules/notifications/notifications.service.ts @@ -6,7 +6,12 @@ * and displayed alongside email notifications. */ -import { Injectable, Inject } from "@nestjs/common"; +import { + Injectable, + Inject, + BadRequestException, + InternalServerErrorException, +} from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { PrismaService } from "@bff/infra/database/prisma.service.js"; import type { Notification as PrismaNotification } from "@prisma/client"; @@ -57,7 +62,7 @@ export class NotificationService { async createNotification(params: CreateNotificationParams): Promise { const template = NOTIFICATION_TEMPLATES[params.type]; if (!template) { - throw new Error(`Unknown notification type: ${params.type}`); + throw new BadRequestException(`Unknown notification type: ${params.type}`); } // Calculate expiry date (30 days from now) @@ -116,7 +121,7 @@ export class NotificationService { userId: params.userId, type: params.type, }); - throw new Error("Failed to create notification"); + throw new InternalServerErrorException("Failed to create notification"); } } @@ -173,7 +178,7 @@ export class NotificationService { error: extractErrorMessage(error), userId, }); - throw new Error("Failed to get notifications"); + throw new InternalServerErrorException("Failed to get notifications"); } } @@ -221,7 +226,7 @@ export class NotificationService { notificationId, userId, }); - throw new Error("Failed to update notification"); + throw new InternalServerErrorException("Failed to update notification"); } } @@ -244,7 +249,7 @@ export class NotificationService { error: extractErrorMessage(error), userId, }); - throw new Error("Failed to update notifications"); + throw new InternalServerErrorException("Failed to update notifications"); } } @@ -268,7 +273,7 @@ export class NotificationService { notificationId, userId, }); - throw new Error("Failed to dismiss notification"); + throw new InternalServerErrorException("Failed to dismiss notification"); } } diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index 4965f484..f5a574ae 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -63,17 +63,7 @@ export class CheckoutController { req.user?.id ); - const session = await this.checkoutSessions.createSession(body, cart); - - return { - sessionId: session.sessionId, - expiresAt: session.expiresAt, - orderType: body.orderType, - cart: { - items: cart.items, - totals: cart.totals, - }, - }; + return this.checkoutSessions.createSessionWithResponse(body, cart); } @Get("session/:sessionId") @@ -84,16 +74,7 @@ export class CheckoutController { type: CheckoutSessionResponseDto, }) async getSession(@Param() params: CheckoutSessionIdParamDto) { - const session = await this.checkoutSessions.getSession(params.sessionId); - return { - sessionId: params.sessionId, - expiresAt: session.expiresAt, - orderType: session.request.orderType, - cart: { - items: session.cart.items, - totals: session.cart.totals, - }, - }; + return this.checkoutSessions.getSessionResponse(params.sessionId); } @Post("validate") diff --git a/apps/bff/src/modules/orders/services/checkout-session.service.ts b/apps/bff/src/modules/orders/services/checkout-session.service.ts index b206eb6c..58519a2f 100644 --- a/apps/bff/src/modules/orders/services/checkout-session.service.ts +++ b/apps/bff/src/modules/orders/services/checkout-session.service.ts @@ -5,6 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service.js"; import type { CheckoutBuildCartRequest, CheckoutCart, + CheckoutSessionResponse, CreateOrderRequest, OrderCreateResponse, } from "@customer-portal/domain/orders"; @@ -62,6 +63,43 @@ export class CheckoutSessionService { return record; } + /** + * Create a session and return the full response with cart summary. + */ + async createSessionWithResponse( + request: CheckoutBuildCartRequest, + cart: CheckoutCart + ): Promise { + const session = await this.createSession(request, cart); + + return { + sessionId: session.sessionId, + expiresAt: session.expiresAt, + orderType: request.orderType, + cart: { + items: cart.items, + totals: cart.totals, + }, + }; + } + + /** + * Get a session and return the full response with cart summary. + */ + async getSessionResponse(sessionId: string): Promise { + const session = await this.getSession(sessionId); + + return { + sessionId, + expiresAt: session.expiresAt, + orderType: session.request.orderType, + cart: { + items: session.cart.items, + totals: session.cart.totals, + }, + }; + } + async deleteSession(sessionId: string): Promise { const key = this.buildKey(sessionId); await this.cache.del(key); diff --git a/apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts b/apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts index 0bfdceb1..424dfd79 100644 --- a/apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts +++ b/apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts @@ -3,6 +3,45 @@ import { Logger } from "nestjs-pino"; import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers"; import type { ContactIdentityData } from "./sim-fulfillment.service.js"; +/** + * Configuration fields extracted from checkout payload and Salesforce order records. + * Used during fulfillment for SIM activation, MNP porting, and address data. + */ +export interface FulfillmentConfigurations { + simType?: string; + eid?: string; + activationType?: string; + scheduledAt?: string; + accessMode?: string; + // MNP porting fields + isMnp?: string; + mnpNumber?: string; + mnpExpiry?: string; + mnpPhone?: string; + mvnoAccountNumber?: string; + portingFirstName?: string; + portingLastName?: string; + portingFirstNameKatakana?: string; + portingLastNameKatakana?: string; + portingGender?: string; + portingDateOfBirth?: string; + // Nested MNP object (alternative format) + mnp?: Record; + // Address override from checkout + address?: Record; + [key: string]: unknown; +} + +/** + * Top-level payload passed into the fulfillment pipeline. + * Contains the order type and checkout configurations. + */ +export interface FulfillmentPayload { + orderType?: string; + configurations?: unknown; + [key: string]: unknown; +} + /** * Fulfillment Context Mapper Service * @@ -22,8 +61,8 @@ export class FulfillmentContextMapper { extractConfigurations( rawConfigurations: unknown, sfOrder?: SalesforceOrderRecord | null - ): Record { - const config: Record = {}; + ): FulfillmentConfigurations { + const config: FulfillmentConfigurations = {}; // Start with payload configurations if provided if (rawConfigurations && typeof rawConfigurations === "object") { @@ -49,7 +88,7 @@ export class FulfillmentContextMapper { } // MNP fields if (!config["isMnp"] && sfOrder.MNP_Application__c) { - config["isMnp"] = sfOrder.MNP_Application__c ? "true" : undefined; + config["isMnp"] = "true"; } if (!config["mnpNumber"] && sfOrder.MNP_Reservation_Number__c) { config["mnpNumber"] = sfOrder.MNP_Reservation_Number__c; diff --git a/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts b/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts index 5a28ac2b..62a4589b 100644 --- a/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts +++ b/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts @@ -6,7 +6,10 @@ import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order. import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import { SimFulfillmentService, type SimFulfillmentResult } from "./sim-fulfillment.service.js"; import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js"; -import { FulfillmentContextMapper } from "./fulfillment-context-mapper.service.js"; +import { + FulfillmentContextMapper, + type FulfillmentPayload, +} from "./fulfillment-context-mapper.service.js"; import type { OrderFulfillmentContext } from "./order-fulfillment-orchestrator.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; @@ -68,7 +71,7 @@ export class FulfillmentStepExecutors { */ async executeSimFulfillment( ctx: OrderFulfillmentContext, - payload: Record + payload: FulfillmentPayload ): Promise { if (ctx.orderDetails?.orderType !== "SIM") { return { activated: false, simType: "eSIM" as const }; @@ -183,7 +186,7 @@ export class FulfillmentStepExecutors { simFulfillmentResult?: SimFulfillmentResult ): WhmcsOrderItemMappingResult { if (!ctx.orderDetails) { - throw new Error("Order details are required for mapping"); + throw new FulfillmentException("Order details are required for mapping"); } // Use domain mapper directly - single transformation! diff --git a/apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts b/apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts index d33bc2ea..1caad34f 100644 --- a/apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts +++ b/apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts @@ -9,6 +9,8 @@ import type { } from "./order-fulfillment-orchestrator.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; +import { FulfillmentException } from "@bff/core/exceptions/domain-exceptions.js"; +import type { FulfillmentPayload } from "./fulfillment-context-mapper.service.js"; type WhmcsOrderItemMappingResult = ReturnType; @@ -45,10 +47,7 @@ export class FulfillmentStepFactory { * 8. sf_registration_complete * 9. opportunity_update */ - buildSteps( - context: OrderFulfillmentContext, - payload: Record - ): DistributedStep[] { + buildSteps(context: OrderFulfillmentContext, payload: FulfillmentPayload): DistributedStep[] { // Mutable state container for cross-step data const state: StepState = {}; @@ -106,7 +105,7 @@ export class FulfillmentStepFactory { private createSimFulfillmentStep( ctx: OrderFulfillmentContext, - payload: Record, + payload: FulfillmentPayload, state: StepState ): DistributedStep { return { @@ -163,7 +162,7 @@ export class FulfillmentStepFactory { description: "Create order in WHMCS", execute: this.createTrackedStep(ctx, "whmcs_create", async () => { if (!state.mappingResult) { - throw new Error("Mapping result is not available"); + throw new FulfillmentException("Mapping result is not available"); } const result = await this.executors.executeWhmcsCreate(ctx, state.mappingResult); // eslint-disable-next-line require-atomic-updates -- Sequential step execution, state is scoped to this transaction @@ -184,7 +183,7 @@ export class FulfillmentStepFactory { description: "Accept/provision order in WHMCS", execute: this.createTrackedStep(ctx, "whmcs_accept", async () => { if (!state.whmcsCreateResult) { - throw new Error("WHMCS create result is not available"); + throw new FulfillmentException("WHMCS create result is not available"); } const acceptResult = await this.executors.executeWhmcsAccept(ctx, state.whmcsCreateResult); // Update state with serviceIds from accept (services are created on accept, not on add) diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index 06676b32..f77449b5 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -4,7 +4,15 @@ import type { OrderBusinessValidation, UserMapping } from "@customer-portal/doma import { UsersService } from "@bff/modules/users/application/users.service.js"; import { SalesforceOrderFieldMapService } from "@bff/integrations/salesforce/config/order-field-map.service.js"; -function assignIfString(target: Record, key: string, value: unknown): void { +/** + * Salesforce Order record payload. + * Keys are dynamic (resolved from SalesforceOrderFieldMap at runtime), + * so a static interface is not possible. Known static keys include + * AccountId, EffectiveDate, Status, Pricebook2Id, and OpportunityId. + */ +type SalesforceOrderFields = Record; + +function assignIfString(target: SalesforceOrderFields, key: string, value: unknown): void { if (typeof value === "string" && value.trim().length > 0) { target[key] = value; } @@ -26,11 +34,11 @@ export class OrderBuilder { userMapping: UserMapping, pricebookId: string, userId: string - ): Promise> { + ): Promise { const today = new Date().toISOString().slice(0, 10); const orderFieldNames = this.orderFieldMap.fields.order; - const orderFields: Record = { + const orderFields: SalesforceOrderFields = { AccountId: userMapping.sfAccountId, EffectiveDate: today, Status: "Pending Review", @@ -59,7 +67,7 @@ export class OrderBuilder { } private addActivationFields( - orderFields: Record, + orderFields: SalesforceOrderFields, body: OrderBusinessValidation, fieldNames: SalesforceOrderFieldMapService["fields"]["order"] ): void { @@ -71,7 +79,7 @@ export class OrderBuilder { } private addInternetFields( - orderFields: Record, + orderFields: SalesforceOrderFields, body: OrderBusinessValidation, fieldNames: SalesforceOrderFieldMapService["fields"]["order"] ): void { @@ -80,7 +88,7 @@ export class OrderBuilder { } private addSimFields( - orderFields: Record, + orderFields: SalesforceOrderFields, body: OrderBusinessValidation, fieldNames: SalesforceOrderFieldMapService["fields"]["order"] ): void { @@ -116,7 +124,7 @@ export class OrderBuilder { } private async addAddressSnapshot( - orderFields: Record, + orderFields: SalesforceOrderFields, userId: string, body: OrderBusinessValidation, fieldNames: SalesforceOrderFieldMapService["fields"]["order"] 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 d8483928..690d0fb7 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 @@ -8,6 +8,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service. import { FulfillmentStepFactory } from "./fulfillment-step-factory.service.js"; import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js"; import type { SimFulfillmentResult } from "./sim-fulfillment.service.js"; +import type { FulfillmentPayload } from "./fulfillment-context-mapper.service.js"; import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { OrderDetails } from "@customer-portal/domain/orders"; @@ -65,7 +66,7 @@ export class OrderFulfillmentOrchestrator { */ async executeFulfillment( sfOrderId: string, - payload: Record, + payload: FulfillmentPayload, idempotencyKey: string ): Promise { const context = this.initializeContext(sfOrderId, idempotencyKey, payload); @@ -198,7 +199,7 @@ export class OrderFulfillmentOrchestrator { private initializeContext( sfOrderId: string, idempotencyKey: string, - payload: Record + payload: FulfillmentPayload ): OrderFulfillmentContext { const orderType = typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown"; return { diff --git a/apps/bff/src/modules/orders/services/order-idempotency.service.ts b/apps/bff/src/modules/orders/services/order-idempotency.service.ts index cf2532db..0f92f458 100644 --- a/apps/bff/src/modules/orders/services/order-idempotency.service.ts +++ b/apps/bff/src/modules/orders/services/order-idempotency.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { CacheService } from "@bff/infra/cache/cache.service.js"; import type { OrderCreateResponse } from "@customer-portal/domain/orders"; @@ -23,13 +24,17 @@ import type { OrderCreateResponse } from "@customer-portal/domain/orders"; export class OrderIdempotencyService { private readonly RESULT_PREFIX = "order-result:"; private readonly LOCK_PREFIX = "order-lock:"; - private readonly RESULT_TTL_SECONDS = 86400; // 24 hours - private readonly LOCK_TTL_SECONDS = 60; // 60 seconds for processing + private readonly RESULT_TTL_SECONDS: number; + private readonly LOCK_TTL_SECONDS: number; constructor( private readonly cache: CacheService, - @Inject(Logger) private readonly logger: Logger - ) {} + @Inject(Logger) private readonly logger: Logger, + config: ConfigService + ) { + this.RESULT_TTL_SECONDS = config.get("ORDER_RESULT_TTL_SECONDS", 86400); // 24 hours + this.LOCK_TTL_SECONDS = config.get("ORDER_LOCK_TTL_SECONDS", 60); // 60 seconds for processing + } /** * Check if an order was already created for this checkout session 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 04978785..77c398cb 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 @@ -2,6 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes import { Logger } from "nestjs-pino"; import { OrderPricebookService } from "./order-pricebook.service.js"; import { createOrderRequestSchema } from "@customer-portal/domain/orders"; +import { OrderValidationException } from "@bff/core/exceptions/domain-exceptions.js"; /** * Handles building order items from SKU data @@ -51,7 +52,9 @@ export class OrderItemBuilder { { sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, "PricebookEntry missing UnitPrice" ); - throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`); + throw new OrderValidationException( + `PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set` + ); } payload.push({ diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index eb8c976b..4f9c21e7 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -9,6 +9,7 @@ import { SimActivationException, OrderValidationException, } from "@bff/core/exceptions/domain-exceptions.js"; +import type { FulfillmentConfigurations } from "./fulfillment-context-mapper.service.js"; /** * Contact identity data for PA05-05 voice option registration @@ -36,7 +37,7 @@ export interface SimAssignmentDetails { export interface SimFulfillmentRequest { orderDetails: OrderDetails; - configurations: Record; + configurations: FulfillmentConfigurations; /** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */ assignedPhysicalSimId?: string; /** Voice Mail enabled from Order.SIM_Voice_Mail__c */ @@ -548,10 +549,10 @@ export class SimFulfillmentService { return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined; } - private extractMnpConfig(config: Record) { + private extractMnpConfig(config: FulfillmentConfigurations) { const nested = config["mnp"]; const hasNestedMnp = nested && typeof nested === "object"; - const source = hasNestedMnp ? (nested as Record) : config; + const source = hasNestedMnp ? nested : config; const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]); if (isMnpFlag && isMnpFlag.toLowerCase() !== "true") { diff --git a/apps/bff/src/modules/realtime/realtime-connection-limiter.service.ts b/apps/bff/src/modules/realtime/realtime-connection-limiter.service.ts index 869877f4..8c986109 100644 --- a/apps/bff/src/modules/realtime/realtime-connection-limiter.service.ts +++ b/apps/bff/src/modules/realtime/realtime-connection-limiter.service.ts @@ -1,4 +1,5 @@ import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; /** * Simple per-instance SSE connection limiter. @@ -11,9 +12,13 @@ import { Injectable } from "@nestjs/common"; */ @Injectable() export class RealtimeConnectionLimiterService { - private readonly maxPerUser = 3; + private readonly maxPerUser: number; private readonly counts = new Map(); + constructor(config: ConfigService) { + this.maxPerUser = config.get("REALTIME_MAX_CONNECTIONS_PER_USER", 3); + } + tryAcquire(userId: string): boolean { const current = this.counts.get(userId) ?? 0; if (current >= this.maxPerUser) { diff --git a/apps/bff/src/modules/services/application/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts index 160d3e3d..78900eec 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.service.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { + Injectable, + Inject, + BadRequestException, + InternalServerErrorException, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; @@ -88,7 +93,7 @@ export class InternetEligibilityService { ): Promise { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.sfAccountId) { - throw new Error("No Salesforce mapping found for current user"); + throw new BadRequestException("No Salesforce mapping found for current user"); } const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); @@ -151,7 +156,9 @@ export class InternetEligibilityService { sfAccountId, error: extractErrorMessage(error), }); - throw new Error("Failed to request availability check. Please try again later."); + throw new InternalServerErrorException( + "Failed to request availability check. Please try again later." + ); } } @@ -269,7 +276,7 @@ export class InternetEligibilityService { ); const update = this.sf.sobject("Account")?.update; if (!update) { - throw new Error("Salesforce Account update method not available"); + throw new InternalServerErrorException("Salesforce Account update method not available"); } const basePayload: { Id: string } & Record = { diff --git a/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts index 9182e686..43806bd4 100644 --- a/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts +++ b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts @@ -18,6 +18,7 @@ import { SalesforceCaseService } from "@bff/integrations/salesforce/services/sal import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import type { OrderPlacedCaseParams, EligibilityCheckCaseParams, @@ -296,7 +297,7 @@ export class WorkflowCaseManager { filename, error: extractErrorMessage(error), }); - throw new Error("Failed to create verification case"); + throw new SalesforceOperationException("Failed to create verification case"); } } diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts index 6fcb8eb5..cb56d99e 100644 --- a/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts @@ -1,4 +1,5 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; import type { CancellationPreview, CancellationStatus, @@ -28,15 +29,14 @@ function isValidPortalStage(stage: string): stage is PortalStage { @Injectable() export class CancellationService { - private readonly logger = new Logger(CancellationService.name); - // eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor constructor( private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator, private readonly opportunityService: SalesforceOpportunityService, private readonly internetCancellation: InternetCancellationService, private readonly simCancellation: SimCancellationService, - private readonly validationCoordinator: SubscriptionValidationCoordinator + private readonly validationCoordinator: SubscriptionValidationCoordinator, + @Inject(Logger) private readonly logger: Logger ) {} /** diff --git a/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts b/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts index 06aea603..05d92c56 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts @@ -1,5 +1,15 @@ -// Notification types for SIM management (BFF-specific, not domain types) -export type SimNotificationContext = Record; +/** + * Notification context for SIM management actions (BFF-specific, not domain types). + * Contains arbitrary action metadata (account, subscriptionId, error messages, etc.) + * that varies per action type. + */ +export interface SimNotificationContext { + account?: string; + subscriptionId?: number; + userId?: string; + error?: string; + [key: string]: unknown; +} export interface SimActionNotification { action: string; @@ -10,3 +20,19 @@ export interface SimActionNotification { export interface SimValidationResult { account: string; } + +/** Debug output for SIM subscription troubleshooting (admin-only endpoint) */ +export interface SimDebugInfo { + subscriptionId: number; + productName: string; + domain?: string | undefined; + orderNumber?: string | undefined; + isSimService: boolean; + groupName?: string | undefined; + status: string; + extractedAccount: string | null; + accountSource: string; + customFieldKeys: string[]; + customFields?: Record | null | undefined; + hint?: string | undefined; +} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-topup.service.ts index 9f389670..7b50d3ea 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-topup.service.ts @@ -5,6 +5,7 @@ import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade. import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SimValidationService } from "../sim-validation.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import type { SimTopUpRequest } from "@customer-portal/domain/sim"; import { SimBillingService } from "../queries/sim-billing.service.js"; import { SimNotificationService } from "../support/sim-notification.service.js"; @@ -272,7 +273,7 @@ export class SimTopUpService { // to ensure consistency across all failure scenarios. // For manual refunds, use the WHMCS admin panel or dedicated refund endpoints. - throw new Error( + throw new FreebitOperationException( `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` ); } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts index 1adc4bbc..7c31792d 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts @@ -8,6 +8,7 @@ import { SimCancellationService } from "./mutations/sim-cancellation.service.js" import { EsimManagementService } from "./mutations/esim-management.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import type { SimDebugInfo } from "../interfaces/sim-base.interface.js"; import { simInfoSchema } from "@customer-portal/domain/sim"; import type { SimInfo, @@ -182,10 +183,7 @@ export class SimOrchestrator { /** * Debug method to check subscription data for SIM services */ - async debugSimSubscription( - userId: string, - subscriptionId: number - ): Promise> { + async debugSimSubscription(userId: string, subscriptionId: number): Promise { return this.simValidation.debugSimSubscription(userId, subscriptionId); } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts index fc5e25eb..35c5ee3e 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts @@ -2,7 +2,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import type { SimValidationResult } from "../interfaces/sim-base.interface.js"; +import type { SimValidationResult, SimDebugInfo } from "../interfaces/sim-base.interface.js"; import { cleanSimAccount, extractSimAccountFromSubscription, @@ -83,10 +83,7 @@ export class SimValidationService { /** * Debug method to check subscription data for SIM services */ - async debugSimSubscription( - userId: string, - subscriptionId: number - ): Promise> { + async debugSimSubscription(userId: string, subscriptionId: number): Promise { try { const subscription = await this.subscriptionsService.getSubscriptionById( userId, diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-notification.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-notification.service.ts index 441cfdf5..65040d19 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-notification.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-notification.service.ts @@ -9,15 +9,20 @@ const ADMIN_EMAIL = "info@asolutions.co.jp"; const SIM_OPERATION_FAILURE_MESSAGE = "SIM operation failed. Please try again or contact support."; /** - * API call log structure for notification emails + * API call log structure for notification emails. + * Payloads are serialized to JSON in email bodies via JSON.stringify. */ export interface ApiCallLog { url: string; - senddata?: Record | string; - json?: Record | string; - result: Record | string; + senddata?: JsonObject | string; + json?: JsonObject | string; + result: JsonObject | string; } +/** JSON-serializable object (used in API call logs for email notifications) */ +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue | undefined }; + /** * Unified SIM notification service. * Handles all SIM-related email notifications including: @@ -326,8 +331,8 @@ Comments: ${params.comments || "N/A"}`; /** * Redact sensitive information from notification context */ - private redactSensitiveFields(context: Record): Record { - const sanitized: Record = {}; + private redactSensitiveFields(context: SimNotificationContext): SimNotificationContext { + const sanitized: SimNotificationContext = {}; for (const [key, value] of Object.entries(context)) { if (typeof key === "string" && key.toLowerCase().includes("password")) { sanitized[key] = "[REDACTED]"; diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts index 8a930574..9baf6bf9 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -15,6 +15,7 @@ import { SimPlanService } from "./services/mutations/sim-plan.service.js"; import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js"; import { EsimManagementService } from "./services/mutations/esim-management.service.js"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; +import type { SimDebugInfo } from "./interfaces/sim-base.interface.js"; import { AdminGuard } from "@bff/core/security/guards/admin.guard.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; @@ -126,7 +127,7 @@ export class SimController { async debugSimSubscription( @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto - ): Promise> { + ): Promise { return this.simOrchestrator.debugSimSubscription(req.user.id, params.id); } diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index 9afb133d..13ad6745 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -140,7 +140,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] { const activities: Activity[] = []; for (const invoice of invoices) { - const baseMetadata: Record = { + const baseMetadata: Record = { amount: invoice.total, currency: invoice.currency ?? DEFAULT_CURRENCY, }; @@ -179,7 +179,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] { function buildSubscriptionActivities(subscriptions: RecentSubscription[]): Activity[] { return subscriptions.map(subscription => { - const metadata: Record = { + const metadata: Record = { productName: subscription.productName, status: subscription.status, }; diff --git a/apps/bff/src/modules/users/queue/address-reconcile.queue.ts b/apps/bff/src/modules/users/queue/address-reconcile.queue.ts index 02836145..ff225e4e 100644 --- a/apps/bff/src/modules/users/queue/address-reconcile.queue.ts +++ b/apps/bff/src/modules/users/queue/address-reconcile.queue.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common"; import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { Logger } from "nestjs-pino"; @@ -100,7 +100,9 @@ export class AddressReconcileQueueService { error: errorMessage, }); - throw new Error(`Failed to queue address reconciliation: ${errorMessage}`); + throw new InternalServerErrorException( + `Failed to queue address reconciliation: ${errorMessage}` + ); } } diff --git a/apps/bff/src/modules/verification/residence-card.controller.ts b/apps/bff/src/modules/verification/residence-card.controller.ts index 252cf7d3..a0ad0244 100644 --- a/apps/bff/src/modules/verification/residence-card.controller.ts +++ b/apps/bff/src/modules/verification/residence-card.controller.ts @@ -14,7 +14,8 @@ import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { ResidenceCardService } from "./residence-card.service.js"; import type { ResidenceCardVerification } from "@customer-portal/domain/customer"; -const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB +const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB +const MAX_FILE_BYTES = Number(process.env["UPLOAD_MAX_FILE_BYTES"]) || DEFAULT_MAX_FILE_BYTES; const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]); type UploadedResidenceCard = { diff --git a/packages/domain/billing/providers/whmcs/raw.types.ts b/packages/domain/billing/providers/whmcs/raw.types.ts index f646dec6..c2be8f3d 100644 --- a/packages/domain/billing/providers/whmcs/raw.types.ts +++ b/packages/domain/billing/providers/whmcs/raw.types.ts @@ -160,7 +160,7 @@ const whmcsInvoiceCommonSchema = z companyname: s.optional(), currencycode: s.optional(), }) - .passthrough(); + .strip(); export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({ id: numberLike, @@ -290,6 +290,8 @@ export type WhmcsCurrency = z.infer; export const whmcsCurrenciesResponseSchema = z .object({ result: z.enum(["success", "error"]).optional(), + message: z.string().optional(), + errorcode: z.string().optional(), totalresults: z .string() .transform(val => Number.parseInt(val, 10)) @@ -300,8 +302,7 @@ export const whmcsCurrenciesResponseSchema = z currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema), }) .optional(), - // Allow any additional flat currency keys for flat format }) - .catchall(z.string().or(z.number())); + .strip(); export type WhmcsCurrenciesResponse = z.infer; diff --git a/packages/domain/checkout/schema.ts b/packages/domain/checkout/schema.ts index 31db8a84..d4c95731 100644 --- a/packages/domain/checkout/schema.ts +++ b/packages/domain/checkout/schema.ts @@ -41,7 +41,10 @@ export const cartItemSchema = z.object({ planSku: z.string().min(1, "Plan SKU is required"), planName: z.string().min(1, "Plan name is required"), addonSkus: z.array(z.string()).default([]), - configuration: z.record(z.string(), z.unknown()).default({}), + // Checkout configuration values are user-supplied key-value pairs (strings, numbers, booleans) + configuration: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])) + .default({}), pricing: z.object({ monthlyTotal: z.number().nonnegative(), oneTimeTotal: z.number().nonnegative(), diff --git a/packages/domain/common/errors.ts b/packages/domain/common/errors.ts index c7e133e1..5afaeff7 100644 --- a/packages/domain/common/errors.ts +++ b/packages/domain/common/errors.ts @@ -675,6 +675,7 @@ export const apiErrorSchema = z.object({ error: z.object({ code: z.string(), message: z.string(), + // Intentionally z.unknown() values — error details vary by error type and may contain nested objects details: z.record(z.string(), z.unknown()).optional(), }), }); diff --git a/packages/domain/common/providers/salesforce.ts b/packages/domain/common/providers/salesforce.ts index fb4537bf..557d84fa 100644 --- a/packages/domain/common/providers/salesforce.ts +++ b/packages/domain/common/providers/salesforce.ts @@ -13,6 +13,8 @@ import { z } from "zod"; /** * Base schema for Salesforce SOQL query result */ +// Base schema uses z.unknown() for records because it is always overridden by +// salesforceResponseSchema() which extends records with the caller's typed schema. const salesforceResponseBaseSchema = z.object({ totalSize: z.number(), done: z.boolean(), diff --git a/packages/domain/common/providers/whmcs-utils/schema.ts b/packages/domain/common/providers/whmcs-utils/schema.ts index e460f7e9..bd1a22bd 100644 --- a/packages/domain/common/providers/whmcs-utils/schema.ts +++ b/packages/domain/common/providers/whmcs-utils/schema.ts @@ -26,3 +26,31 @@ export const whmcsNumberLike = z.union([z.number(), z.string()]); * Use for WHMCS boolean flags that arrive in varying formats. */ export const whmcsBooleanLike = z.union([z.boolean(), z.number(), z.string()]); + +/** + * Coercing required number — accepts number or numeric string, always outputs number. + * Use for WHMCS fields that must be a number but may arrive as a string. + */ +export const whmcsRequiredNumber = z.preprocess(value => { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + } + return value; +}, z.number()); + +/** + * Coercing optional number — accepts number, numeric string, null, undefined, or empty string. + * Returns undefined for missing/empty values. + * Use for WHMCS fields that are optional numbers but may arrive as strings. + */ +export const whmcsOptionalNumber = z.preprocess((value): number | undefined => { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}, z.number().optional()); diff --git a/packages/domain/common/schema.ts b/packages/domain/common/schema.ts index 2112f4fa..4c0d18c8 100644 --- a/packages/domain/common/schema.ts +++ b/packages/domain/common/schema.ts @@ -46,8 +46,7 @@ export const statusEnum = z.enum(["active", "inactive", "pending", "suspended"]) export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]); export const categoryEnum = z.enum(["technical", "billing", "account", "general"]); -export const billingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Onetime", "Free"]); -export const subscriptionBillingCycleEnum = z.enum([ +export const billingCycleSchema = z.enum([ "Monthly", "Quarterly", "Semi-Annually", @@ -57,6 +56,7 @@ export const subscriptionBillingCycleEnum = z.enum([ "One-time", "Free", ]); +export type BillingCycle = z.infer; // ============================================================================ // Salesforce and SOQL Validation Schemas @@ -123,6 +123,7 @@ export const apiErrorResponseSchema = z.object({ error: z.object({ code: z.string(), message: z.string(), + // Intentionally z.unknown() — error details vary by error type details: z.unknown().optional(), }), }); diff --git a/packages/domain/customer/providers/whmcs/mapper.ts b/packages/domain/customer/providers/whmcs/mapper.ts index 9a01d49e..2fef2657 100644 --- a/packages/domain/customer/providers/whmcs/mapper.ts +++ b/packages/domain/customer/providers/whmcs/mapper.ts @@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined { country: client.country ?? null, countryCode: client.countrycode ?? null, phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null, - phoneCountryCode: client.phonecc == null ? null : String(client.phonecc), + phoneCountryCode: client.phonecc ?? null, }); const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== ""); diff --git a/packages/domain/customer/providers/whmcs/raw.types.ts b/packages/domain/customer/providers/whmcs/raw.types.ts index 3b91dd6a..8f5a007d 100644 --- a/packages/domain/customer/providers/whmcs/raw.types.ts +++ b/packages/domain/customer/providers/whmcs/raw.types.ts @@ -67,7 +67,7 @@ export const whmcsCustomFieldSchema = z name: z.string().optional(), type: z.string().optional(), }) - .passthrough(); + .strip(); export const whmcsUserSchema = z .object({ @@ -76,7 +76,7 @@ export const whmcsUserSchema = z email: z.string(), is_owner: booleanLike.optional(), }) - .passthrough(); + .strip(); export const whmcsEmailPreferencesSchema = z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) @@ -89,13 +89,13 @@ const customFieldsSchema = z .object({ customfield: z.union([whmcsCustomFieldSchema, z.array(whmcsCustomFieldSchema)]), }) - .passthrough(), + .strip(), ]) .optional(); const usersSchema = z .object({ user: z.union([whmcsUserSchema, z.array(whmcsUserSchema)]) }) - .passthrough() + .strip() .optional(); export const whmcsClientSchema = z @@ -142,7 +142,7 @@ export const whmcsClientSchema = z customfields: customFieldsSchema, users: usersSchema, }) - .catchall(z.unknown()); + .strip(); export const whmcsClientStatsSchema = z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) @@ -155,7 +155,7 @@ export const whmcsClientResponseSchema = z client: whmcsClientSchema, stats: whmcsClientStatsSchema, }) - .catchall(z.unknown()); + .strip(); export type WhmcsCustomField = z.infer; export type WhmcsUser = z.infer; diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts index b9d36d06..f5fcdf78 100644 --- a/packages/domain/customer/schema.ts +++ b/packages/domain/customer/schema.ts @@ -13,6 +13,10 @@ import { z } from "zod"; import { countryCodeSchema } from "../common/schema.js"; +import { + whmcsNumberLike as numberLike, + whmcsBooleanLike as booleanLike, +} from "../common/providers/whmcs-utils/index.js"; import { whmcsClientSchema as whmcsRawClientSchema, whmcsCustomFieldSchema, @@ -23,8 +27,6 @@ import { // ============================================================================ const stringOrNull = z.union([z.string(), z.null()]); -const booleanLike = z.union([z.boolean(), z.number(), z.string()]); -const numberLike = z.union([z.number(), z.string()]); /** * Normalize boolean-like values to actual booleans @@ -172,30 +174,33 @@ const subUserSchema = z */ const statsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(); -const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema); +const normalizeCustomFields = (input: unknown): unknown => { + if (!input) return input; + if (Array.isArray(input)) return input; + if (typeof input === "object" && input !== null && "customfield" in input) { + const cf = (input as Record)["customfield"]; + if (Array.isArray(cf)) return cf; + return cf ? [cf] : input; + } + return input; +}; const whmcsCustomFieldsSchema = z - .union([ - z.record(z.string(), z.string()), - whmcsRawCustomFieldsArraySchema, - z - .object({ - customfield: z.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema]).optional(), - }) - .passthrough(), - ]) + .preprocess(normalizeCustomFields, z.array(whmcsCustomFieldSchema).optional()) .optional(); -const whmcsUsersSchema = z - .union([ - z.array(subUserSchema), - z - .object({ - user: z.union([subUserSchema, z.array(subUserSchema)]).optional(), - }) - .passthrough(), - ]) - .optional(); +const normalizeUsers = (input: unknown): unknown => { + if (!input) return input; + if (Array.isArray(input)) return input; + if (typeof input === "object" && input !== null && "user" in input) { + const u = (input as Record)["user"]; + if (Array.isArray(u)) return u; + return u ? [u] : input; + } + return input; +}; + +const whmcsUsersSchema = z.preprocess(normalizeUsers, z.array(subUserSchema).optional()).optional(); /** * WhmcsClient - Full WHMCS client data diff --git a/packages/domain/dashboard/schema.ts b/packages/domain/dashboard/schema.ts index d06dd7be..2ed2be53 100644 --- a/packages/domain/dashboard/schema.ts +++ b/packages/domain/dashboard/schema.ts @@ -18,7 +18,10 @@ export const activitySchema = z.object({ description: z.string().optional(), date: z.string(), relatedId: z.number().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), + // Activity metadata varies by activity type (invoice, service, etc.) + metadata: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])) + .optional(), }); export const invoiceActivityMetadataSchema = z @@ -68,7 +71,10 @@ export const dashboardSummarySchema = z.object({ export const dashboardErrorSchema = z.object({ code: z.string(), message: z.string(), - details: z.record(z.string(), z.unknown()).optional(), + // Error details vary by error type; values are primitive scalars + details: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])) + .optional(), }); export const activityFilterSchema = z.enum(["all", "billing", "orders", "support"]); diff --git a/packages/domain/orders/providers/salesforce/raw.types.ts b/packages/domain/orders/providers/salesforce/raw.types.ts index 1ae4494b..5a4a8207 100644 --- a/packages/domain/orders/providers/salesforce/raw.types.ts +++ b/packages/domain/orders/providers/salesforce/raw.types.ts @@ -18,8 +18,32 @@ export const salesforceOrderItemRecordSchema = z.object({ UnitPrice: z.number().nullable().optional(), TotalPrice: z.number().nullable().optional(), PricebookEntryId: z.string().nullable().optional(), - // Note: PricebookEntry nested object comes from catalog domain - PricebookEntry: z.unknown().nullable().optional(), + // Minimal PricebookEntry shape for fields used by the order mapper. + // Full schema lives in services/providers/salesforce/raw.types.ts. + PricebookEntry: z + .object({ + Id: z.string(), + Product2Id: z.string().nullable().optional(), + Product2: z + .object({ + Id: z.string(), + Name: z.string().optional(), + StockKeepingUnit: z.string().optional(), + Item_Class__c: z.string().nullable().optional(), + Billing_Cycle__c: z.string().nullable().optional(), + Internet_Offering_Type__c: z.string().nullable().optional(), + Internet_Plan_Tier__c: z.string().nullable().optional(), + VPN_Region__c: z.string().nullable().optional(), + Bundled_Addon__c: z.string().nullable().optional(), + Is_Bundled_Addon__c: z.boolean().nullable().optional(), + WH_Product_ID__c: z.number().nullable().optional(), + WH_Product_Name__c: z.string().nullable().optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), Billing_Cycle__c: z.string().nullable().optional(), WHMCS_Service_ID__c: z.string().nullable().optional(), CreatedDate: z.string().optional(), @@ -114,7 +138,7 @@ export const salesforceOrderProvisionEventPayloadSchema = z OrderId__c: z.string().optional(), OrderId: z.string().optional(), }) - .passthrough(); + .strip(); export type SalesforceOrderProvisionEventPayload = z.infer< typeof salesforceOrderProvisionEventPayloadSchema @@ -128,7 +152,7 @@ export const salesforceOrderProvisionEventSchema = z payload: salesforceOrderProvisionEventPayloadSchema, replayId: z.number().optional(), }) - .passthrough(); + .strip(); export type SalesforceOrderProvisionEvent = z.infer; @@ -152,7 +176,7 @@ export const salesforcePubSubErrorMetadataSchema = z .object({ "error-code": z.array(z.string()).optional(), }) - .passthrough(); + .strip(); export type SalesforcePubSubErrorMetadata = z.infer; @@ -164,7 +188,7 @@ export const salesforcePubSubErrorSchema = z details: z.string().optional(), metadata: salesforcePubSubErrorMetadataSchema.optional(), }) - .passthrough(); + .strip(); export type SalesforcePubSubError = z.infer; @@ -203,6 +227,7 @@ export const salesforcePubSubCallbackSchema = z.object({ data: z.union([ salesforceOrderProvisionEventSchema, salesforcePubSubErrorSchema, + // Fallback for unknown Pub/Sub event types whose shape cannot be predicted z.record(z.string(), z.unknown()), z.null(), ]), diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index 751d92c9..1fa14f0a 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -187,7 +187,7 @@ export const orderSelectionsSchema = z }) .optional(), }) - .passthrough(); + .strip(); export type OrderSelections = z.infer; diff --git a/packages/domain/payments/providers/whmcs/raw.types.ts b/packages/domain/payments/providers/whmcs/raw.types.ts index 162c5427..cf614f08 100644 --- a/packages/domain/payments/providers/whmcs/raw.types.ts +++ b/packages/domain/payments/providers/whmcs/raw.types.ts @@ -48,7 +48,9 @@ export const whmcsPaymentGatewayRawSchema = z.object({ display_name: z.string().optional(), type: z.string(), visible: z.union([z.boolean(), z.number(), z.string()]).optional(), - configuration: z.record(z.string(), z.unknown()).optional(), + configuration: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])) + .optional(), }); export type WhmcsPaymentGatewayRaw = z.infer; diff --git a/packages/domain/payments/schema.ts b/packages/domain/payments/schema.ts index a6623791..d4b6daca 100644 --- a/packages/domain/payments/schema.ts +++ b/packages/domain/payments/schema.ts @@ -47,7 +47,10 @@ export const paymentGatewaySchema = z.object({ displayName: z.string(), type: paymentGatewayTypeSchema, isActive: z.boolean(), - configuration: z.record(z.string(), z.unknown()).optional(), + // Gateway configuration varies by provider; values are primitive scalars + configuration: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])) + .optional(), }); export const paymentGatewayListSchema = z.object({ diff --git a/packages/domain/services/contract.ts b/packages/domain/services/contract.ts index b12f1b43..ac469211 100644 --- a/packages/domain/services/contract.ts +++ b/packages/domain/services/contract.ts @@ -43,7 +43,7 @@ export interface SalesforceProductFieldMap { export interface PricingTier { name: string; price: number; - billingCycle: "Monthly" | "Onetime" | "Annual"; + billingCycle: "Monthly" | "One-time" | "Annually"; description?: string; features?: string[]; isRecommended?: boolean; diff --git a/packages/domain/subscriptions/providers/whmcs/raw.types.ts b/packages/domain/subscriptions/providers/whmcs/raw.types.ts index e22a89d2..5e246ae1 100644 --- a/packages/domain/subscriptions/providers/whmcs/raw.types.ts +++ b/packages/domain/subscriptions/providers/whmcs/raw.types.ts @@ -11,27 +11,10 @@ import { z } from "zod"; import { whmcsString as s, whmcsNumberLike as numberLike, + whmcsRequiredNumber as normalizeRequiredNumber, + whmcsOptionalNumber as normalizeOptionalNumber, } from "../../../common/providers/whmcs-utils/index.js"; -const normalizeRequiredNumber = z.preprocess(value => { - if (typeof value === "number") return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : value; - } - return value; -}, z.number()); - -const normalizeOptionalNumber = z.preprocess((value): number | undefined => { - if (value === undefined || value === null || value === "") return undefined; - if (typeof value === "number") return value; - if (typeof value === "string") { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return undefined; -}, z.number().optional()); - const optionalStringField = () => z .union([z.string(), z.number()]) diff --git a/packages/domain/subscriptions/schema.ts b/packages/domain/subscriptions/schema.ts index 80eb610c..c50f745f 100644 --- a/packages/domain/subscriptions/schema.ts +++ b/packages/domain/subscriptions/schema.ts @@ -6,6 +6,8 @@ import { z } from "zod"; +import { billingCycleSchema } from "../common/schema.js"; + // Subscription Status Schema export const subscriptionStatusSchema = z.enum([ "Active", @@ -17,17 +19,8 @@ export const subscriptionStatusSchema = z.enum([ "Completed", ]); -// Subscription Cycle Schema -export const subscriptionCycleSchema = z.enum([ - "Monthly", - "Quarterly", - "Semi-Annually", - "Annually", - "Biennially", - "Triennially", - "One-time", - "Free", -]); +// Subscription Cycle Schema — re-exported from common +export const subscriptionCycleSchema = billingCycleSchema; // Subscription Schema export const subscriptionSchema = z.object({ @@ -105,7 +98,8 @@ export const subscriptionStatsSchema = z.object({ */ export const simActionResponseSchema = z.object({ message: z.string(), - data: z.unknown().optional(), + /** Action-specific payload — varies by SIM/internet operation (top-up, cancellation, etc.) */ + data: z.record(z.string(), z.unknown()).optional(), }); /**