diff --git a/apps/bff/src/core/constants/http.constants.ts b/apps/bff/src/core/constants/http.constants.ts new file mode 100644 index 00000000..485646e3 --- /dev/null +++ b/apps/bff/src/core/constants/http.constants.ts @@ -0,0 +1,13 @@ +/** + * Shared HTTP-related constants for controller responses. + */ + +/** Standard Cache-Control header values */ +export const CACHE_CONTROL = { + /** Public resources cached for 1 hour */ + PUBLIC_1H: "public, max-age=3600", + /** Private (per-user) resources cached for 5 minutes */ + PRIVATE_5M: "private, max-age=300", + /** Private (per-user) resources cached for 1 minute */ + PRIVATE_1M: "private, max-age=60", +} as const; diff --git a/apps/bff/src/core/constants/index.ts b/apps/bff/src/core/constants/index.ts new file mode 100644 index 00000000..469e4f2f --- /dev/null +++ b/apps/bff/src/core/constants/index.ts @@ -0,0 +1 @@ +export * from "./http.constants.js"; diff --git a/apps/bff/src/core/http/exception.filter.ts b/apps/bff/src/core/http/exception.filter.ts index ad94b934..cd5b0dd0 100644 --- a/apps/bff/src/core/http/exception.filter.ts +++ b/apps/bff/src/core/http/exception.filter.ts @@ -86,16 +86,16 @@ export class UnifiedExceptionFilter implements ExceptionFilter { const userMessage = this.getUserMessage(errorCode, originalMessage, fieldErrors); // Log the error - this.logError(errorCode, originalMessage, status, errorContext, exception); + this.logError({ errorCode, originalMessage, status, context: errorContext, exception }); // Build and send response - const errorResponse = this.buildErrorResponse( + const errorResponse = this.buildErrorResponse({ errorCode, - userMessage, + message: userMessage, status, - errorContext, - fieldErrors - ); + context: errorContext, + fieldErrors, + }); response.status(status).json(errorResponse); } @@ -298,34 +298,36 @@ export class UnifiedExceptionFilter implements ExceptionFilter { private buildErrorContext( request: Request & { user?: { id?: string }; requestId?: string } ): ErrorContext { - const userAgentHeader = request.headers["user-agent"]; - const userAgent = - typeof userAgentHeader === "string" - ? userAgentHeader - : Array.isArray(userAgentHeader) - ? userAgentHeader[0] - : undefined; - return { requestId: request.requestId ?? this.generateRequestId(), userId: request.user?.id, method: request.method, path: request.url, - userAgent, + userAgent: this.extractUserAgent(request.headers["user-agent"]), ip: request.ip, }; } + /** + * Extract user-agent string from request header + */ + private extractUserAgent(header: string | string[] | undefined): string | undefined { + if (typeof header === "string") return header; + if (Array.isArray(header)) return header[0]; + return undefined; + } + /** * Log error with appropriate level based on metadata */ - private logError( - errorCode: ErrorCodeType, - originalMessage: string, - status: number, - context: ErrorContext, - exception: unknown - ): void { + private logError(params: { + errorCode: ErrorCodeType; + originalMessage: string; + status: number; + context: ErrorContext; + exception: unknown; + }): void { + const { errorCode, originalMessage, status, context, exception } = params; const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; const logData = { @@ -370,13 +372,14 @@ export class UnifiedExceptionFilter implements ExceptionFilter { /** * Build standard error response */ - private buildErrorResponse( - errorCode: ErrorCodeType, - message: string, - status: number, - context: ErrorContext, - fieldErrors?: Record - ): ApiError { + private buildErrorResponse(params: { + errorCode: ErrorCodeType; + message: string; + status: number; + context: ErrorContext; + fieldErrors?: Record | undefined; + }): ApiError { + const { errorCode, message, status, context, fieldErrors } = params; const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; return { diff --git a/apps/bff/src/core/security/controllers/csrf.controller.ts b/apps/bff/src/core/security/controllers/csrf.controller.ts index d8c036ff..3b6869b8 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.ts @@ -12,6 +12,8 @@ export type AuthenticatedRequest = Request & { sessionID?: string; }; +const USER_AGENT_HEADER = "user-agent"; + @Controller("security/csrf") export class CsrfController { constructor( @@ -23,69 +25,13 @@ export class CsrfController { @Public() @Get("token") getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { - const sessionId = this.extractSessionId(req) || undefined; - const userId = req.user?.id; - - // Generate new CSRF token - const tokenData = this.csrfService.generateToken(undefined, sessionId, userId); - const isProduction = this.configService.get("NODE_ENV") === "production"; - const cookieName = this.csrfService.getCookieName(); - - // Set CSRF secret in secure cookie - res.cookie(cookieName, tokenData.secret, { - httpOnly: true, - secure: isProduction, - sameSite: "strict", - maxAge: this.csrfService.getTokenTtl(), - path: "/api", - }); - - this.logger.debug("CSRF token requested", { - userId, - sessionId, - userAgent: req.get("user-agent"), - ip: req.ip, - }); - - return res.json({ - success: true, - token: tokenData.token, - expiresAt: tokenData.expiresAt.toISOString(), - }); + return this.generateAndSetToken(req, res, "CSRF token requested"); } @Public() @Post("refresh") refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { - const sessionId = this.extractSessionId(req) || undefined; - const userId = req.user?.id; - - // Generate new CSRF token - const tokenData = this.csrfService.generateToken(undefined, sessionId, userId); - const isProduction = this.configService.get("NODE_ENV") === "production"; - const cookieName = this.csrfService.getCookieName(); - - // Set CSRF secret in secure cookie - res.cookie(cookieName, tokenData.secret, { - httpOnly: true, - secure: isProduction, - sameSite: "strict", - maxAge: this.csrfService.getTokenTtl(), - path: "/api", - }); - - this.logger.debug("CSRF token refreshed", { - userId, - sessionId, - userAgent: req.get("user-agent"), - ip: req.ip, - }); - - return res.json({ - success: true, - token: tokenData.token, - expiresAt: tokenData.expiresAt.toISOString(), - }); + return this.generateAndSetToken(req, res, "CSRF token refreshed"); } @UseGuards(AdminGuard) @@ -95,7 +41,7 @@ export class CsrfController { this.logger.debug("CSRF stats requested", { userId, - userAgent: req.get("user-agent"), + userAgent: req.get(USER_AGENT_HEADER), ip: req.ip, }); @@ -107,6 +53,36 @@ export class CsrfController { }; } + private generateAndSetToken(req: AuthenticatedRequest, res: Response, logMessage: string) { + const sessionId = this.extractSessionId(req) || undefined; + const userId = req.user?.id; + + const tokenData = this.csrfService.generateToken(undefined, sessionId, userId); + const isProduction = this.configService.get("NODE_ENV") === "production"; + const cookieName = this.csrfService.getCookieName(); + + res.cookie(cookieName, tokenData.secret, { + httpOnly: true, + secure: isProduction, + sameSite: "strict", + maxAge: this.csrfService.getTokenTtl(), + path: "/api", + }); + + this.logger.debug(logMessage, { + userId, + sessionId, + userAgent: req.get(USER_AGENT_HEADER), + ip: req.ip, + }); + + return res.json({ + success: true, + token: tokenData.token, + expiresAt: tokenData.expiresAt.toISOString(), + }); + } + private extractSessionId(req: AuthenticatedRequest): string | null { const cookies = req.cookies as Record | undefined; const sessionCookie = cookies?.["session-id"] ?? cookies?.["connect.sid"]; diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index 03e5ffe3..d4caae66 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -23,6 +23,8 @@ type CsrfRequest = Omit & { cookies: CookieJar; }; +const USER_AGENT_HEADER = "user-agent"; + /** * CSRF Protection Middleware * Implements a double-submit cookie pattern: @@ -101,7 +103,7 @@ export class CsrfMiddleware implements NestMiddleware { this.logger.warn("CSRF validation failed - missing token", { method: req.method, path: req.path, - userAgent: req.get("user-agent"), + userAgent: req.get(USER_AGENT_HEADER), ip: req.ip, }); throw new ForbiddenException("CSRF token required"); @@ -111,7 +113,7 @@ export class CsrfMiddleware implements NestMiddleware { this.logger.warn("CSRF validation failed - missing secret cookie", { method: req.method, path: req.path, - userAgent: req.get("user-agent"), + userAgent: req.get(USER_AGENT_HEADER), ip: req.ip, }); throw new ForbiddenException("CSRF secret required"); @@ -129,7 +131,7 @@ export class CsrfMiddleware implements NestMiddleware { reason: validationResult.reason, method: req.method, path: req.path, - userAgent: req.get("user-agent"), + userAgent: req.get(USER_AGENT_HEADER), ip: req.ip, userId, sessionId, diff --git a/apps/bff/src/core/utils/array.util.ts b/apps/bff/src/core/utils/array.util.ts index 22953d59..f7548af0 100644 --- a/apps/bff/src/core/utils/array.util.ts +++ b/apps/bff/src/core/utils/array.util.ts @@ -65,3 +65,12 @@ export function uniqueBy(array: T[], keyFn: (item: T) => K): T[] { return true; }); } + +/** + * Normalize a value that may be a single item, an array, or nullish into an array. + * Handles the common WHMCS pattern where API responses return either a single object or an array. + */ +export function normalizeToArray(value: T | T[] | undefined | null): T[] { + if (Array.isArray(value)) return value; + return value ? [value] : []; +} diff --git a/apps/bff/src/core/utils/index.ts b/apps/bff/src/core/utils/index.ts index 0c1cee6c..fe836a68 100644 --- a/apps/bff/src/core/utils/index.ts +++ b/apps/bff/src/core/utils/index.ts @@ -5,11 +5,16 @@ */ // Array utilities -export { chunkArray, groupBy, uniqueBy } from "./array.util.js"; +export { chunkArray, groupBy, uniqueBy, normalizeToArray } from "./array.util.js"; // Error utilities export { extractErrorMessage } from "./error.util.js"; -export { safeOperation, OperationCriticality, type SafeOperationOptions, type SafeOperationResult } from "./safe-operation.util.js"; +export { + safeOperation, + OperationCriticality, + type SafeOperationOptions, + type SafeOperationResult, +} from "./safe-operation.util.js"; // Retry utilities export { diff --git a/apps/bff/src/core/utils/safe-operation.util.ts b/apps/bff/src/core/utils/safe-operation.util.ts index 4b2c6391..9e0569a9 100644 --- a/apps/bff/src/core/utils/safe-operation.util.ts +++ b/apps/bff/src/core/utils/safe-operation.util.ts @@ -39,7 +39,7 @@ export interface SafeOperationOptions { metadata?: Record; /** Exception types to rethrow as-is (only applicable when criticality is CRITICAL) */ - rethrow?: Array Error>; + rethrow?: Array Error>; /** Custom message for InternalServerErrorException wrapper (only applicable when criticality is CRITICAL) */ fallbackMessage?: string; @@ -89,7 +89,15 @@ export async function safeOperation( executor: () => Promise, options: SafeOperationOptions ): Promise { - const { criticality, fallback, context, logger, metadata = {}, rethrow, fallbackMessage } = options; + const { + criticality, + fallback, + context, + logger, + metadata = {}, + rethrow, + fallbackMessage, + } = options; try { return await executor(); diff --git a/apps/bff/src/infra/email/providers/sendgrid.provider.ts b/apps/bff/src/infra/email/providers/sendgrid.provider.ts index c219137b..07fc4d22 100644 --- a/apps/bff/src/infra/email/providers/sendgrid.provider.ts +++ b/apps/bff/src/infra/email/providers/sendgrid.provider.ts @@ -34,6 +34,7 @@ export interface ParsedSendGridError { /** HTTP status codes that indicate transient failures worth retrying */ const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); +const UNKNOWN_ERROR = "Unknown error"; @Injectable() export class SendGridEmailProvider implements OnModuleInit { @@ -199,20 +200,20 @@ export class SendGridEmailProvider implements OnModuleInit { private extractErrorsFromBody(body: unknown): SendGridErrorDetail[] { if (!body || typeof body !== "object") { - return [{ message: "Unknown error" }]; + return [{ message: UNKNOWN_ERROR }]; } // SendGrid returns { errors: [...] } in the body const bodyObj = body as { errors?: Array<{ message?: string; field?: string; help?: string }> }; if (Array.isArray(bodyObj.errors)) { return bodyObj.errors.map(e => ({ - message: e.message || "Unknown error", + message: e.message || UNKNOWN_ERROR, field: e.field, help: e.help, })); } - return [{ message: "Unknown error" }]; + return [{ message: UNKNOWN_ERROR }]; } private isSendGridError(error: unknown): error is ResponseError { diff --git a/apps/bff/src/infra/email/queue/email.processor.ts b/apps/bff/src/infra/email/queue/email.processor.ts index b2fc8d0a..84db1699 100644 --- a/apps/bff/src/infra/email/queue/email.processor.ts +++ b/apps/bff/src/infra/email/queue/email.processor.ts @@ -14,6 +14,8 @@ interface ParsedSendGridError { isRetryable: boolean; } +const SERVICE_NAME = "email-processor"; + /** TTL for idempotency keys (24 hours) - prevents duplicate sends on delayed retries */ const IDEMPOTENCY_TTL_SECONDS = 86400; const IDEMPOTENCY_PREFIX = "email:sent:"; @@ -31,7 +33,7 @@ export class EmailProcessor extends WorkerHost { async process(job: Job): Promise { const jobContext = { - service: "email-processor", + service: SERVICE_NAME, jobId: job.id, attempt: job.attemptsMade + 1, maxAttempts: job.opts.attempts || 3, @@ -88,7 +90,7 @@ export class EmailProcessor extends WorkerHost { @OnWorkerEvent("completed") onCompleted(job: Job): void { this.logger.debug("Email job marked complete", { - service: "email-processor", + service: SERVICE_NAME, jobId: job.id, category: job.data.category, }); @@ -98,7 +100,7 @@ export class EmailProcessor extends WorkerHost { onFailed(job: Job | undefined, error: Error): void { if (!job) { this.logger.error("Email job failed (job undefined)", { - service: "email-processor", + service: SERVICE_NAME, error: error.message, }); return; @@ -109,7 +111,7 @@ export class EmailProcessor extends WorkerHost { if (isLastAttempt) { this.logger.error("Email job permanently failed - all retries exhausted", { - service: "email-processor", + service: SERVICE_NAME, jobId: job.id, category: job.data.category, subject: job.data.subject, @@ -119,7 +121,7 @@ export class EmailProcessor extends WorkerHost { }); } else { this.logger.warn("Email job failed - will retry", { - service: "email-processor", + service: SERVICE_NAME, jobId: job.id, category: job.data.category, attempt: job.attemptsMade, @@ -133,7 +135,7 @@ export class EmailProcessor extends WorkerHost { @OnWorkerEvent("error") onError(error: Error): void { this.logger.error("Email processor worker error", { - service: "email-processor", + service: SERVICE_NAME, error: error.message, stack: error.stack, }); diff --git a/apps/bff/src/infra/email/queue/email.queue.ts b/apps/bff/src/infra/email/queue/email.queue.ts index 344690aa..3662d92e 100644 --- a/apps/bff/src/infra/email/queue/email.queue.ts +++ b/apps/bff/src/infra/email/queue/email.queue.ts @@ -14,6 +14,8 @@ export interface EmailJobResult { queued: boolean; } +const SERVICE_NAME = "email-queue"; + /** Queue configuration constants */ const QUEUE_CONFIG = { /** Keep last N completed jobs for debugging */ @@ -35,7 +37,7 @@ export class EmailQueueService { async enqueueEmail(data: EmailJobData): Promise { const jobContext = { - service: "email-queue", + service: SERVICE_NAME, queue: QUEUE_NAMES.EMAIL, category: data.category || "transactional", recipientCount: Array.isArray(data.to) ? data.to.length : 1, @@ -108,7 +110,7 @@ export class EmailQueueService { const job = await this.queue.getJob(jobId); if (!job) { this.logger.warn("Job not found for retry", { - service: "email-queue", + service: SERVICE_NAME, jobId, }); return; @@ -116,7 +118,7 @@ export class EmailQueueService { await job.retry(); this.logger.log("Job retry triggered", { - service: "email-queue", + service: SERVICE_NAME, jobId, category: job.data.category, }); 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 54796be7..f925e269 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -255,19 +255,26 @@ export class FreebitClientService { } const timestamp = this.testTracker.getCurrentTimestamp(); - const resultCode = response?.resultCode - ? String(response.resultCode) - : error instanceof FreebitError - ? String(error.resultCode || "ERROR") - : "ERROR"; - const statusMessage = - response?.status?.message || - (error instanceof FreebitError - ? error.message - : error - ? extractErrorMessage(error) - : "Success"); + let resultCode: string; + if (response?.resultCode) { + resultCode = String(response.resultCode); + } else if (error instanceof FreebitError) { + resultCode = String(error.resultCode || "ERROR"); + } else { + resultCode = "ERROR"; + } + + let statusMessage: string; + if (response?.status?.message) { + statusMessage = response.status.message; + } else if (error instanceof FreebitError) { + statusMessage = error.message; + } else if (error) { + statusMessage = extractErrorMessage(error); + } else { + statusMessage = "Success"; + } await this.testTracker.logApiCall({ timestamp, 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 08a806dd..09745ab4 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -11,6 +11,8 @@ import type { } from "../interfaces/freebit.types.js"; import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js"; +const NOT_IN_API_RESPONSE = "(not in API response)"; + @Injectable() export class FreebitMapperService { constructor( @@ -143,9 +145,9 @@ export class FreebitMapperService { "[FreebitMapper] No stored options found, using API values (default: disabled)", { account: account.account, - rawVoiceMail: account.voicemail ?? account.voiceMail ?? "(not in API response)", - rawCallWaiting: account.callwaiting ?? account.callWaiting ?? "(not in API response)", - rawWorldWing: account.worldwing ?? account.worldWing ?? "(not in API response)", + rawVoiceMail: account.voicemail ?? account.voiceMail ?? NOT_IN_API_RESPONSE, + rawCallWaiting: account.callwaiting ?? account.callWaiting ?? NOT_IN_API_RESPONSE, + rawWorldWing: account.worldwing ?? account.worldWing ?? NOT_IN_API_RESPONSE, parsedVoiceMailEnabled: voiceMailEnabled, parsedCallWaitingEnabled: callWaitingEnabled, parsedInternationalRoamingEnabled: internationalRoamingEnabled, diff --git a/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts index 8a3cab7d..a6877dc6 100644 --- a/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts @@ -362,11 +362,14 @@ export class AccountCreationWorkflowService { }); // Step 6: Generate Auth Result - const auditSource = withEligibility - ? "signup_with_eligibility" - : isNewCustomer - ? "get_started_new_customer" - : "get_started_complete_account"; + let auditSource: string; + if (withEligibility) { + auditSource = "signup_with_eligibility"; + } else if (isNewCustomer) { + auditSource = "get_started_new_customer"; + } else { + auditSource = "get_started_complete_account"; + } const authResult = await this.authResultStep.execute({ userId: portalUserResult.userId, diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index ea33c38e..27f057f3 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -43,8 +43,11 @@ export class CheckoutService { */ private summarizeSelectionsForLog(selections: OrderSelections): Record { const addons = this.collectAddonRefs(selections); - const normalizeBool = (value?: string) => - value === "true" ? true : value === "false" ? false : undefined; + const normalizeBool = (value?: string): boolean | undefined => { + if (value === "true") return true; + if (value === "false") return false; + return undefined; + }; return { planSku: selections.planSku, diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 9bb7fabb..d4fa4292 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -19,6 +19,9 @@ import type { import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; +const SF_ACCOUNT_ID_FIELD = "sfAccountId"; +const ORDER_NOT_FOUND = "Order not found"; + type OrderDetailsResponse = OrderDetails; type OrderSummaryResponse = OrderSummary; @@ -214,18 +217,18 @@ export class OrderOrchestrator { async getOrderForUser(orderId: string, userId: string): Promise { const userMapping = await this.orderValidator.validateUserMapping(userId); const sfAccountId = userMapping.sfAccountId - ? assertSalesforceId(userMapping.sfAccountId, "sfAccountId") + ? assertSalesforceId(userMapping.sfAccountId, SF_ACCOUNT_ID_FIELD) : null; if (!sfAccountId) { this.logger.warn({ userId }, "User mapping missing Salesforce account ID"); - throw new NotFoundException("Order not found"); + throw new NotFoundException(ORDER_NOT_FOUND); } const safeOrderId = assertSalesforceId(orderId, "orderId"); const order = await this.getOrder(safeOrderId); if (!order) { - throw new NotFoundException("Order not found"); + throw new NotFoundException(ORDER_NOT_FOUND); } if (!order.accountId || order.accountId !== sfAccountId) { @@ -238,7 +241,7 @@ export class OrderOrchestrator { }, "Order access denied due to account mismatch" ); - throw new NotFoundException("Order not found"); + throw new NotFoundException(ORDER_NOT_FOUND); } return order; @@ -253,7 +256,7 @@ export class OrderOrchestrator { // Get user mapping const userMapping = await this.orderValidator.validateUserMapping(userId); const sfAccountId = userMapping.sfAccountId - ? assertSalesforceId(userMapping.sfAccountId, "sfAccountId") + ? assertSalesforceId(userMapping.sfAccountId, SF_ACCOUNT_ID_FIELD) : undefined; if (!sfAccountId) { diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 085b3b69..caf7544c 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -4,6 +4,7 @@ import { ZodError } from "zod"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { normalizeToArray } from "@bff/core/utils/array.util.js"; import { orderWithSkuValidationSchema, type CreateOrderRequest, @@ -82,11 +83,7 @@ export class OrderValidator { try { const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); const productContainer = products.products?.product; - const existing = Array.isArray(productContainer) - ? productContainer - : productContainer - ? [productContainer] - : []; + const existing = normalizeToArray(productContainer); // Check for active Internet products const activeInternetProducts = existing.filter((product: WhmcsProduct) => { 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 4f9c21e7..0cee296f 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -141,15 +141,15 @@ export class SimFulfillmentService { const scheduledAt = this.readString(configurations["scheduledAt"]); const phoneNumber = this.readString(configurations["mnpPhone"]); const mnp = this.extractMnpConfig(configurations); - const isMnp = !!mnp?.reserveNumber; + const isMnp = !!mnp?.["reserveNumber"]; this.logger.log("MNP detection result", { orderId: orderDetails.id, isMnp, simType, - mnpReserveNumber: mnp?.reserveNumber, - mnpHasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji), - mnpGender: mnp?.gender, + mnpReserveNumber: mnp?.["reserveNumber"], + mnpHasIdentity: !!(mnp?.["lastnameKanji"] || mnp?.["firstnameKanji"]), + mnpGender: mnp?.["gender"], }); const simPlanItem = orderDetails.items.find( @@ -173,93 +173,152 @@ export class SimFulfillmentService { } if (simType === "eSIM") { - // eSIM activation flow - if (!eid || eid.length < 15) { - throw new SimActivationException("EID is required for eSIM and must be valid", { - orderId: orderDetails.id, - simType, - eidLength: eid?.length, - }); - } - - if (!phoneNumber) { - throw new SimActivationException("Phone number is required for eSIM activation", { - orderId: orderDetails.id, - }); - } - - // Map product SKU to Freebit plan code (same as physical SIM path) - const planCode = mapProductToFreebitPlanCode(planSku, planName); - if (!planCode) { - throw new SimActivationException( - `Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`, - { orderId: orderDetails.id, planSku, planName } - ); - } - - await this.activateEsim({ - account: phoneNumber, + return this.fulfillEsim({ + orderDetails, eid, - planCode, - activationType, - ...(scheduledAt && { scheduledAt }), - ...(mnp && { mnp }), - }); - - this.logger.log("eSIM fulfillment completed successfully", { - orderId: orderDetails.id, - account: phoneNumber, - planSku, - }); - - return { - activated: true, - simType: "eSIM", phoneNumber, - eid, - }; - } else { - // Physical SIM activation flow: - // Non-MNP: PA02-01 + PA05-05 - // MNP: PA05-19 + PA05-05 - if (!assignedPhysicalSimId) { - throw new SimActivationException( - "Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)", - { orderId: orderDetails.id } - ); - } - - const simData = await this.activatePhysicalSim({ - orderId: orderDetails.id, - simInventoryId: assignedPhysicalSimId, planSku, planName, - voiceMailEnabled, - callWaitingEnabled, - contactIdentity, - assignmentDetails, - isMnp, - ...(mnp && { mnp }), + activationType, + scheduledAt, + mnp, }); - - this.logger.log("Physical SIM fulfillment completed successfully", { - orderId: orderDetails.id, - simInventoryId: assignedPhysicalSimId, - planSku, - voiceMailEnabled, - callWaitingEnabled, - phoneNumber: simData.phoneNumber, - serialNumber: simData.serialNumber, - }); - - return { - activated: true, - simType: "Physical SIM", - phoneNumber: simData.phoneNumber, - serialNumber: simData.serialNumber, - simInventoryId: assignedPhysicalSimId, - }; } + + return this.fulfillPhysicalSim({ + orderDetails, + assignedPhysicalSimId, + planSku, + planName, + voiceMailEnabled, + callWaitingEnabled, + contactIdentity, + assignmentDetails, + isMnp, + mnp, + }); + } + + private async fulfillEsim(params: { + orderDetails: OrderDetails; + eid: string | undefined; + phoneNumber: string | undefined; + planSku: string; + planName: string | undefined; + activationType: "Immediate" | "Scheduled"; + scheduledAt: string | undefined; + mnp: MnpConfig | undefined; + }): Promise { + const { orderDetails, eid, phoneNumber, planSku, planName, activationType, scheduledAt, mnp } = + params; + + if (!eid || eid.length < 15) { + throw new SimActivationException("EID is required for eSIM and must be valid", { + orderId: orderDetails.id, + simType: "eSIM", + eidLength: eid?.length, + }); + } + + if (!phoneNumber) { + throw new SimActivationException("Phone number is required for eSIM activation", { + orderId: orderDetails.id, + }); + } + + const planCode = mapProductToFreebitPlanCode(planSku, planName); + if (!planCode) { + throw new SimActivationException( + `Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`, + { orderId: orderDetails.id, planSku, planName } + ); + } + + await this.activateEsim({ + account: phoneNumber, + eid, + planCode, + activationType, + ...(scheduledAt && { scheduledAt }), + ...(mnp && { mnp }), + }); + + this.logger.log("eSIM fulfillment completed successfully", { + orderId: orderDetails.id, + account: phoneNumber, + planSku, + }); + + return { + activated: true, + simType: "eSIM", + phoneNumber, + eid, + }; + } + + private async fulfillPhysicalSim(params: { + orderDetails: OrderDetails; + assignedPhysicalSimId: string | undefined; + planSku: string; + planName: string | undefined; + voiceMailEnabled: boolean; + callWaitingEnabled: boolean; + contactIdentity: ContactIdentityData | undefined; + assignmentDetails: SimAssignmentDetails | undefined; + isMnp: boolean; + mnp: MnpConfig | undefined; + }): Promise { + const { + orderDetails, + assignedPhysicalSimId, + planSku, + planName, + voiceMailEnabled, + callWaitingEnabled, + contactIdentity, + assignmentDetails, + isMnp, + mnp, + } = params; + + if (!assignedPhysicalSimId) { + throw new SimActivationException( + "Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)", + { orderId: orderDetails.id } + ); + } + + const simData = await this.activatePhysicalSim({ + orderId: orderDetails.id, + simInventoryId: assignedPhysicalSimId, + planSku, + planName, + voiceMailEnabled, + callWaitingEnabled, + contactIdentity, + assignmentDetails, + isMnp, + ...(mnp && { mnp }), + }); + + this.logger.log("Physical SIM fulfillment completed successfully", { + orderId: orderDetails.id, + simInventoryId: assignedPhysicalSimId, + planSku, + voiceMailEnabled, + callWaitingEnabled, + phoneNumber: simData.phoneNumber, + serialNumber: simData.serialNumber, + }); + + return { + activated: true, + simType: "Physical SIM", + phoneNumber: simData.phoneNumber, + serialNumber: simData.serialNumber, + simInventoryId: assignedPhysicalSimId, + }; } /** @@ -289,20 +348,7 @@ export class SimFulfillmentService { try { // Build unified MNP object with both reservation and identity data (all Level 2 per PA05-41) - const mnpPayload = - isMnp && mnp?.reserveNumber - ? { - reserveNumber: mnp.reserveNumber, - ...(mnp.reserveExpireDate && { reserveExpireDate: mnp.reserveExpireDate }), - ...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }), - ...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }), - ...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }), - ...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }), - // Map Salesforce gender 'F' → Freebit gender 'W' (Weiblich) - ...(mnp.gender && { gender: mapGenderToFreebit(mnp.gender) }), - ...(mnp.birthday && { birthday: mnp.birthday }), - } - : undefined; + const mnpPayload = isMnp ? this.buildMnpPayload(mnp) : undefined; const addKind = isMnp ? ("M" as const) : ("N" as const); const aladinOperated = isMnp ? ("20" as const) : ("10" as const); @@ -479,39 +525,13 @@ export class SimFulfillmentService { } // Step 5: Call Freebit PA05-05 (Voice Options Registration) - // Only call if we have contact identity data - if (contactIdentity) { - this.logger.log("Calling PA05-05 Voice Options Registration", { - orderId, - account: accountPhoneNumber, - voiceMailEnabled, - callWaitingEnabled, - }); - - await this.freebitFacade.registerVoiceOptions({ - account: accountPhoneNumber, - voiceMailEnabled, - callWaitingEnabled, - identificationData: { - lastnameKanji: contactIdentity.lastnameKanji, - firstnameKanji: contactIdentity.firstnameKanji, - lastnameKana: contactIdentity.lastnameKana, - firstnameKana: contactIdentity.firstnameKana, - gender: contactIdentity.gender, - birthday: contactIdentity.birthday, - }, - }); - - this.logger.log("PA05-05 Voice Options Registration successful", { - orderId, - account: accountPhoneNumber, - }); - } else { - this.logger.warn("Skipping PA05-05: No contact identity data provided", { - orderId, - account: accountPhoneNumber, - }); - } + await this.registerVoiceOptionsIfAvailable({ + orderId, + account: accountPhoneNumber, + voiceMailEnabled, + callWaitingEnabled, + contactIdentity, + }); // Step 6: Update SIM Inventory status to "Assigned" with assignment details await this.simInventory.markAsAssigned(simInventoryId, assignmentDetails); @@ -541,6 +561,65 @@ export class SimFulfillmentService { } } + private buildMnpPayload(mnp?: MnpConfig) { + if (!mnp?.reserveNumber) return; + return { + reserveNumber: mnp.reserveNumber, + ...(mnp.reserveExpireDate && { reserveExpireDate: mnp.reserveExpireDate }), + ...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }), + ...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }), + ...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }), + ...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }), + // Map Salesforce gender 'F' → Freebit gender 'W' (Weiblich) + ...(mnp.gender && { gender: mapGenderToFreebit(mnp.gender) }), + ...(mnp.birthday && { birthday: mnp.birthday }), + }; + } + + private async registerVoiceOptionsIfAvailable(params: { + orderId: string; + account: string; + voiceMailEnabled: boolean; + callWaitingEnabled: boolean; + contactIdentity?: ContactIdentityData | undefined; + }): Promise { + const { orderId, account, voiceMailEnabled, callWaitingEnabled, contactIdentity } = params; + + if (!contactIdentity) { + this.logger.warn("Skipping PA05-05: No contact identity data provided", { + orderId, + account, + }); + return; + } + + this.logger.log("Calling PA05-05 Voice Options Registration", { + orderId, + account, + voiceMailEnabled, + callWaitingEnabled, + }); + + await this.freebitFacade.registerVoiceOptions({ + account, + voiceMailEnabled, + callWaitingEnabled, + identificationData: { + lastnameKanji: contactIdentity.lastnameKanji, + firstnameKanji: contactIdentity.firstnameKanji, + lastnameKana: contactIdentity.lastnameKana, + firstnameKana: contactIdentity.firstnameKana, + gender: contactIdentity.gender, + birthday: contactIdentity.birthday, + }, + }); + + this.logger.log("PA05-05 Voice Options Registration successful", { + orderId, + account, + }); + } + private readString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -549,6 +628,18 @@ export class SimFulfillmentService { return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined; } + private static readonly MNP_FIELD_MAPPINGS = [ + { key: "reserveNumber", sources: ["mnpNumber", "reserveNumber"] }, + { key: "reserveExpireDate", sources: ["mnpExpiry", "reserveExpireDate"] }, + { key: "account", sources: ["mvnoAccountNumber", "account"] }, + { key: "firstnameKanji", sources: ["portingFirstName", "firstnameKanji"] }, + { key: "lastnameKanji", sources: ["portingLastName", "lastnameKanji"] }, + { key: "firstnameZenKana", sources: ["portingFirstNameKatakana", "firstnameZenKana"] }, + { key: "lastnameZenKana", sources: ["portingLastNameKatakana", "lastnameZenKana"] }, + { key: "gender", sources: ["portingGender", "gender"] }, + { key: "birthday", sources: ["portingDateOfBirth", "birthday"] }, + ] as const; + private extractMnpConfig(config: FulfillmentConfigurations) { const nested = config["mnp"]; const hasNestedMnp = nested && typeof nested === "object"; @@ -563,69 +654,30 @@ export class SimFulfillmentService { return; } - const reserveNumber = this.readString(source["mnpNumber"] ?? source["reserveNumber"]); - const reserveExpireDate = this.readString(source["mnpExpiry"] ?? source["reserveExpireDate"]); - const account = this.readString(source["mvnoAccountNumber"] ?? source["account"]); - const firstnameKanji = this.readString(source["portingFirstName"] ?? source["firstnameKanji"]); - const lastnameKanji = this.readString(source["portingLastName"] ?? source["lastnameKanji"]); - const firstnameZenKana = this.readString( - source["portingFirstNameKatakana"] ?? source["firstnameZenKana"] - ); - const lastnameZenKana = this.readString( - source["portingLastNameKatakana"] ?? source["lastnameZenKana"] - ); - const gender = this.readString(source["portingGender"] ?? source["gender"]); - const birthday = this.readString(source["portingDateOfBirth"] ?? source["birthday"]); + const result: Record = {}; + for (const { key, sources } of SimFulfillmentService.MNP_FIELD_MAPPINGS) { + const value = this.readString(source[sources[0]] ?? source[sources[1]]); + if (value) result[key] = value; + } - if ( - !reserveNumber && - !reserveExpireDate && - !account && - !firstnameKanji && - !lastnameKanji && - !firstnameZenKana && - !lastnameZenKana && - !gender && - !birthday - ) { + if (Object.keys(result).length === 0) { this.logger.log("MNP extraction: no MNP fields found in config", { hasNestedMnp, isMnpFlag: isMnpFlag ?? "not-set", - checkedKeys: [ - "mnpNumber", - "reserveNumber", - "mnpExpiry", - "portingFirstName", - "portingLastName", - "portingGender", - "portingDateOfBirth", - ], + checkedKeys: SimFulfillmentService.MNP_FIELD_MAPPINGS.flatMap(m => m.sources), }); return; } - // Build object with only defined properties (for exactOptionalPropertyTypes) - const result = { - ...(reserveNumber && { reserveNumber }), - ...(reserveExpireDate && { reserveExpireDate }), - ...(account && { account }), - ...(firstnameKanji && { firstnameKanji }), - ...(lastnameKanji && { lastnameKanji }), - ...(firstnameZenKana && { firstnameZenKana }), - ...(lastnameZenKana && { lastnameZenKana }), - ...(gender && { gender }), - ...(birthday && { birthday }), - }; - this.logger.log("MNP config extracted", { - hasReserveNumber: !!reserveNumber, - reserveNumberLength: reserveNumber?.length, - hasReserveExpireDate: !!reserveExpireDate, - hasAccount: !!account, - hasIdentity: !!(firstnameKanji && lastnameKanji), - hasKana: !!(firstnameZenKana && lastnameZenKana), - gender: gender ?? "not-set", - hasBirthday: !!birthday, + hasReserveNumber: !!result["reserveNumber"], + reserveNumberLength: result["reserveNumber"]?.length, + hasReserveExpireDate: !!result["reserveExpireDate"], + hasAccount: !!result["account"], + hasIdentity: !!(result["firstnameKanji"] && result["lastnameKanji"]), + hasKana: !!(result["firstnameZenKana"] && result["lastnameZenKana"]), + gender: result["gender"] ?? "not-set", + hasBirthday: !!result["birthday"], totalFieldsExtracted: Object.keys(result).length, }); diff --git a/apps/bff/src/modules/orders/validators/internet-order.validator.ts b/apps/bff/src/modules/orders/validators/internet-order.validator.ts index 38adee14..5971bf0d 100644 --- a/apps/bff/src/modules/orders/validators/internet-order.validator.ts +++ b/apps/bff/src/modules/orders/validators/internet-order.validator.ts @@ -3,6 +3,7 @@ import { Logger } from "nestjs-pino"; import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { normalizeToArray } from "@bff/core/utils/array.util.js"; import { ValidationErrorCode, createValidationError, @@ -75,11 +76,7 @@ export class InternetOrderValidator { try { const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); const productContainer = products.products?.product; - const existing = Array.isArray(productContainer) - ? productContainer - : productContainer - ? [productContainer] - : []; + const existing = normalizeToArray(productContainer); // Check for active Internet products const activeInternetProducts = existing.filter((product: WhmcsProduct) => { 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 43806bd4..1f0bab26 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 @@ -52,11 +52,14 @@ export class WorkflowCaseManager { ? this.buildLightningUrl("Opportunity", opportunityId) : null; - const opportunityStatus = opportunityId - ? opportunityCreated - ? "Created new opportunity for this order" - : "Linked to existing opportunity" - : "No opportunity linked"; + let opportunityStatus: string; + if (!opportunityId) { + opportunityStatus = "No opportunity linked"; + } else if (opportunityCreated) { + opportunityStatus = "Created new opportunity for this order"; + } else { + opportunityStatus = "Linked to existing opportunity"; + } const description = this.buildDescription([ "Order placed via Customer Portal.", diff --git a/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts b/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts index e4a297a3..1bcf0c16 100644 --- a/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts +++ b/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts @@ -20,10 +20,7 @@ import { type SimInternationalCallHistoryResponse, type SimSmsHistoryResponse, } from "@customer-portal/domain/sim"; - -// Cache-Control header constants -const CACHE_CONTROL_PUBLIC_1H = "public, max-age=3600"; -const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300"; +import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js"; // DTOs class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} @@ -56,7 +53,7 @@ export class CallHistoryController { */ @Public() @Get("sim/call-history/available-months") - @Header("Cache-Control", CACHE_CONTROL_PUBLIC_1H) + @Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H) @ZodResponse({ description: "Get available call/SMS history months", type: SimHistoryAvailableMonthsResponseDto, @@ -97,7 +94,7 @@ export class CallHistoryController { * Get domestic call history */ @Get(":id/sim/call-history/domestic") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get domestic call history", type: SimDomesticCallHistoryResponseDto, @@ -121,7 +118,7 @@ export class CallHistoryController { * Get international call history */ @Get(":id/sim/call-history/international") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get international call history", type: SimInternationalCallHistoryResponseDto, @@ -145,7 +142,7 @@ export class CallHistoryController { * Get SMS history */ @Get(":id/sim/sms-history") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get SMS history", type: SimSmsHistoryResponseDto }) async getSmsHistory( @Request() req: RequestWithUser, diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index 4568ee0d..34606393 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -18,6 +18,7 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js"; import { EmailService } from "@bff/infra/email/email.service.js"; +import { normalizeToArray } from "@bff/core/utils/array.util.js"; import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import { SubscriptionValidationCoordinator } from "../../shared/index.js"; import { @@ -75,11 +76,7 @@ export class InternetCancellationService { clientid: whmcsClientId, }); const productContainer = productsResponse.products?.product; - const products = Array.isArray(productContainer) - ? productContainer - : productContainer - ? [productContainer] - : []; + const products = normalizeToArray(productContainer); const subscription = products.find( (p: { id?: number | string }) => Number(p.id) === 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 35c5ee3e..7dc3b7eb 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 @@ -9,6 +9,11 @@ import { isSimSubscription, } from "@customer-portal/domain/sim"; +function getAccountSource(account: string | null | undefined, domain: string | undefined): string { + if (!account) return "NOT FOUND - check fields below"; + return domain ? "domain field" : "custom field or order number"; +} + @Injectable() export class SimValidationService { constructor( @@ -103,11 +108,7 @@ export class SimValidationService { status: subscription.status, // Account extraction result extractedAccount, - accountSource: extractedAccount - ? subscription.domain - ? "domain field" - : "custom field or order number" - : "NOT FOUND - check fields below", + accountSource: getAccountSource(extractedAccount, subscription.domain), // All custom fields for debugging customFieldKeys: Object.keys(subscription.customFields || {}), customFields: subscription.customFields, 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 9baf6bf9..3d3b61b9 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -47,6 +47,7 @@ import { type SimAvailablePlan, type SimCancellationPreview, } from "@customer-portal/domain/sim"; +import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js"; // DTOs class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} @@ -89,7 +90,7 @@ export class SimController { // ==================== Static SIM Routes (must be before :id routes) ==================== @Get("sim/top-up/pricing") - @Header("Cache-Control", "public, max-age=3600") + @Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H) @ZodResponse({ description: "Get SIM top-up pricing", type: SimTopUpPricingResponseDto }) async getSimTopUpPricing() { const pricing = await this.simTopUpPricingService.getTopUpPricing(); @@ -97,7 +98,7 @@ export class SimController { } @Get("sim/top-up/pricing/preview") - @Header("Cache-Control", "public, max-age=3600") + @Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H) @ZodResponse({ description: "Preview SIM top-up pricing", type: SimTopUpPricingPreviewResponseDto, @@ -220,7 +221,7 @@ export class SimController { // ==================== Enhanced SIM Management Endpoints ==================== @Get(":id/sim/available-plans") - @Header("Cache-Control", "private, max-age=300") + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get available SIM plans", type: SimAvailablePlansResponseDto }) async getAvailablePlans( @Request() req: RequestWithUser, @@ -245,7 +246,7 @@ export class SimController { } @Get(":id/sim/cancellation-preview") - @Header("Cache-Control", "private, max-age=60") + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_1M) @ZodResponse({ description: "Get SIM cancellation preview", type: SimCancellationPreviewResponseDto, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index b7bf6bc4..62e42712 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -18,10 +18,7 @@ import { Validation } from "@customer-portal/domain/toolkit"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { invoiceListSchema } from "@customer-portal/domain/billing"; - -// Cache-Control header constants -const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300"; -const CACHE_CONTROL_PRIVATE_1M = "private, max-age=60"; +import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js"; const subscriptionInvoiceQuerySchema = Validation.createPaginationSchema({ defaultLimit: 10, @@ -54,7 +51,7 @@ export class SubscriptionsController { constructor(private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator) {} @Get() - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "List subscriptions", type: SubscriptionListDto }) async getSubscriptions( @Request() req: RequestWithUser, @@ -68,21 +65,21 @@ export class SubscriptionsController { } @Get("active") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto }) async getActiveSubscriptions(@Request() req: RequestWithUser): Promise { return this.subscriptionsOrchestrator.getActiveSubscriptions(req.user.id); } @Get("stats") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto }) async getSubscriptionStats(@Request() req: RequestWithUser): Promise { return this.subscriptionsOrchestrator.getSubscriptionStats(req.user.id); } @Get(":id") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get subscription", type: SubscriptionDto }) async getSubscriptionById( @Request() req: RequestWithUser, @@ -92,7 +89,7 @@ export class SubscriptionsController { } @Get(":id/invoices") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_1M) + @Header("Cache-Control", CACHE_CONTROL.PRIVATE_1M) @ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto }) async getSubscriptionInvoices( @Request() req: RequestWithUser, diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index ab43549a..485842c9 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -24,6 +24,14 @@ import { basename, extname } from "node:path"; // Default fallback filename for residence card submissions const DEFAULT_FILENAME = "residence-card"; +function extractNonEmptyString(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + return null; +} + @Injectable() export class ResidenceCardService { // eslint-disable-next-line max-params -- NestJS DI requires injecting multiple services @@ -91,11 +99,7 @@ export class ResidenceCardService { const reviewedAt = normalizeSalesforceDateTimeToIsoUtc(verifiedAtRaw); const reviewerNotes = - typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0 - ? rejectionRaw.trim() - : typeof noteRaw === "string" && noteRaw.trim().length > 0 - ? noteRaw.trim() - : null; + extractNonEmptyString(rejectionRaw) ?? extractNonEmptyString(noteRaw) ?? null; return residenceCardVerificationSchema.parse({ status, diff --git a/packages/domain/opportunity/contract.ts b/packages/domain/opportunity/contract.ts index 253199db..b8608b01 100644 --- a/packages/domain/opportunity/contract.ts +++ b/packages/domain/opportunity/contract.ts @@ -561,6 +561,12 @@ export function getCustomerPhaseFromStage( } } +function getStepStatus(index: number, currentStep: number): "completed" | "current" | "upcoming" { + if (index < currentStep) return "completed"; + if (index === currentStep) return "current"; + return "upcoming"; +} + /** * Get order tracking progress steps * Used for customers who have placed an order but WHMCS is not yet active @@ -585,7 +591,7 @@ export function getOrderTrackingSteps( return stages.map((s, index) => ({ label: s.label, - status: index < currentStep ? "completed" : index === currentStep ? "current" : "upcoming", + status: getStepStatus(index, currentStep), })); } diff --git a/packages/domain/subscriptions/providers/whmcs/mapper.ts b/packages/domain/subscriptions/providers/whmcs/mapper.ts index 1824dd21..1be95b3b 100644 --- a/packages/domain/subscriptions/providers/whmcs/mapper.ts +++ b/packages/domain/subscriptions/providers/whmcs/mapper.ts @@ -28,6 +28,11 @@ import { normalizeCycle, } from "../../../common/providers/whmcs-utils/index.js"; +function normalizeToArray(value: T | T[] | undefined | null): T[] { + if (Array.isArray(value)) return value; + return value ? [value] : []; +} + export interface TransformSubscriptionOptions { defaultCurrencyCode?: string; defaultCurrencySymbol?: string; @@ -193,11 +198,7 @@ export function transformWhmcsSubscriptionListResponse( } const productContainer = parsed.products?.product; - const products = Array.isArray(productContainer) - ? productContainer - : productContainer - ? [productContainer] - : []; + const products = normalizeToArray(productContainer); const subscriptions: Subscription[] = []; for (const product of products) {